mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
Compare commits
23
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a87a10d12 | ||
|
|
10dc74e2a1 | ||
|
|
c00d581d9e | ||
|
|
f6f8bc33f9 | ||
|
|
7196adb84c | ||
|
|
a740a50e79 | ||
|
|
ffe797b3e4 | ||
|
|
9cd6061c0f | ||
|
|
67d86f7834 | ||
|
|
a6138fd62b | ||
|
|
d1763757be | ||
|
|
a4dfa542d1 | ||
|
|
9fb1d14c41 | ||
|
|
eb60ce4984 | ||
|
|
061086f0ad | ||
|
|
d1cb6277f9 | ||
|
|
02baed9bb6 | ||
|
|
55b792b23c | ||
|
|
7c3fa728f7 | ||
|
|
755d11f71c | ||
|
|
eeded5fc46 | ||
|
|
ac14864655 | ||
|
|
80316b1db7 |
+12
-3
@@ -1,8 +1,17 @@
|
||||
# WeChat Database Key (Optional, can be entered in UI)
|
||||
VITE_DB_KEY=
|
||||
|
||||
# DeepSeek API Key (Optional, can be entered in UI)
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
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=
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
node_modules
|
||||
dist
|
||||
out
|
||||
docs/
|
||||
.env
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
*.log*
|
||||
.omc
|
||||
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"[typescript]": {
|
||||
"editor.defaultFormatter": "dbaeumer.vscode-eslint"
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
},
|
||||
"[javascript]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024 WechatExplorer Contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -3,90 +3,93 @@
|
||||
MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||
|
||||
## 📸 预览
|
||||
## 项目说明
|
||||
|
||||
<div style="display: flex; justify-content: space-between;">
|
||||
<img src="./public/WX20251215-155324.png" alt="预览图1" width="48%" />
|
||||
<img src="./public/WX20251215-155524.png" alt="预览图2" width="48%" />
|
||||
</div>
|
||||
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **AI 智能总结**: 集成 DeepSeek AI,一键总结群聊精华内容,生成话题报告。
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||
- **问题与解答**: 整理群内的问答内容。
|
||||
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||
- **词云/关键词**: 可视化展示群聊关键词。
|
||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||
|
||||
## 🛠️ 技术栈
|
||||
## 📸 预览
|
||||
|
||||
- **Electron**: 跨平台桌面应用框架
|
||||
- **React**: 用于构建用户界面
|
||||
- **TypeScript**: 提供类型安全
|
||||
- **Vite**: 极速构建工具
|
||||
- **Better-SQLite3**: 高性能 SQLite 数据库操作
|
||||
- **OpenAI SDK**: 用于调用 DeepSeek API
|
||||
### 群聊总结长图
|
||||
|
||||
<img src="./public/example1.png" alt="总结图片" />
|
||||
|
||||
### 软件主页面
|
||||
|
||||
<img src="./public/example2.png" alt="软件主页面" />
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
## 📦 安装说明
|
||||
|
||||
1. 下载下方的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 前置要求
|
||||
### 使用前置要求
|
||||
|
||||
- 微信<=4.0 需要获取自己微信本地数据库的密码, 获取方式参考: [Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
|
||||
- 如果无法获取本地数据库密码 则无法使用当前项目
|
||||
- **微信版本**:
|
||||
- 微信 <= 4.0: 可正常使用,获取数据库密码方式参考:[Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
|
||||
- 微信 >= 4.0: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
||||
- Node.js (推荐 v16+)
|
||||
- pnpm (推荐) 或 npm
|
||||
- pnpm@7
|
||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
||||
- [DeepSeek API Key](https://www.deepseek.com/) (用于 AI 总结功能)
|
||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
||||
|
||||
### 安装
|
||||
### 环境变量配置 (.env)
|
||||
|
||||
```bash
|
||||
# 克隆项目
|
||||
git clone https://github.com/your-username/WechatExplorer.git
|
||||
可选配置项,可在 `.env` 文件中设置:
|
||||
|
||||
# 进入目录
|
||||
cd WechatExplorer
|
||||
| 变量名 | 说明 | 示例 |
|
||||
| ----------------------- | --------------------------- | --------------------------- |
|
||||
| `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` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
# 安装依赖
|
||||
pnpm install
|
||||
```
|
||||
#### 图片解密密钥说明
|
||||
|
||||
### 配置
|
||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
||||
|
||||
复制 `.env.example` 文件为 `.env`,并填写相关配置(可选,也可以在应用内设置):
|
||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
这两个密钥可以通过以下方式获取:
|
||||
|
||||
### 开发模式运行
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
### 打包构建
|
||||
|
||||
```bash
|
||||
# Windows
|
||||
pnpm build:win
|
||||
|
||||
# macOS
|
||||
pnpm build:mac
|
||||
|
||||
# Linux
|
||||
pnpm build:linux
|
||||
```
|
||||
1. 从 WeFlow/Chatlog 设置中导出
|
||||
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
||||
|
||||
## ⚠️ 免责声明
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
|
||||
## 📄 许可证
|
||||
|
||||
[MIT](./LICENSE) License
|
||||
|
||||
## 🔗 参考
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WechatExplorer](https://github.com/hicccc77/WechatExplorer)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 获取当前版本号(从 package.json 读取)
|
||||
VERSION=$(jq -r '.version' package.json)
|
||||
|
||||
# 格式化为新的 tag(在版本号前加上 "v")
|
||||
TAG="v${VERSION}"
|
||||
|
||||
# 输出当前生成的 tag
|
||||
echo "生成的 tag: $TAG"
|
||||
|
||||
# 创建并推送 tag 到远程
|
||||
git tag $TAG
|
||||
git push origin $TAG
|
||||
|
||||
echo "Tag $TAG 已经推送到远程仓库!"
|
||||
@@ -14,6 +14,11 @@ extraMetadata:
|
||||
main: out/main/index.js
|
||||
asarUnpack:
|
||||
- resources/**
|
||||
extraResources:
|
||||
- from: resources
|
||||
to: resources
|
||||
filter:
|
||||
- '**/*'
|
||||
win:
|
||||
executableName: wechatexplorer
|
||||
nsis:
|
||||
|
||||
+25
-5
@@ -1,8 +1,14 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"description": "mac 版本获取微信聊天记录, AI群聊总结助手",
|
||||
"keywords": ["wechat", "chat", "mac微信", "微信聊天记录", "AI群聊总结助手"],
|
||||
"keywords": [
|
||||
"wechat",
|
||||
"chat",
|
||||
"mac微信",
|
||||
"微信聊天记录",
|
||||
"AI群聊总结助手"
|
||||
],
|
||||
"author": "Qingmao",
|
||||
"main": "./out/main/index.js",
|
||||
"scripts": {
|
||||
@@ -17,7 +23,9 @@
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:unpack": "npm run build && electron-builder --dir",
|
||||
"build:win": "npm run build && electron-builder --win",
|
||||
"build:mac": "electron-vite build && electron-builder --mac",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -25,13 +33,16 @@
|
||||
"@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",
|
||||
@@ -55,5 +66,14 @@
|
||||
"electron",
|
||||
"esbuild"
|
||||
]
|
||||
},
|
||||
"build": {
|
||||
"artifactName": "${productName}-${version}-${os}-${arch}.${ext}",
|
||||
"mac": {
|
||||
"target": [
|
||||
"dmg",
|
||||
"zip"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+27
-6
@@ -10,6 +10,7 @@ specifiers:
|
||||
'@electron-toolkit/preload': ^3.0.2
|
||||
'@electron-toolkit/tsconfig': ^2.0.0
|
||||
'@electron-toolkit/utils': ^4.0.0
|
||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/node': ^22.19.1
|
||||
'@types/react': ^19.2.7
|
||||
@@ -24,11 +25,13 @@ 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
|
||||
|
||||
@@ -37,13 +40,16 @@ dependencies:
|
||||
'@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 +860,12 @@ packages:
|
||||
dev: true
|
||||
optional: true
|
||||
|
||||
/@rollup/rollup-darwin-arm64/4.62.2:
|
||||
resolution: {integrity: sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
dev: true
|
||||
|
||||
/@rollup/rollup-darwin-x64/4.53.3:
|
||||
resolution: {integrity: sha512-DZ8N4CSNfl965CmPktJ8oBnfYr3F8dTTNBQkRlffnUarJ2ohudQD17sZBa097J8xhQ26AwhHJ5mvUyQW8ddTsQ==}
|
||||
cpu: [x64]
|
||||
@@ -2717,6 +2729,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'}
|
||||
@@ -2928,10 +2944,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==}
|
||||
|
||||
@@ -3387,6 +3399,10 @@ packages:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
/koffi/2.16.2:
|
||||
resolution: {integrity: sha512-owU0MRwv6xkrVqCd+33uw6BaYppkTRXbO/rVdJNI2dvZG0gzyRhYwW25eWtc5pauwK8TGh3AbkFONSezdykfSA==}
|
||||
dev: false
|
||||
|
||||
/lazy-val/1.0.5:
|
||||
resolution: {integrity: sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==}
|
||||
dev: true
|
||||
@@ -4335,6 +4351,11 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
dev: true
|
||||
|
||||
/silk-wasm/3.7.1:
|
||||
resolution: {integrity: sha512-mXPwLRtZxrYV3TZx41jMAeKc80wvmyrcXIcs8HctFxK15Ahz2OJQENYhNgEPeCEOdI6Mbx1NxQsqxzwc3DKerw==}
|
||||
engines: {node: '>=16.11.0'}
|
||||
dev: false
|
||||
|
||||
/simple-concat/1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
dev: false
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 138 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 463 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 583 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
Executable
BIN
Binary file not shown.
@@ -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>
|
||||
Executable
BIN
Binary file not shown.
Vendored
+9
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_DEEPSEEK_API_KEY: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
|
||||
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) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,525 @@
|
||||
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 === 0) {
|
||||
console.log('[ImageDecrypt] using V3 (XOR only)')
|
||||
decrypted = this.decryptDatV3(datPath)
|
||||
} else if (version === 1) {
|
||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else {
|
||||
// version === 2
|
||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
console.log('[ImageDecrypt] no AES key configured')
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
}
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('[ImageDecrypt] decrypt error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的图片转换为 base64
|
||||
*/
|
||||
decryptImageToBase64(datPath: string): string | null {
|
||||
if (!extname(datPath).toLowerCase().includes('dat')) {
|
||||
const data = readFileSync(datPath)
|
||||
const ext = this.detectImageExtension(data) || extname(datPath).toLowerCase()
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${data.toString('base64')}`
|
||||
}
|
||||
|
||||
const decrypted = this.decryptImage(datPath)
|
||||
if (!decrypted) return null
|
||||
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
console.log('[ImageDecrypt] unknown image format')
|
||||
return null
|
||||
}
|
||||
|
||||
const mimeType = this.getMimeType(ext)
|
||||
return `data:${mimeType};base64,${unwrapped.toString('base64')}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本
|
||||
*/
|
||||
private getDatVersion(inputPath: string): number {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 6) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const signature = bytes.subarray(0, 6)
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x31, 0x08, 0x07]))) {
|
||||
return 1
|
||||
}
|
||||
if (this.compareBytes(signature, Buffer.from([0x07, 0x08, 0x56, 0x32, 0x08, 0x07]))) {
|
||||
return 2
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
* V3 解密 - 仅 XOR
|
||||
*/
|
||||
private decryptDatV3(inputPath: string): Buffer {
|
||||
const data = readFileSync(inputPath)
|
||||
const out = Buffer.alloc(data.length)
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
out[i] = data[i] ^ this.xorKey
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* V4 解密 - AES + XOR
|
||||
*/
|
||||
private decryptDatV4(inputPath: string, aesKey: Buffer): Buffer {
|
||||
const bytes = readFileSync(inputPath)
|
||||
if (bytes.length < 0x0f) {
|
||||
throw new Error('文件太小,无法解析')
|
||||
}
|
||||
|
||||
const header = bytes.subarray(0, 0x0f)
|
||||
const data = bytes.subarray(0x0f)
|
||||
|
||||
const aesSize = this.bytesToInt32(header.subarray(6, 10))
|
||||
const xorSize = this.bytesToInt32(header.subarray(10, 14))
|
||||
|
||||
// 对齐 AES 数据到 16 字节边界
|
||||
const remainder = ((aesSize % 16) + 16) % 16
|
||||
const alignedAesSize = aesSize + (16 - remainder)
|
||||
|
||||
if (alignedAesSize > data.length) {
|
||||
throw new Error('文件格式异常:AES 数据长度超过文件实际长度')
|
||||
}
|
||||
|
||||
// 解密 AES 数据
|
||||
const aesData = data.subarray(0, alignedAesSize)
|
||||
let unpadded: Buffer = Buffer.alloc(0)
|
||||
if (aesData.length > 0) {
|
||||
const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
|
||||
decipher.setAutoPadding(false)
|
||||
const decrypted = Buffer.concat([decipher.update(aesData), decipher.final()])
|
||||
unpadded = this.strictRemovePadding(decrypted)
|
||||
}
|
||||
|
||||
// 解密 XOR 数据
|
||||
const remaining = data.subarray(alignedAesSize)
|
||||
if (xorSize < 0 || xorSize > remaining.length) {
|
||||
throw new Error('文件格式异常:XOR 数据长度不合法')
|
||||
}
|
||||
|
||||
let rawData: Buffer
|
||||
let xoredData: Buffer
|
||||
if (xorSize > 0) {
|
||||
const rawLength = remaining.length - xorSize
|
||||
if (rawLength < 0) {
|
||||
throw new Error('文件格式异常:原始数据长度小于XOR长度')
|
||||
}
|
||||
rawData = remaining.subarray(0, rawLength)
|
||||
const xorData = remaining.subarray(rawLength)
|
||||
xoredData = Buffer.alloc(xorData.length)
|
||||
for (let i = 0; i < xorData.length; i += 1) {
|
||||
xoredData[i] = xorData[i] ^ this.xorKey
|
||||
}
|
||||
} else {
|
||||
rawData = remaining
|
||||
xoredData = Buffer.alloc(0)
|
||||
}
|
||||
|
||||
return Buffer.concat([unpadded, rawData, xoredData])
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测图片扩展名
|
||||
*/
|
||||
private detectImageExtension(buffer: Buffer): string | null {
|
||||
if (buffer.length < 4) return null
|
||||
|
||||
const SIGNATURES: Record<string, Buffer> = {
|
||||
'.jpg': Buffer.from([0xff, 0xd8, 0xff]),
|
||||
'.png': Buffer.from([0x89, 0x50, 0x4e, 0x47]),
|
||||
'.gif': Buffer.from([0x47, 0x49, 0x46, 0x38]),
|
||||
'.bmp': Buffer.from([0x42, 0x4d]),
|
||||
'.webp': Buffer.from([0x52, 0x49, 0x46, 0x46])
|
||||
}
|
||||
|
||||
for (const [ext, sig] of Object.entries(SIGNATURES)) {
|
||||
if (this.compareBytes(buffer.subarray(0, sig.length), sig)) {
|
||||
return ext
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
private getMimeType(ext: string): string {
|
||||
const mimeTypes: Record<string, string> = {
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.bmp': 'image/bmp',
|
||||
'.webp': 'image/webp'
|
||||
}
|
||||
return mimeTypes[ext] || 'image/jpeg'
|
||||
}
|
||||
|
||||
private normalizeDatBase(value: string): string {
|
||||
const lower = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!lower) return ''
|
||||
const file = lower.split('/').pop()?.split('\\').pop() || lower
|
||||
const withoutDat = file.endsWith('.dat') ? file.slice(0, -4) : file
|
||||
return withoutDat.replace(/(_thumb|\.thumb|_hd|\.hd|_h|\.h|_t|\.t|_c|\.c)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
private buildPreferredDatNames(baseName: string): string[] {
|
||||
const base = this.normalizeDatBase(baseName)
|
||||
if (!base) return []
|
||||
return [
|
||||
`${base}_h.dat`,
|
||||
`${base}.dat`,
|
||||
`${base}_hd.dat`,
|
||||
`${base}_c.dat`,
|
||||
`${base}_t.dat`,
|
||||
`${base}.thumb.dat`,
|
||||
`${base}_thumb.dat`
|
||||
]
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
||||
const actualDir = dirname(inputPath)
|
||||
const base = this.normalizeDatBase(basename(inputPath))
|
||||
const variants = this.buildPreferredDatNames(base)
|
||||
const ordered = allowThumbnail
|
||||
? variants
|
||||
: variants.filter((name) => !this.isThumbnailName(name))
|
||||
for (const variant of ordered) {
|
||||
const candidate = join(actualDir, variant)
|
||||
if (existsSync(candidate)) return candidate
|
||||
}
|
||||
return inputPath
|
||||
}
|
||||
|
||||
private isThumbnailName(fileName: string): boolean {
|
||||
const lower = fileName.toLowerCase()
|
||||
return lower.includes('_t.dat') || lower.includes('_thumb.dat') || lower.includes('.thumb.dat')
|
||||
}
|
||||
|
||||
private unwrapWxgf(buffer: Buffer): Buffer {
|
||||
if (
|
||||
buffer.length < 20 ||
|
||||
buffer[0] !== 0x77 ||
|
||||
buffer[1] !== 0x78 ||
|
||||
buffer[2] !== 0x67 ||
|
||||
buffer[3] !== 0x66
|
||||
) {
|
||||
return buffer
|
||||
}
|
||||
|
||||
for (let i = 4; i < Math.min(buffer.length - 12, 4096); i += 1) {
|
||||
if (buffer[i] === 0xff && buffer[i + 1] === 0xd8 && buffer[i + 2] === 0xff) {
|
||||
return buffer.subarray(i)
|
||||
}
|
||||
if (
|
||||
buffer[i] === 0x89 &&
|
||||
buffer[i + 1] === 0x50 &&
|
||||
buffer[i + 2] === 0x4e &&
|
||||
buffer[i + 3] === 0x47
|
||||
) {
|
||||
return buffer.subarray(i)
|
||||
}
|
||||
}
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
private uniq(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => value.trim()).filter(Boolean)))
|
||||
}
|
||||
|
||||
private bytesToInt32(bytes: Buffer): number {
|
||||
return bytes[0] | (bytes[1] << 8) | (bytes[2] << 16) | (bytes[3] << 24)
|
||||
}
|
||||
|
||||
private compareBytes(a: Buffer, b: Buffer): boolean {
|
||||
if (a.length !== b.length) return false
|
||||
for (let i = 0; i < a.length; i += 1) {
|
||||
if (a[i] !== b[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private strictRemovePadding(buffer: Buffer): Buffer {
|
||||
if (buffer.length === 0) return buffer
|
||||
const lastByte = buffer[buffer.length - 1]
|
||||
if (lastByte <= 16 && lastByte > 0) {
|
||||
const paddingLength = lastByte
|
||||
let valid = true
|
||||
for (let i = buffer.length - paddingLength; i < buffer.length; i++) {
|
||||
if (buffer[i] !== lastByte) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (valid) {
|
||||
return buffer.subarray(0, buffer.length - paddingLength)
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
+254
-30
@@ -3,14 +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 } : {}),
|
||||
@@ -41,6 +91,7 @@ function createWindow(): void {
|
||||
// 当 Electron 完成初始化并准备好创建浏览器窗口时,将调用此方法
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(() => {
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
// 为窗口设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.electron')
|
||||
|
||||
@@ -56,11 +107,50 @@ 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()
|
||||
let monitoring = false
|
||||
if (wcdb4Client) {
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
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
|
||||
}
|
||||
})
|
||||
|
||||
@@ -75,12 +165,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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -113,28 +205,34 @@ 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
|
||||
? wcdb4Client.getMyGroupNickname(username)
|
||||
: undefined
|
||||
|
||||
return rawMessages.map((msg: WechatMessage) => {
|
||||
const typeDict: Record<number, string> = {
|
||||
1: '普通文本',
|
||||
3: '图片',
|
||||
34: '语音',
|
||||
43: '视频',
|
||||
47: '表情包',
|
||||
48: '位置',
|
||||
49: '分享消息',
|
||||
10000: '系统消息'
|
||||
}
|
||||
|
||||
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,16 +257,72 @@ app.whenReady().then(() => {
|
||||
}
|
||||
}
|
||||
|
||||
// 解析富媒体消息内容
|
||||
let contentData: ReturnType<typeof parseMessageContent> | undefined = undefined
|
||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||
const inferredMsgType =
|
||||
typeof content === 'string' &&
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||
? 49
|
||||
: msgType
|
||||
if ([3, 42, 47, 48, 49, 50].includes(inferredMsgType)) {
|
||||
try {
|
||||
const parsed =
|
||||
inferredMsgType === 47
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: parseMessageContent(content, inferredMsgType)
|
||||
if (parsed.type !== 'unknown') {
|
||||
content = ''
|
||||
}
|
||||
if (parsed.type === 'image') {
|
||||
const imageDatName = parseImageDatNameFromRow(msg)
|
||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||
} else {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5 && wcdb4Client) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
}
|
||||
contentData = parsed
|
||||
}
|
||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!contentData &&
|
||||
typeof content === 'string' &&
|
||||
/^[0-9a-fA-F]{64,}$/.test(content.trim())
|
||||
) {
|
||||
const parsed = parseStickerMessageFromRow(msg, content)
|
||||
if (parsed.type === 'sticker') {
|
||||
if (!parsed.url && parsed.md5 && wcdb4Client) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
}
|
||||
content = ''
|
||||
contentData = parsed
|
||||
displayType = '表情包'
|
||||
}
|
||||
}
|
||||
|
||||
if (msgType === 34) {
|
||||
content = '[语音消息]'
|
||||
}
|
||||
|
||||
return {
|
||||
id: msg.mesLocalID || Math.random().toString(),
|
||||
from: msg.mesDes === '1' ? 'user' : 'assistant', // 1 通常是接收到的,0 是发送的?需要验证。Swift 说:[1: "user", 0: "assistant"]
|
||||
type: typeDict[msgType] || msg.messageType,
|
||||
from: isMine ? 'assistant' : 'user',
|
||||
type: displayType,
|
||||
datetime: date.toLocaleString('zh-CN', { hour12: false }),
|
||||
content: content,
|
||||
isSender: msg.mesDes === '0',
|
||||
img: img,
|
||||
name: name
|
||||
// 实际上让我们坚持我们观察到的:1=用户(其他人), 0=助手(我)
|
||||
name: name,
|
||||
sessionId: username,
|
||||
localId: localId,
|
||||
createTime: createTime,
|
||||
contentData: contentData
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -183,22 +337,24 @@ app.whenReady().then(() => {
|
||||
async (
|
||||
_,
|
||||
messages: { role: string; content: string }[],
|
||||
options?: { apiKey?: string; model?: string }
|
||||
options?: { apiKey?: string; model?: string; baseURL?: string }
|
||||
) => {
|
||||
// @ts-ignore: vite env
|
||||
const apiKey = options?.apiKey || import.meta.env.VITE_DEEPSEEK_API_KEY
|
||||
const model = options?.model || 'deepseek-chat'
|
||||
const model = options?.model || import.meta.env.VITE_AI_MODEL || 'deepseek-chat'
|
||||
const baseURL =
|
||||
options?.baseURL || import.meta.env.VITE_AI_BASE_URL || 'https://api.deepseek.com'
|
||||
|
||||
if (!apiKey) {
|
||||
return { success: false, error: '未配置 DeepSeek API Key' }
|
||||
return { success: false, error: '未配置 API Key' }
|
||||
}
|
||||
|
||||
// 动态导入以避免如果未安装或初始类型缺失的问题
|
||||
const { OpenAI } = await import('openai')
|
||||
|
||||
const openai = new OpenAI({
|
||||
baseURL: 'https://api.deepseek.com',
|
||||
apiKey: apiKey
|
||||
baseURL,
|
||||
apiKey
|
||||
})
|
||||
try {
|
||||
const completion = await openai.chat.completions.create({
|
||||
@@ -208,7 +364,7 @@ app.whenReady().then(() => {
|
||||
})
|
||||
return { success: true, data: completion.choices[0].message.content }
|
||||
} catch (error: unknown) {
|
||||
console.error('DeepSeek API Error:', error)
|
||||
console.error('AI API Error:', error)
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error'
|
||||
return { success: false, error: errorMessage }
|
||||
}
|
||||
@@ -225,6 +381,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 +461,8 @@ app.on('window-all-closed', () => {
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
app.on('before-quit', () => {
|
||||
wechatDb?.close()
|
||||
wechatDb = null
|
||||
})
|
||||
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
type VoiceContent = { type: 'voice'; duration?: number }
|
||||
type LocationContent = {
|
||||
type: 'location'
|
||||
poiname?: string
|
||||
label?: string
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
des?: string
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
md5?: string
|
||||
datName?: string
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
encryptUrl?: string
|
||||
aeskey?: string
|
||||
}
|
||||
type QuoteContent = {
|
||||
type: 'quote'
|
||||
title?: string
|
||||
content?: string
|
||||
sender?: string
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
| VoiceContent
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
| UnknownContent
|
||||
|
||||
export function parseMessageContent(content: string, messageType: number): ParsedContent {
|
||||
if (!content || typeof content !== 'string') {
|
||||
return { type: 'unknown', raw: content || '' }
|
||||
}
|
||||
|
||||
const normalized = content.trim()
|
||||
|
||||
switch (messageType) {
|
||||
case 3:
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
return parseLocationMessage(normalized)
|
||||
case 49:
|
||||
return parseShareMessage(normalized)
|
||||
case 50:
|
||||
return parseVoipMessage(normalized)
|
||||
case 10000:
|
||||
case 10002:
|
||||
return { type: 'system', content: normalized }
|
||||
default:
|
||||
return { type: 'text', content: normalized }
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageMessage(content: string): ParsedContent {
|
||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||
let aeskey =
|
||||
extractXmlAttribute(content, 'img', 'aeskey') || extractXmlValue(content, 'aeskey') || undefined
|
||||
const encrypVerStr =
|
||||
extractXmlAttribute(content, 'img', 'encrypver') || extractXmlValue(content, 'encrypver') || '0'
|
||||
let datName = ''
|
||||
|
||||
// 如果 XML 格式解析失败,尝试 JSON 格式
|
||||
if (!md5) {
|
||||
try {
|
||||
const json = JSON.parse(content)
|
||||
// 可能是引用消息格式 { type: "...", content: "md5", ... }
|
||||
if (
|
||||
json.content &&
|
||||
typeof json.content === 'string' &&
|
||||
/^[a-f0-9]{32}$/i.test(json.content)
|
||||
) {
|
||||
md5 = json.content
|
||||
} else if (json.md5 && typeof json.md5 === 'string') {
|
||||
md5 = json.md5
|
||||
}
|
||||
if (json.datName && typeof json.datName === 'string') {
|
||||
datName = json.datName
|
||||
}
|
||||
if (json.imageDatName && typeof json.imageDatName === 'string') {
|
||||
datName = json.imageDatName
|
||||
}
|
||||
// 尝试从其他字段获取 aeskey
|
||||
if (!aeskey && json.aeskey) {
|
||||
aeskey = json.aeskey
|
||||
}
|
||||
if (!aeskey && json.aeskey_v2) {
|
||||
aeskey = json.aeskey_v2
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON 格式
|
||||
}
|
||||
}
|
||||
|
||||
const encrypVer = parseInt(encrypVerStr, 10)
|
||||
|
||||
if (!md5 && !datName) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'image', md5: md5 || undefined, datName: datName || undefined, aeskey, encrypVer }
|
||||
}
|
||||
|
||||
function parseStickerMessage(content: string): ParsedContent {
|
||||
// 表情包消息可能包含 md5 或 url
|
||||
const md5 =
|
||||
extractXmlAttribute(content, 'emoji', 'md5') ||
|
||||
extractXmlValue(content, 'md5') ||
|
||||
extractXmlAttribute(content, 'sticker', 'md5') ||
|
||||
extractLooseHexMd5(content) ||
|
||||
''
|
||||
const url = decodeXmlUrl(
|
||||
extractXmlValue(content, 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'cdnurl') ||
|
||||
extractXmlAttribute(content, 'emoji', 'url') ||
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') ||
|
||||
extractLooseAttribute(content, 'cdnurl') ||
|
||||
extractLooseAttribute(content, 'url') ||
|
||||
extractLooseAttribute(content, 'thumburl') ||
|
||||
''
|
||||
)
|
||||
const thumbUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'thumburl') || extractLooseAttribute(content, 'thumburl')
|
||||
)
|
||||
const encryptUrl = decodeXmlUrl(
|
||||
extractXmlAttribute(content, 'emoji', 'encrypturl') ||
|
||||
extractLooseAttribute(content, 'encrypturl')
|
||||
)
|
||||
const aeskey =
|
||||
extractXmlAttribute(content, 'emoji', 'aeskey') ||
|
||||
extractLooseAttribute(content, 'aeskey') ||
|
||||
undefined
|
||||
|
||||
if (!md5 && !url && !thumbUrl && !encryptUrl) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5,
|
||||
url: url || thumbUrl || undefined,
|
||||
thumbUrl: thumbUrl || undefined,
|
||||
encryptUrl: encryptUrl || undefined,
|
||||
aeskey
|
||||
}
|
||||
}
|
||||
|
||||
export function parseStickerMessageFromRow(
|
||||
row: Record<string, unknown>,
|
||||
content: string
|
||||
): ParsedContent {
|
||||
const supplementalPayload = [
|
||||
content,
|
||||
pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']),
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']),
|
||||
decodeSupplementalPayload(
|
||||
pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info'
|
||||
])
|
||||
),
|
||||
decodeSupplementalPayload(pickRowString(row, ['reserved0', 'Reserved0', 'WCDB_CT_reserved0']))
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
|
||||
const directMd5 = normalizeMd5(pickRowString(row, ['emoji_md5', 'emojiMd5', 'md5']))
|
||||
const directUrl = decodeXmlUrl(
|
||||
String(
|
||||
pickRowString(row, ['emoji_cdn_url', 'emojiCdnUrl', 'cdnurl', 'emoji_url', 'emojiUrl']) || ''
|
||||
)
|
||||
)
|
||||
const parsed = parseStickerMessage(supplementalPayload)
|
||||
|
||||
if (parsed.type === 'sticker') {
|
||||
return {
|
||||
...parsed,
|
||||
md5: parsed.md5 || directMd5,
|
||||
url: parsed.url || directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
if (directMd5 || directUrl) {
|
||||
return {
|
||||
type: 'sticker',
|
||||
md5: directMd5,
|
||||
url: directUrl || undefined
|
||||
}
|
||||
}
|
||||
|
||||
return parsed
|
||||
}
|
||||
|
||||
function parseCardMessage(content: string): ParsedContent {
|
||||
const username =
|
||||
extractXmlValue(content, 'username') || extractXmlValue(content, 'cardUsername') || ''
|
||||
const nickname =
|
||||
extractXmlValue(content, 'nickname') || extractXmlValue(content, 'cardNickname') || ''
|
||||
const avatarUrl =
|
||||
extractXmlValue(content, 'avatarUrl') ||
|
||||
extractXmlValue(content, 'smallHeadImgUrl') ||
|
||||
undefined
|
||||
|
||||
if (!username && !nickname) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'card', username, nickname, avatarUrl }
|
||||
}
|
||||
|
||||
function parseLocationMessage(content: string): ParsedContent {
|
||||
const poiname = extractXmlValue(content, 'poiname') || extractXmlValue(content, 'poiName') || ''
|
||||
const label = extractXmlValue(content, 'label') || ''
|
||||
|
||||
const latStr =
|
||||
extractXmlAttribute(content, 'location', 'x') ||
|
||||
extractXmlAttribute(content, 'location', 'latitude') ||
|
||||
'0'
|
||||
const lngStr =
|
||||
extractXmlAttribute(content, 'location', 'y') ||
|
||||
extractXmlAttribute(content, 'location', 'longitude') ||
|
||||
'0'
|
||||
|
||||
const lat = parseFloat(latStr)
|
||||
const lng = parseFloat(lngStr)
|
||||
|
||||
if (!poiname && lat === 0 && lng === 0) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'location', poiname, label, lat, lng }
|
||||
}
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
return {
|
||||
type: 'quote',
|
||||
title,
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
}
|
||||
}
|
||||
|
||||
const title = extractXmlValue(content, 'title') || ''
|
||||
const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || ''
|
||||
const url = extractXmlValue(content, 'url') || ''
|
||||
const appname = extractXmlValue(content, 'appname') || extractXmlValue(content, 'appInfo') || ''
|
||||
const typeVal = extractXmlValue(content, 'type') || ''
|
||||
|
||||
if (!title && !url) {
|
||||
return { type: 'unknown', raw: content }
|
||||
}
|
||||
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
|
||||
const referMsgXml = content.substring(referMsgStart, referMsgEnd + '</refermsg>'.length)
|
||||
const sender =
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'displayname')) ||
|
||||
sanitizeQuotedContent(extractXmlValue(referMsgXml, 'fromusr')) ||
|
||||
undefined
|
||||
const referContent = extractXmlValue(referMsgXml, 'content')
|
||||
const referType = extractXmlValue(referMsgXml, 'type')
|
||||
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
return { sender, content: '[视频]', type: referType }
|
||||
case '47':
|
||||
return { sender, content: '[表情]', type: referType }
|
||||
case '49':
|
||||
return {
|
||||
sender,
|
||||
content: extractXmlValue(referMsgXml, 'title') || '[分享消息]',
|
||||
type: referType
|
||||
}
|
||||
default:
|
||||
return {
|
||||
sender,
|
||||
content: sanitizeQuotedContent(referContent) || '[引用消息]',
|
||||
type: referType
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractAppMsgType(content: string): string {
|
||||
const appmsgMatch = /<appmsg[\s\S]*?>([\s\S]*?)<\/appmsg>/i.exec(content)
|
||||
if (!appmsgMatch) return extractXmlValue(content, 'type')
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function sanitizeQuotedContent(content: string): string {
|
||||
const decoded = String(content || '')
|
||||
.replace(/^wxid_[^:\n]+:\s*/i, '')
|
||||
.trim()
|
||||
if (/^(wxid_[\w-]+|[a-z][a-z0-9_-]{5,})$/i.test(decoded)) return ''
|
||||
return decoded
|
||||
}
|
||||
|
||||
function parseVoipMessage(content: string): ParsedContent {
|
||||
const roomTypeStr = extractXmlValue(content, 'room_type')
|
||||
const msg = extractXmlValue(content, 'msg') || ''
|
||||
const durationStr = extractXmlValue(content, 'duration') || '0'
|
||||
|
||||
const roomType = roomTypeStr ? parseInt(roomTypeStr, 10) : 0
|
||||
const duration = parseInt(durationStr, 10)
|
||||
|
||||
let status = msg
|
||||
if (!status) {
|
||||
status = roomType === 1 ? '[视频通话]' : '[语音通话]'
|
||||
}
|
||||
|
||||
return { type: 'voip', duration, status, roomType }
|
||||
}
|
||||
|
||||
function extractXmlValue(xml: string, tagName: string): string {
|
||||
const patterns = [
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*><!\\[CDATA\\[([^\\]]*)\\]\\]></${tagName}>`, 'i'),
|
||||
new RegExp(`<${tagName}[^>]*>([^<]*)</${tagName}>`, 'i'),
|
||||
new RegExp(`${tagName}=["']([^"']*)["']`, 'i')
|
||||
]
|
||||
|
||||
for (const pattern of patterns) {
|
||||
const match = xml.match(pattern)
|
||||
if (match && match[1]) {
|
||||
return match[1].trim()
|
||||
}
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
|
||||
function extractLooseAttribute(content: string, attrName: string): string {
|
||||
const quoted = new RegExp(`${attrName}\\s*=\\s*["']([^"']+)["']`, 'i').exec(content)
|
||||
if (quoted?.[1]) return quoted[1].trim()
|
||||
const unquoted = new RegExp(`${attrName}\\s*=\\s*([^"']+?)(?=\\s|/|>)`, 'i').exec(content)
|
||||
return unquoted?.[1]?.trim() || ''
|
||||
}
|
||||
|
||||
function decodeXmlUrl(value: string): string {
|
||||
const normalized = String(value || '')
|
||||
.replace(/&/g, '&')
|
||||
.trim()
|
||||
if (!normalized) return ''
|
||||
if (!normalized.includes('%')) return normalized
|
||||
try {
|
||||
return decodeURIComponent(normalized)
|
||||
} catch {
|
||||
return normalized
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
return /^[a-f0-9]{32}$/.test(md5) ? md5 : undefined
|
||||
}
|
||||
|
||||
function extractLooseHexMd5(content: string): string | undefined {
|
||||
if (!content) return undefined
|
||||
const match =
|
||||
/(?:emoji|sticker|md5)[^a-fA-F0-9]{0,32}([a-fA-F0-9]{32})/i.exec(content) ||
|
||||
/([a-fA-F0-9]{32})/i.exec(content)
|
||||
return normalizeMd5(match?.[1] || match?.[0])
|
||||
}
|
||||
|
||||
function decodeSupplementalPayload(raw: unknown): string {
|
||||
if (!raw) return ''
|
||||
if (typeof raw === 'string' && !/^[a-fA-F0-9]+$/.test(raw.trim())) return raw.trim()
|
||||
const buffer = decodePackedInfo(raw)
|
||||
if (!buffer || buffer.length === 0) return ''
|
||||
const decoded = buffer.toString('utf-8')
|
||||
const replacementCount = (decoded.match(/\uFFFD/g) || []).length
|
||||
if (replacementCount < decoded.length * 0.2) {
|
||||
return decoded.replace(/\uFFFD/g, '')
|
||||
}
|
||||
return Array.from(buffer)
|
||||
.map((byte) => (byte >= 0x20 && byte <= 0x7e ? String.fromCharCode(byte) : ' '))
|
||||
.join('')
|
||||
}
|
||||
|
||||
export function parseImageDatNameFromRow(row: Record<string, unknown>): string | undefined {
|
||||
const packed = pickRowString(row, [
|
||||
'packed_info_data',
|
||||
'packed_info',
|
||||
'packedInfoData',
|
||||
'packedInfo',
|
||||
'PackedInfoData',
|
||||
'PackedInfo',
|
||||
'WCDB_CT_packed_info_data',
|
||||
'WCDB_CT_packed_info',
|
||||
'WCDB_CT_PackedInfoData',
|
||||
'WCDB_CT_PackedInfo'
|
||||
])
|
||||
const buffer = decodePackedInfo(packed)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
|
||||
const printable = Array.from(buffer).map((byte) => (byte >= 0x20 && byte <= 0x7e ? byte : 0x20))
|
||||
const text = Buffer.from(printable).toString('utf-8')
|
||||
const match = /([0-9a-fA-F]{8,})(?:\.t)?\.dat/.exec(text)
|
||||
if (match?.[1]) return match[1].toLowerCase()
|
||||
const hexMatch = /([0-9a-fA-F]{16,})/.exec(text)
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
const foundKey = Object.keys(row).find(
|
||||
(candidate) => candidate.toLowerCase() === key.toLowerCase()
|
||||
)
|
||||
if (foundKey) return row[foundKey]
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function decodePackedInfo(raw: unknown): Buffer | null {
|
||||
if (!raw) return null
|
||||
if (Buffer.isBuffer(raw)) return raw
|
||||
if (raw instanceof Uint8Array) return Buffer.from(raw)
|
||||
if (Array.isArray(raw)) return Buffer.from(raw)
|
||||
if (typeof raw === 'string') {
|
||||
const trimmed = raw.trim()
|
||||
if (/^[a-fA-F0-9]+$/.test(trimmed) && trimmed.length % 2 === 0) {
|
||||
try {
|
||||
return Buffer.from(trimmed, 'hex')
|
||||
} catch {
|
||||
// Try base64 below.
|
||||
}
|
||||
}
|
||||
try {
|
||||
return Buffer.from(trimmed, 'base64')
|
||||
} catch {
|
||||
// Unsupported packed_info encoding.
|
||||
}
|
||||
}
|
||||
if (typeof raw === 'object' && raw && Array.isArray((raw as { data?: unknown }).data)) {
|
||||
return Buffer.from((raw as { data: number[] }).data)
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
+147
-28
@@ -3,17 +3,19 @@ import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
type Database = import('better-sqlite3-multiple-ciphers').Database
|
||||
|
||||
export interface UserContact {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
mesLocalID: string
|
||||
mesDes: string
|
||||
mesDes: number
|
||||
messageType: string
|
||||
msgCreateTime: string
|
||||
msgContent: string
|
||||
@@ -26,6 +28,7 @@ export interface Contact {
|
||||
m_nsNickName: string
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
}
|
||||
|
||||
export interface GroupMemberInfo {
|
||||
@@ -44,10 +47,18 @@ export class WechatDb {
|
||||
private correctUserId: string | null = null
|
||||
private chatDb: { name: string; db_number: string }[] | null = null
|
||||
private groupMemberCache = new Map<string, GroupMemberInfo | null>()
|
||||
private wcdb4Client: Wcdb4Client | null = null
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
private wechat4OpenError: string | null = null
|
||||
|
||||
constructor(rawKey: string) {
|
||||
this.rawKey = rawKey
|
||||
console.log(`Initializing WechatDb with key: ${rawKey}`)
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
|
||||
if (this.tryOpenWechat4()) {
|
||||
this.chatDb = this.getChatDbNumber()
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(WechatDb.WECHAT_DIR)) {
|
||||
throw new Error(`WeChat directory not found at ${WechatDb.WECHAT_DIR}`)
|
||||
@@ -57,12 +68,28 @@ export class WechatDb {
|
||||
console.log('User found, getting chat DB number')
|
||||
this.chatDb = this.getChatDbNumber()
|
||||
} else {
|
||||
throw new Error('No valid user found or invalid key')
|
||||
throw new Error(
|
||||
`No valid user found or invalid key${this.wechat4OpenError ? `; WeChat 4.0 error: ${this.wechat4OpenError}` : ''}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private tryOpenWechat4(): boolean {
|
||||
try {
|
||||
const client = new Wcdb4Client(this.rawKey)
|
||||
client.open()
|
||||
this.wcdb4Client = client
|
||||
console.log('Opened WeChat 4.0 database with WechatExplorer WCDB native adapter')
|
||||
return true
|
||||
} catch (error) {
|
||||
this.wechat4OpenError = error instanceof Error ? error.message : String(error)
|
||||
console.warn('WeChat 4.0 open failed, fallback to 3.0 SQLCipher mode:', error)
|
||||
this.wcdb4Client = null
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private connectDb(dbPath: string): Database | null {
|
||||
// 1. 路径预检查
|
||||
const targetPath = path.resolve(dbPath)
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
console.error(`❌ 错误:数据库文件不存在于路径:${targetPath}`)
|
||||
@@ -70,45 +97,26 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
try {
|
||||
// console.log(`🔌 正在尝试连接至:${path.basename(targetPath)}`)
|
||||
// console.log('路径是', targetPath)
|
||||
// 2. 初始化数据库句柄
|
||||
// 关键:使用 better-sqlite3-multiple-ciphers 的构造函数
|
||||
// verbose 选项有助于调试 Pragma 的执行情况
|
||||
const db = new DatabaseConstructor(targetPath, {
|
||||
// verbose: console.log
|
||||
})
|
||||
|
||||
// 此时 db 实例已创建,但因为未提供密钥,任何数据读取都会失败。
|
||||
// 必须立即配置加密参数。
|
||||
|
||||
// 3. 配置加密方案 (Cipher Scheme)
|
||||
// 必须显式切换到 'sqlcipher',因为该库默认可能使用 'chacha20'
|
||||
// 配置加密方案 (Cipher Scheme)
|
||||
db.pragma("cipher='sqlcipher'")
|
||||
|
||||
// 4. 配置页大小 (Page Size)
|
||||
// macOS 版 WeChat 使用 1024 字节。这必须在设置密钥前(或紧随其后)生效。
|
||||
db.pragma('cipher_page_size = 1024')
|
||||
|
||||
// 5. 配置兼容性与算法细节
|
||||
// 显式指定 SQLCipher 3 的行为
|
||||
db.pragma('legacy=3')
|
||||
db.pragma('kdf_iter = 64000')
|
||||
db.pragma('cipher_hmac_algorithm = HMAC_SHA1')
|
||||
db.pragma('cipher_kdf_algorithm = PBKDF2_HMAC_SHA1')
|
||||
|
||||
// 6. 应用密钥
|
||||
// 使用正确的 hex 语法: key = "x'HEXSTRING'"
|
||||
const processedKey = this.processRawKey(this.rawKey)
|
||||
// console.log(`🔑 应用密钥 (前6位): ${processedKey.substring(0, 6)}...`)
|
||||
|
||||
db.pragma(`key = "x'${processedKey}'"`)
|
||||
|
||||
// 7. 验证连接
|
||||
// 如果参数错误,这里将抛出 "file is not a database" 或 "HMAC check failed"
|
||||
// 验证连接
|
||||
db.prepare('SELECT count(*) as count FROM sqlite_master').get()
|
||||
|
||||
// console.log(`✅ 连接成功!当前数据库 ${path.basename(targetPath)} 包含 ${row.count} 张表。`)
|
||||
return db
|
||||
} catch (error) {
|
||||
console.error(`❌ 连接失败:`, error)
|
||||
@@ -156,6 +164,17 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
private getChatDbNumber(): { name: string; db_number: string }[] {
|
||||
if (this.wcdb4Client) {
|
||||
const chatDb = this.wcdb4Client.getChatTables()
|
||||
this.chatMd5ToUsername.clear()
|
||||
for (const table of chatDb) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
return chatDb
|
||||
}
|
||||
|
||||
const chatDb: { name: string; db_number: string }[] = []
|
||||
if (!this.correctUserId) return []
|
||||
|
||||
@@ -178,6 +197,24 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||
if (this.wcdb4Client) {
|
||||
const keyword = (nicknameFilter || '').trim().toLowerCase()
|
||||
return this.wcdb4Client
|
||||
.getSessions()
|
||||
.map((session) => ({
|
||||
m_nsUsrName: session.username,
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
return (
|
||||
contact.m_nsUsrName.toLowerCase().includes(keyword) ||
|
||||
contact.nickname.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (!this.correctUserId) return []
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Contact/wccontact_new2.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
@@ -194,6 +231,16 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public getAllGroupContacts(): Record<string, string> {
|
||||
if (this.wcdb4Client) {
|
||||
const groupContacts: Record<string, string> = {}
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
if (session.username.endsWith('@chatroom')) {
|
||||
groupContacts[this.md5(session.username)] = session.nickname || session.username
|
||||
}
|
||||
}
|
||||
return groupContacts
|
||||
}
|
||||
|
||||
if (!this.correctUserId) return {}
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
@@ -216,6 +263,19 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public getAllGroupMembers(): Record<string, string> {
|
||||
if (this.wcdb4Client) {
|
||||
const members: Record<string, string> = {}
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
if (!session.username.endsWith('@chatroom')) continue
|
||||
for (const member of this.wcdb4Client.getGroupMembers(session.username)) {
|
||||
if (member.m_nsUsrName) {
|
||||
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
|
||||
}
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
if (!this.correctUserId) return {}
|
||||
const dbPath = path.join(WechatDb.WECHAT_DIR, this.correctUserId, 'Group/group_new.db')
|
||||
const db = this.connectDb(dbPath)
|
||||
@@ -238,10 +298,35 @@ export class WechatDb {
|
||||
return groupMembers
|
||||
}
|
||||
|
||||
public getGroupMember(wxid: string): GroupMemberInfo | null {
|
||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||
if (this.wcdb4Client) {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username || !username.endsWith('@chatroom')) return {}
|
||||
|
||||
const members: Record<string, string> = {}
|
||||
for (const member of this.wcdb4Client.getGroupMembers(username)) {
|
||||
if (member.m_nsUsrName) {
|
||||
members[member.m_nsUsrName] = member.nickname || member.m_nsUsrName
|
||||
}
|
||||
}
|
||||
return members
|
||||
}
|
||||
|
||||
return this.getAllGroupMembers()
|
||||
}
|
||||
|
||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||
if (this.wcdb4Client && chatroomId) {
|
||||
return (
|
||||
this.wcdb4Client
|
||||
.getGroupMembers(chatroomId)
|
||||
.find((member) => member.m_nsUsrName === wxid) || null
|
||||
)
|
||||
}
|
||||
|
||||
if (!this.correctUserId) return null
|
||||
|
||||
// 1. 检查缓存
|
||||
// 检查缓存
|
||||
if (this.groupMemberCache.has(wxid)) {
|
||||
return this.groupMemberCache.get(wxid) || null
|
||||
}
|
||||
@@ -261,7 +346,7 @@ export class WechatDb {
|
||||
|
||||
db.close()
|
||||
|
||||
// 2. 写入缓存
|
||||
// 写入缓存
|
||||
this.groupMemberCache.set(wxid, result || null)
|
||||
|
||||
return result || null
|
||||
@@ -271,7 +356,29 @@ export class WechatDb {
|
||||
return this.chatDb || []
|
||||
}
|
||||
|
||||
public getMyAvatarUrl(): string | undefined {
|
||||
return this.wcdb4Client?.getMyAvatarUrl()
|
||||
}
|
||||
|
||||
public getWcdb4Client(): Wcdb4Client | null {
|
||||
return this.wcdb4Client
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
this.wcdb4Client?.close()
|
||||
this.wcdb4Client = null
|
||||
}
|
||||
|
||||
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
|
||||
if (this.wcdb4Client) {
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime).map((message) => ({
|
||||
...message,
|
||||
...message.raw
|
||||
}))
|
||||
}
|
||||
|
||||
if (!this.chatDb || !this.correctUserId) return []
|
||||
|
||||
const tableName = `Chat_${userMd5}`
|
||||
@@ -322,6 +429,18 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
if (this.wcdb4Client) {
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
if (!lowerKeyword) return null
|
||||
for (const session of this.wcdb4Client.getSessions()) {
|
||||
const found = this.wcdb4Client
|
||||
.getMessages(session.username)
|
||||
.some((message) => message.msgContent.toLowerCase().includes(lowerKeyword))
|
||||
if (found) return `Chat_${this.md5(session.username)}`
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
if (!this.correctUserId) return null
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
|
||||
Vendored
+64
-2
@@ -1,19 +1,81 @@
|
||||
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[]>
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+31
-2
@@ -1,5 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron'
|
||||
import { electronAPI } from '@electron-toolkit/preload'
|
||||
import { GroupReportExportRequest } from '../shared/group-report'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
@@ -10,9 +11,37 @@ const api = {
|
||||
search: (keyword: string) => ipcRenderer.invoke('db:search', keyword),
|
||||
aiChat: (
|
||||
messages: { role: string; content: string }[],
|
||||
options?: { apiKey?: string; model?: string }
|
||||
options?: { apiKey?: string; model?: string; baseURL?: string }
|
||||
) => ipcRenderer.invoke('ai:chat', messages, options),
|
||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String)
|
||||
copyImage: (base64String) => ipcRenderer.invoke('copy-image', base64String),
|
||||
getVoiceData: (sessionId: string, localId: number, createTime: number, svrId?: string | number) =>
|
||||
ipcRenderer.invoke('db:getVoiceData', sessionId, localId, createTime, svrId),
|
||||
parseMessage: (content: string, messageType: number) =>
|
||||
ipcRenderer.invoke('db:parseMessage', content, messageType),
|
||||
getImage: (imageMd5?: string, imageDatNameOrThumb?: string | boolean, sessionId?: string) =>
|
||||
ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
+182
-16
@@ -3,6 +3,24 @@ 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
|
||||
}
|
||||
|
||||
function App(): React.ReactElement {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||
const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '')
|
||||
@@ -12,6 +30,33 @@ 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)
|
||||
|
||||
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 +68,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 +84,55 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -83,19 +180,52 @@ function App(): React.ReactElement {
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
if (!disposed) {
|
||||
setMessages((current) =>
|
||||
areMessagesEquivalent(current, latestMessages) ? current : latestMessages
|
||||
)
|
||||
}
|
||||
} 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])
|
||||
|
||||
const handleSearchContacts = (keyword: string): void => {
|
||||
if (!keyword) {
|
||||
setFilteredContacts(contacts)
|
||||
@@ -144,16 +274,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>
|
||||
)
|
||||
|
||||
@@ -133,6 +133,12 @@ body {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
.section-empty {
|
||||
padding: 10px 14px 12px 27px;
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
@@ -159,6 +165,16 @@ body {
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.contact-avatar img,
|
||||
.message-avatar img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.contact-info {
|
||||
@@ -215,6 +231,319 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.wechat-message-list {
|
||||
padding: 18px 28px;
|
||||
gap: 14px;
|
||||
background-color: #edf1f2;
|
||||
background-image:
|
||||
radial-gradient(circle at 20px 20px, rgba(0, 0, 0, 0.025) 1px, transparent 1px),
|
||||
radial-gradient(circle at 80px 70px, rgba(0, 0, 0, 0.02) 1px, transparent 1px);
|
||||
background-size: 120px 120px;
|
||||
}
|
||||
|
||||
.wechat-message-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 6px;
|
||||
background: #d6d6d6;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
flex: 0 0 38px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.mine-avatar {
|
||||
background: #607d86;
|
||||
}
|
||||
|
||||
.message-stack {
|
||||
max-width: min(68%, 720px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-stack {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-sender-name {
|
||||
color: #6f777a;
|
||||
font-size: 12px;
|
||||
margin: 0 0 4px 2px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
position: relative;
|
||||
padding: 9px 13px;
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: #222;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.03);
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-bubble {
|
||||
background: #516d76;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.message-bubble::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-top: 5px solid transparent;
|
||||
border-bottom: 5px solid transparent;
|
||||
}
|
||||
|
||||
.wechat-message-row.other .message-bubble::before {
|
||||
left: -6px;
|
||||
border-right: 6px solid #fff;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .message-bubble::before {
|
||||
right: -6px;
|
||||
border-left: 6px solid #516d76;
|
||||
}
|
||||
|
||||
.message-meta {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 4px;
|
||||
color: #9aa1a4;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.voice-bubble {
|
||||
min-width: 138px;
|
||||
}
|
||||
|
||||
.image-message-bubble {
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .image-message-bubble {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.image-message-bubble::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.image-bubble {
|
||||
position: relative;
|
||||
max-width: min(260px, 46vw);
|
||||
max-height: 220px;
|
||||
min-width: 96px;
|
||||
min-height: 72px;
|
||||
border-radius: 7px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
cursor: zoom-in;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.image-content {
|
||||
display: block;
|
||||
max-width: min(260px, 46vw);
|
||||
max-height: 220px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
background: #f7f7f7;
|
||||
}
|
||||
|
||||
.image-loading,
|
||||
.image-placeholder,
|
||||
.image-error {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
padding: 14px 18px;
|
||||
color: #768184;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.image-placeholder-icon {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-actions {
|
||||
position: absolute;
|
||||
right: 6px;
|
||||
bottom: 6px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.image-bubble:hover .image-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.image-action-btn {
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: rgba(0, 0, 0, 0.48);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.voice-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voice-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .voice-icon {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
|
||||
.voice-bars {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 3px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.voice-bars i {
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background: currentColor;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(1) {
|
||||
height: 7px;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(2) {
|
||||
height: 13px;
|
||||
}
|
||||
|
||||
.voice-bars i:nth-child(3) {
|
||||
height: 9px;
|
||||
}
|
||||
|
||||
.sticker-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.sticker-image {
|
||||
display: block;
|
||||
max-width: 140px;
|
||||
max-height: 140px;
|
||||
border-radius: 6px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.sticker-placeholder {
|
||||
color: #666;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.sticker-md5 {
|
||||
max-width: 180px;
|
||||
color: #999;
|
||||
font-size: 11px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quote-message {
|
||||
min-width: 160px;
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.quoted-message {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
margin-bottom: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.wechat-message-row.other .quoted-message {
|
||||
background: #f0f0f0;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.quoted-sender {
|
||||
flex: 0 1 auto;
|
||||
min-width: 0;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.quoted-sender::after {
|
||||
content: ':';
|
||||
}
|
||||
|
||||
.quoted-text {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.quote-reply {
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
@@ -272,7 +601,8 @@ body {
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.col-content {}
|
||||
.col-content {
|
||||
}
|
||||
|
||||
.chat-toolbar {
|
||||
padding: 10px;
|
||||
@@ -324,7 +654,7 @@ body {
|
||||
background-color: #fff;
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
width: 300px;
|
||||
width: 360px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@@ -336,6 +666,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 +694,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 +791,355 @@ 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 {
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image-viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(16, 24, 28, 0.28);
|
||||
backdrop-filter: blur(1px);
|
||||
}
|
||||
|
||||
.image-viewer-window {
|
||||
width: min(900px, 86vw);
|
||||
height: min(700px, 82vh);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(0, 0, 0, 0.14);
|
||||
border-radius: 8px;
|
||||
background: #eaf0f1;
|
||||
box-shadow: 0 18px 48px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.image-viewer-titlebar {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 12px 0 16px;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
|
||||
background: rgba(255, 255, 255, 0.78);
|
||||
color: #333;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.image-viewer-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.image-viewer-title {
|
||||
margin-right: 8px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.image-viewer-zoom {
|
||||
min-width: 44px;
|
||||
color: #666;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.image-viewer-divider {
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.image-viewer-titlebar button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.image-viewer-titlebar button:hover {
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.image-viewer-stage {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
overflow: auto;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.image-viewer-stage img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 3px 16px rgba(0, 0, 0, 0.12);
|
||||
transform-origin: center center;
|
||||
transition: transform 0.08s ease-out;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Voice Player */
|
||||
.voice-message {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.voice-loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.voice-error {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.voice-loading-text {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.voice-error-text {
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.voice-duration {
|
||||
font-size: 12px;
|
||||
margin-left: 4px;
|
||||
min-width: 32px;
|
||||
}
|
||||
|
||||
.voice-icon.playing {
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Rich Message Bubbles */
|
||||
.location-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.location-icon {
|
||||
font-size: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.location-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.location-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-label {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.location-coords {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.wechat-message-row.mine .location-message {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.card-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 160px;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.card-avatar {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card-nickname {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-username {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.card-username:hover {
|
||||
color: #07c160;
|
||||
}
|
||||
|
||||
.share-message {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
cursor: pointer;
|
||||
min-width: 180px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.share-appname {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.share-title {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.share-desc {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.share-url {
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.voip-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voip-icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.voip-status {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@@ -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,26 +17,41 @@ interface ChatWindowProps {
|
||||
onRefreshData?: () => void
|
||||
}
|
||||
|
||||
const systemPrompt = `你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
||||
请注意 不要回复总结除外的内容, 并且不要输出 群友的wxid 微信id 只需要显示群名称
|
||||
请帮我将给出的群聊内容总结成一个群聊报告,需要你生成7个最重要 最火爆的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
||||
- 整体评价
|
||||
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
||||
- 参与者(不超过5个人,将重复的人名去重)
|
||||
- 注意按时间排序,时间段(从日期几点到几点)
|
||||
- 过程(50到200字左右)
|
||||
- 评价(50字以下)
|
||||
- 生成这7天内热度最高的话题,27日到2日一共7天
|
||||
需要生成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. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
||||
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,
|
||||
@@ -38,26 +60,49 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
onRefresh,
|
||||
onRefreshData
|
||||
}) => {
|
||||
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 [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)
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem('deepseek_api_key') || '')
|
||||
const [model, setModel] = useState('deepseek-chat')
|
||||
const [apiKey, setApiKey] = useState(() => localStorage.getItem('ai_api_key') || '')
|
||||
const [baseURL, setBaseURL] = useState(
|
||||
() => 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 => {
|
||||
localStorage.setItem('deepseek_api_key', apiKey)
|
||||
if (!summaryMessageTypes.length) {
|
||||
alert('请至少选择一种消息类型')
|
||||
return
|
||||
}
|
||||
localStorage.setItem('ai_api_key', apiKey)
|
||||
localStorage.setItem('ai_base_url', baseURL)
|
||||
localStorage.setItem('ai_model', model)
|
||||
setShowSettingsModal(false)
|
||||
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' })
|
||||
}
|
||||
@@ -66,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 => {
|
||||
@@ -113,11 +180,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
// 昨天
|
||||
const startOfYesterday = startOfDay - 86400000
|
||||
return parsed >= startOfYesterday && parsed < startOfDay
|
||||
} else {
|
||||
} else if (days === 7) {
|
||||
// 过去 7 天
|
||||
const startOf7DaysAgo = startOfDay - 7 * 86400000
|
||||
return parsed >= startOf7DaysAgo
|
||||
} else if (days === 30) {
|
||||
// 过去 30 天
|
||||
const startOf30DaysAgo = startOfDay - 30 * 86400000
|
||||
return parsed >= startOf30DaysAgo
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -125,7 +197,15 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filtered.map((m) => {
|
||||
const content = m.content.replace(/"/g, '""').replace(/\n/g, ' ')
|
||||
let prefix = ''
|
||||
if (isGroupChat) {
|
||||
prefix = m.name ? `${m.name}: ` : ''
|
||||
} else {
|
||||
const name = m.from === 'user' ? contact?.m_nsNickName || '未知' : '我'
|
||||
prefix = `${name}: `
|
||||
}
|
||||
const fullContent = `${prefix}${m.content}`
|
||||
const content = fullContent.replace(/"/g, '""').replace(/\n/g, ' ')
|
||||
return `"${m.from}","${m.type}","${m.datetime}","${content}"`
|
||||
})
|
||||
].join('\n')
|
||||
@@ -140,85 +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) => {
|
||||
delete msg.img
|
||||
// @ts-ignore 暂时去除
|
||||
delete msg.from
|
||||
// @ts-ignore 暂时去除
|
||||
delete msg.id
|
||||
// @ts-ignore 暂时去除
|
||||
delete msg.isSender
|
||||
return !'分享消息,图片,表情包,视频'.split(',').includes(msg.type)
|
||||
})
|
||||
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 }
|
||||
{ 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)
|
||||
@@ -227,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)
|
||||
@@ -240,8 +286,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
|
||||
const visibleMessages = filteredMessages.slice(0, displayLimit)
|
||||
|
||||
if (!contact) {
|
||||
return (
|
||||
<div className="chat-window">
|
||||
@@ -257,74 +301,82 @@ 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}
|
||||
<div className="message-list wechat-message-list">
|
||||
{filteredMessages.map((msg) => {
|
||||
const isMine = msg.from === 'assistant'
|
||||
const displayName = isMine
|
||||
? '我'
|
||||
: isGroupChat
|
||||
? msg.name || msg.from
|
||||
: contact.m_nsNickName
|
||||
const avatarSrc = isMine ? msg.img : msg.img || contact.avatar
|
||||
const isVoice = msg.type === '语音'
|
||||
const isImage = msg.type === '图片'
|
||||
const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包'].includes(msg.type)
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
||||
{!isMine && showAvatar && (
|
||||
<div className="message-avatar">
|
||||
{avatarSrc ? (
|
||||
<img src={avatarSrc} alt={displayName} referrerPolicy="no-referrer" />
|
||||
) : (
|
||||
(displayName || '?').charAt(0)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${isImage ? 'image-message-bubble' : ''}`}
|
||||
>
|
||||
{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' }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', marginRight: 12 }}>
|
||||
<span>{msg.name}</span>
|
||||
{msg?.img && (
|
||||
<img style={{ width: '50px', height: '50px' }} src={msg?.img}></img>
|
||||
)}
|
||||
</div>
|
||||
{/* <span style={{ marginRight: '24px' }}>:</span> */}
|
||||
<div>{msg.content}</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>
|
||||
)}
|
||||
{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>
|
||||
|
||||
<div className="chat-toolbar">
|
||||
<label
|
||||
style={{ marginRight: '10px', display: 'flex', alignItems: 'center', cursor: 'pointer' }}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showAvatar}
|
||||
onChange={(e) => setShowAvatar(e.target.checked)}
|
||||
style={{ marginRight: '5px' }}
|
||||
/>
|
||||
显示头像
|
||||
</label>
|
||||
<button className="toolbar-btn" onClick={onRefresh}>
|
||||
🔄 刷新聊天记录
|
||||
</button>
|
||||
@@ -343,47 +395,22 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
<button className="toolbar-btn" onClick={() => handleExport(7)}>
|
||||
📅 导出近7天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => handleExport(30)}>
|
||||
📅 导出近30天
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => setShowSettingsModal(true)}>
|
||||
🤖 AI总结群聊
|
||||
</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>
|
||||
@@ -419,6 +446,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' }}
|
||||
@@ -430,28 +465,127 @@ 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>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>模型服务:</label>
|
||||
<select
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
style={{ width: '100%', padding: '8px' }}
|
||||
>
|
||||
<option value="deepseek-chat">DeepSeek Chat</option>
|
||||
<option value="gpt-4o">GPT-4o</option>
|
||||
<option value="gpt-4o-mini">GPT-4o Mini</option>
|
||||
<option value="gpt-4-turbo">GPT-4 Turbo</option>
|
||||
<option value="claude-3-5-sonnet-20240620">Claude 3.5 Sonnet</option>
|
||||
<option value="moonshot-v1-8k">Moonshot V1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '15px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>Base URL:</label>
|
||||
<input
|
||||
type="text"
|
||||
value={baseURL}
|
||||
onChange={(e) => setBaseURL(e.target.value)}
|
||||
placeholder="https://api.deepseek.com"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group" style={{ marginBottom: '20px' }}>
|
||||
<label style={{ display: 'block', marginBottom: '5px' }}>API Key:</label>
|
||||
<input
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
placeholder="Enter your DeepSeek API Key"
|
||||
placeholder="Enter your API Key"
|
||||
style={{ width: '95%', padding: '8px' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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,222 @@
|
||||
import { ParsedContent } from '../../../shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
|
||||
const stickerDataUrlCache = new Map<string, string>()
|
||||
|
||||
interface RichMessageBubbleProps {
|
||||
contentData: ParsedContent
|
||||
}
|
||||
|
||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
||||
switch (contentData.type) {
|
||||
case 'location':
|
||||
return <LocationBubble data={contentData} />
|
||||
case 'card':
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'voip':
|
||||
return <VoipBubble data={contentData} />
|
||||
case 'sticker':
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
|
||||
)
|
||||
default:
|
||||
return <div className="message-text">[不支持的消息类型]</div>
|
||||
}
|
||||
}
|
||||
|
||||
function LocationBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'location' }>
|
||||
}): JSX.Element {
|
||||
const { poiname, label, lat, lng } = data
|
||||
const locationText = poiname || label || '位置'
|
||||
const hasCoords = lat !== 0 || lng !== 0
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (hasCoords) {
|
||||
const url = `https://maps.apple.com/?q=${encodeURIComponent(locationText)}&ll=${lat},${lng}`
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="location-message" onClick={hasCoords ? handleClick : undefined}>
|
||||
<div className="location-icon">📍</div>
|
||||
<div className="location-info">
|
||||
<div className="location-name">{locationText}</div>
|
||||
{label && poiname && label !== poiname && <div className="location-label">{label}</div>}
|
||||
{hasCoords && (
|
||||
<div className="location-coords">
|
||||
{lat.toFixed(6)}, {lng.toFixed(6)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CardBubble({ data }: { data: Extract<ParsedContent, { type: 'card' }> }): JSX.Element {
|
||||
const { username, nickname } = data
|
||||
|
||||
const handleCopy = (e: MouseEvent): void => {
|
||||
e.stopPropagation()
|
||||
navigator.clipboard.writeText(username)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card-message">
|
||||
<div className="card-avatar">{(nickname || username).charAt(0).toUpperCase()}</div>
|
||||
<div className="card-info">
|
||||
<div className="card-nickname">{nickname || '未知'}</div>
|
||||
<div className="card-username" onClick={handleCopy} title="点击复制">
|
||||
{username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }> }): JSX.Element {
|
||||
const { title, des, url, appname } = data
|
||||
|
||||
const handleClick = (): void => {
|
||||
if (url) {
|
||||
window.open(url, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
const urlHost = getUrlHost(url)
|
||||
|
||||
return (
|
||||
<div className="share-message" onClick={handleClick}>
|
||||
{appname && <div className="share-appname">{appname}</div>}
|
||||
<div className="share-title">{title || '链接'}</div>
|
||||
{des && <div className="share-desc">{des}</div>}
|
||||
{urlHost && <div className="share-url">{urlHost}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||
const { status, roomType, duration } = data
|
||||
const isVideo = roomType === 1
|
||||
|
||||
const formatDuration = (seconds: number | undefined): string => {
|
||||
if (!seconds) return ''
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
if (mins > 0) {
|
||||
return `${mins}分${secs}秒`
|
||||
}
|
||||
return `${secs}秒`
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="voip-message">
|
||||
<span className="voip-icon">{isVideo ? '📹' : '📞'}</span>
|
||||
<span className="voip-status">
|
||||
{status}
|
||||
{duration ? ` ${formatDuration(duration)}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StickerBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'sticker' }>
|
||||
}): JSX.Element {
|
||||
const { md5, url, thumbUrl } = data
|
||||
const sourceUrl = url || thumbUrl || ''
|
||||
const cacheKey = md5 || sourceUrl
|
||||
const [displayUrl, setDisplayUrl] = useState(() =>
|
||||
cacheKey ? stickerDataUrlCache.get(cacheKey) || '' : ''
|
||||
)
|
||||
const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl)
|
||||
const [error, setError] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!cacheKey || displayUrl || error) return
|
||||
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getSticker(sourceUrl, md5)
|
||||
.then((result) => {
|
||||
if (cancelled) return
|
||||
if (result.success && result.data) {
|
||||
stickerDataUrlCache.set(cacheKey, result.data)
|
||||
setDisplayUrl(result.data)
|
||||
setError(false)
|
||||
} else {
|
||||
setError(true)
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError(true)
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [cacheKey, displayUrl, error, md5, sourceUrl])
|
||||
|
||||
if (displayUrl) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<img src={displayUrl} alt="表情包" className="sticker-image" referrerPolicy="no-referrer" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">表情包加载中...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="sticker-message">
|
||||
<div className="sticker-placeholder">{error ? '表情包未缓存' : '表情包'}</div>
|
||||
{md5 && <div className="sticker-md5">MD5: {md5}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
|
||||
return (
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
<span className="quoted-text">{quotedText}</span>
|
||||
</div>
|
||||
{replyText && <div className="quote-reply">{replyText}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function getUrlHost(url?: string): string {
|
||||
if (!url) return ''
|
||||
try {
|
||||
return new URL(url).hostname
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
@@ -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()}>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -15,8 +15,73 @@ export interface Message {
|
||||
isSender: boolean
|
||||
img?: string
|
||||
name?: string
|
||||
contentData?: ParsedContent
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
localId?: number
|
||||
createTime?: number
|
||||
sessionId?: string
|
||||
}
|
||||
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
type VoiceContent = { type: 'voice'; duration?: number }
|
||||
type LocationContent = {
|
||||
type: 'location'
|
||||
poiname?: string
|
||||
label?: string
|
||||
lat: number
|
||||
lng: number
|
||||
}
|
||||
type CardContent = { type: 'card'; username: string; nickname: string; avatarUrl?: string }
|
||||
type ShareContent = {
|
||||
type: 'share'
|
||||
title: string
|
||||
des?: string
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
md5?: string
|
||||
datName?: string
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
url?: string
|
||||
thumbUrl?: string
|
||||
encryptUrl?: string
|
||||
aeskey?: string
|
||||
}
|
||||
type QuoteContent = {
|
||||
type: 'quote'
|
||||
title?: string
|
||||
content?: string
|
||||
sender?: string
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
| TextContent
|
||||
| VoiceContent
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
| UnknownContent
|
||||
|
||||
export interface ChatTable {
|
||||
name: string
|
||||
db_number: string
|
||||
|
||||
+2
-1
@@ -1,4 +1,5 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }]
|
||||
"references": [{ "path": "./tsconfig.node.json" }, { "path": "./tsconfig.web.json" }],
|
||||
"include": ["src", "vite-env.d.ts"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"extends": "@electron-toolkit/tsconfig/tsconfig.node.json",
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*"],
|
||||
"include": ["electron.vite.config.*", "src/main/**/*", "src/preload/**/*", "src/shared/**/*", "src/env.d.ts"],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"types": ["electron-vite/node"]
|
||||
|
||||
Reference in New Issue
Block a user