Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d0ae9e6019 | ||
|
|
49684f3365 | ||
|
|
33aaf4d558 | ||
|
|
5ccabb3898 | ||
|
|
7db845ac7e | ||
|
|
9348c5ce4a | ||
|
|
f626d89a1a | ||
|
|
95e517eca5 | ||
|
|
e93e4554d7 | ||
|
|
9ae0c6cc47 | ||
|
|
59e609b08a | ||
|
|
9de7dcde3b | ||
|
|
cb3c2855c0 | ||
|
|
dca88e5db3 | ||
|
|
1a9f27488b | ||
|
|
3b64e18b5e | ||
|
|
554dccdb21 | ||
|
|
c674dcbc4a | ||
|
|
0f65b96da1 | ||
|
|
8488e81bb5 | ||
|
|
430a36333b | ||
|
|
474250c6c7 | ||
|
|
794880d389 | ||
|
|
d0ceeceb68 | ||
|
|
62f729d281 | ||
|
|
0d544275c0 | ||
|
|
a91b4d7a56 | ||
|
|
f9b567fba2 | ||
|
|
1f693aa3d7 | ||
|
|
8e1e166856 | ||
|
|
1275c512c4 |
@@ -6,9 +6,12 @@ VITE_DB_KEY=
|
||||
VITE_AUTO_LOGIN=false
|
||||
|
||||
# AI API Configuration (Optional, can be entered in UI)
|
||||
# 注意:发布版本不再自动读取以下环境变量。
|
||||
# 如果你只是本地开发想用默认值,可以在自己机器的 .env.local 里填,
|
||||
# 然后在「设置 → AI 模型」里手动完成"添加供应商"流程。
|
||||
VITE_DEEPSEEK_API_KEY=
|
||||
VITE_AI_BASE_URL=https://api.deepseek.com
|
||||
VITE_AI_MODEL=deepseek-v4-flash
|
||||
VITE_AI_MODEL=deepseek-chat
|
||||
|
||||
# Message types to filter out (comma separated). Empty means show all message types.
|
||||
VITE_FILTER_MSG_TYPES=
|
||||
|
||||
@@ -13,3 +13,6 @@ docs/design/
|
||||
docs/ui-redesign-plan.md
|
||||
docs/ui-redesign-spec.md
|
||||
AGENTS.md
|
||||
findings.md
|
||||
progress.md
|
||||
task_plan.md
|
||||
|
||||
@@ -9,12 +9,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
|
||||
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||
|
||||
> 当前版本:`v2.1.0`。macOS 支持相对稳定;Windows 已初步支持微信 4.0 数据库连接、自动获取密钥和聊天记录查看,仍在持续兼容不同微信版本与本地目录结构。
|
||||
> macOS 支持相对稳定;Windows 已初步支持 但因聊天记录大/机械硬盘等问题 会有所卡顿,仍在持续兼容不同微信版本与本地目录结构。
|
||||
|
||||
## ✨ 功能特性
|
||||
|
||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||
- **全局搜索**: 快速搜索聊天内容。
|
||||
- **消息防撤回**: 高亮查看对方已撤回消息
|
||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||
@@ -51,42 +52,21 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
|
||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
## 📦 安装说明
|
||||
## 📖 使用方法
|
||||
|
||||
### macOS 版本已测 4.1.8
|
||||
安装、获取数据库密钥、连接微信数据及常见问题,请查看:
|
||||
|
||||
> 微信 macOS `4.1.8.100` 下载地址:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||
### [👉 WechatExplorer 完整使用教程](./docs/user-guide/getting-started.md)
|
||||
|
||||
1. 下载 Releases 中的 `xxx.dmg` 文件。
|
||||
2. 打开 DMG 并将应用拖动到 **Applications** (应用程序) 文件夹。
|
||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||
4. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥
|
||||
教程包含 macOS 与 Windows 的分步截图,以及数据目录、SIP、图片解密密钥和自动获取失败的排查方法。
|
||||
|
||||
### Windows 版本已测 4.1.9
|
||||
> 微信 4.0+ 在 macOS / Windows 上已支持部分能力,目前仍在持续适配。如需其他成熟方案,也可参考 [WeFlow](https://github.com/hicccc77/WeFlow) 和 [Chatlog](https://github.com/sjzar/chatlog)。
|
||||
|
||||
> 微信 Windows `4.1.9.57` 下载地址:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||
## 🛠️ 开发配置(可选)
|
||||
|
||||
1. 下载 Releases 中的 `xxx-setup.exe` 安装包。
|
||||
2. 双击安装,可按安装向导选择安装目录。
|
||||
3. 打开微信登录界面不要登录,然后点击获取密钥,提示登录 然后点击登录 自动获取密钥。
|
||||
本地开发需要 Node.js(推荐 v16+)和 pnpm 7。
|
||||
|
||||
> Windows 支持仍处于初步阶段。软件会有些卡顿, 如果自动获取密钥失败,请先确认微信客户端已登录并保持运行;不同微信安装路径、数据目录和权限环境可能仍需要继续适配。
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 使用前置要求
|
||||
|
||||
- **微信版本**:
|
||||
- 微信 4.0+: macOS / Windows 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
||||
- **macOS 自动获取密钥**: 需要先关闭 SIP,操作方式见 [macOS 关闭 SIP 教程](./docs/mac-disable-sip.md);关闭 SIP 会降低系统安全性,使用完成后建议重新开启。
|
||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
||||
- Node.js (推荐 v16+)
|
||||
- pnpm@7
|
||||
- 解密后的微信数据库文件 (`.db`) 和对应的密钥
|
||||
- AI API Key(支持 OpenAI 兼容 API,可选 DeepSeek/GPT/Claude/Moonshot 等)
|
||||
|
||||
### 环境变量配置 (.env)
|
||||
### 环境变量
|
||||
|
||||
可选配置项,可在 `.env` 文件中设置;本地开发时运行 `pnpm dev` 会在 `.env` 不存在时自动从 `.env.example` 复制一份。成品用户也可以直接在软件“设置”里填写或自动获取图片解密密钥。
|
||||
|
||||
@@ -100,20 +80,6 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
|
||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||
|
||||
#### 图片解密密钥说明
|
||||
|
||||
微信 4.0+ 的图片以 `.dat` 文件存储,需要密钥解密:
|
||||
|
||||
- **XOR Key**: 单字节 hex 值(如 `0x40`),用于简单的字节异或解密
|
||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||
|
||||
这两个密钥可以通过以下方式获取:
|
||||
|
||||
1. 在应用登录界面点击“自动获取密钥”
|
||||
2. 从 WeFlow/Chatlog 设置中导出
|
||||
3. 在软件“设置 -> 图片解密密钥”中自动获取或手动填写
|
||||
4. 如自动获取失败,可手动粘贴已获取的密钥
|
||||
|
||||
## 🤖 AI 集成(本地 HTTP API)
|
||||
|
||||
WechatExplorer 内置了一个本地 HTTP API 服务,默认监听 `127.0.0.1:6131`(纯本地,无鉴权),让你能够从 **Claude Desktop / Claude Code / Codex / curl / 任何脚本** 读取已经解锁的微信聊天记录。
|
||||
@@ -192,8 +158,24 @@ curl -G "http://127.0.0.1:6131/api/v1/resolve" \
|
||||
|
||||
本项目仅供学习和研究使用。请勿用于非法用途。开发者不对使用本项目造成的任何后果负责。请遵守相关法律法规和微信使用协议。
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=Wxw-Gu%2FWechatExplorer&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&theme=dark&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=Wxw-Gu/WechatExplorer&type=date&legend=top-left&sealed_token=cSQi7zyyCJXEyry3kvUhQJUB3RY8PjpgsI4KKZMH7m06AzRJU0EtAtKHcHtmhhgWoOU5lOjCBh-mZGzX4j50AaKL2krLbHLA7Ip7P1MWWolL9_TPXin1kg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## 🔗 参考致谢
|
||||
|
||||
- [WechatMessageExplorer](https://github.com/svcvit/WechatMessageExplorer)
|
||||
- [WeFlow](https://github.com/hicccc77/WeFlow)
|
||||
- [chatlog](https://github.com/sjzar/chatlog)
|
||||
|
||||
## 📱 交流与反馈
|
||||
|
||||
<p align="center">
|
||||
<img src="./public/二维码.jpg" alt="WechatExplorer 交流二维码" width="280" />
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# WechatExplorer 使用教程
|
||||
|
||||
本文介绍如何安装 WechatExplorer、自动获取微信数据库密钥,并完成首次连接。
|
||||
|
||||
## 1. 使用前准备
|
||||
|
||||
### 支持的版本
|
||||
|
||||
| 系统 | 已测试的微信版本 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| macOS | `4.1.8.100` | 支持相对稳定;自动获取密钥前需要关闭 SIP |
|
||||
| Windows | `4.1.9.57` | 已初步支持;不同安装路径和数据目录可能仍需手动调整 |
|
||||
|
||||
- macOS 微信下载:[wechat-versions v4.1.8.100](https://github.com/zsbai/wechat-versions/releases/tag/4.1.8.100)
|
||||
- Windows 微信下载:[wechat-win-archive v4.1.9.57](https://github.com/iibob/wechat-win-archive/releases#release-v4.1.9.57)
|
||||
- WechatExplorer 下载:[GitHub Releases](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> WechatExplorer 必须取得本机微信数据库密钥才能读取聊天记录。请仅处理你有权访问的数据。
|
||||
|
||||
### macOS:关闭 SIP
|
||||
|
||||
macOS 自动获取密钥前需要关闭 SIP,具体操作见 [macOS 关闭 SIP 教程](../mac-disable-sip.md)。
|
||||
|
||||
关闭 SIP 会降低系统安全性。建议了解风险后再操作,并在不再需要自动获取密钥时重新开启。
|
||||
|
||||
## 2. 安装 WechatExplorer
|
||||
|
||||
### macOS
|
||||
|
||||
1. 从 Releases 下载 `.dmg` 文件。
|
||||
2. 打开 DMG,将 WechatExplorer 拖入“应用程序”文件夹。
|
||||
3. 如果系统提示“无法打开,因为开发者无法验证”,请前往“系统设置 → 隐私与安全性”,点击“仍要打开”。
|
||||
4. 如果系统提示应用已损坏,在终端执行:
|
||||
|
||||
```bash
|
||||
xattr -cr "/Applications/WechatExplorer.app"
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
1. 从 Releases 下载 `-setup.exe` 安装包。
|
||||
2. 双击安装,并按安装向导完成操作。
|
||||
|
||||
## 3. 自动获取密钥
|
||||
|
||||
### 第一步:确认微信数据目录
|
||||
|
||||
启动 WechatExplorer 后,先检查页面中的“存储路径”是否正确。
|
||||
|
||||

|
||||
|
||||
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
|
||||
|
||||

|
||||
|
||||
### 第二步:让微信停留在登录页面
|
||||
|
||||
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
|
||||
|
||||

|
||||
|
||||
### 第三步:开始获取密钥
|
||||
|
||||
返回 WechatExplorer,点击“自动获取密钥”。
|
||||
|
||||
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
|
||||
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
|
||||
|
||||

|
||||
|
||||
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
|
||||
|
||||
### 第四步:完成连接
|
||||
|
||||
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
|
||||
|
||||

|
||||
|
||||
## 4. 图片解密密钥
|
||||
|
||||
微信 4.0 及以上版本的图片通常以 `.dat` 文件存储,显示图片还需要:
|
||||
|
||||
- **XOR Key**:单字节十六进制值,例如 `0x40`。
|
||||
- **AES Key**:用于 AES-128-ECB 解密的 16 字符字符串。
|
||||
|
||||
可以通过以下方式配置:
|
||||
|
||||
1. 使用首次连接页面的“自动获取密钥”。
|
||||
2. 在“设置 → 图片解密密钥”中自动获取或手动填写。
|
||||
3. 从 WeFlow 或 Chatlog 的设置中导出后手动填写。
|
||||
|
||||
数据库连接成功但图片无法显示时,请优先检查这两项密钥。
|
||||
|
||||
## 5. 常见问题
|
||||
|
||||
### 自动获取密钥失败
|
||||
|
||||
请依次确认:
|
||||
|
||||
1. 微信版本是否与上方已测试版本一致。
|
||||
2. 点击“自动获取密钥”时,微信是否停留在未登录页面。
|
||||
3. 微信数据目录是否正确;Windows 用户尤其需要检查是否多选或少选了一层目录。
|
||||
4. macOS 是否已按教程关闭 SIP,并完成系统授权。
|
||||
5. 微信和 WechatExplorer 是否都保持运行。
|
||||
|
||||
仍然失败时,可以切换到“手动输入”,粘贴从其他兼容工具中取得的数据库密钥。
|
||||
|
||||
### Windows 使用时卡顿
|
||||
|
||||
Windows 支持仍处于初步阶段,不同微信版本、安装路径、数据目录和权限环境可能存在差异。建议优先使用上方已测试的微信版本。
|
||||
|
||||
### 数据会上传吗?
|
||||
|
||||
聊天数据库在本机读取和处理。只有使用 AI 总结功能时,相关聊天内容才会按你配置的模型服务发送;是否启用以及使用哪个服务由你决定。
|
||||
|
||||
## 6. 下一步
|
||||
|
||||
- 在应用“设置”中填写兼容 OpenAI API 的模型服务和 API Key,使用 AI 总结功能。
|
||||
- 在应用的 **API** 页面安装 Reader Skill,让 Codex 或 Claude Code 读取和总结本地群聊。
|
||||
- 本地 API 的端点和调试方法见项目 [README](../../README.md#ai-集成本地-http-api)。
|
||||
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 214 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "wechatexplorer",
|
||||
"version": "2.1.2",
|
||||
"version": "2.1.5",
|
||||
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
|
||||
"keywords": [
|
||||
"wechat",
|
||||
@@ -44,11 +44,13 @@
|
||||
"build:linux": "electron-vite build && electron-builder --config electron-builder.yml --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@electron-toolkit/preload": "^3.0.2",
|
||||
"@electron-toolkit/utils": "^4.0.0",
|
||||
"@koromix/koffi-win32-x64": "3.1.0",
|
||||
"@tanstack/react-virtual": "^3.14.6",
|
||||
"fs-extra": "^11.3.2",
|
||||
"fzstd": "^0.1.1",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"koffi": "^3.1.0",
|
||||
"openai": "^6.10.0",
|
||||
"silk-wasm": "^3.7.1",
|
||||
|
||||
@@ -12,6 +12,7 @@ specifiers:
|
||||
'@electron-toolkit/utils': ^4.0.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@rollup/rollup-darwin-arm64': ^4.62.2
|
||||
'@tanstack/react-virtual': ^3.14.6
|
||||
'@types/fs-extra': ^11.0.4
|
||||
'@types/node': ^22.19.1
|
||||
'@types/react': ^19.2.7
|
||||
@@ -26,6 +27,7 @@ specifiers:
|
||||
eslint-plugin-react-refresh: ^0.4.24
|
||||
fs-extra: ^11.3.2
|
||||
fzstd: ^0.1.1
|
||||
jsonrepair: ^3.15.0
|
||||
koffi: ^3.1.0
|
||||
openai: ^6.10.0
|
||||
prettier: ^3.7.4
|
||||
@@ -40,8 +42,10 @@ dependencies:
|
||||
'@electron-toolkit/preload': 3.0.2_electron@43.1.0
|
||||
'@electron-toolkit/utils': 4.0.0_electron@43.1.0
|
||||
'@koromix/koffi-win32-x64': 3.1.0
|
||||
'@tanstack/react-virtual': 3.14.6_bokjwhiew3ov3ffvbmafuwoalq
|
||||
fs-extra: 11.3.2
|
||||
fzstd: 0.1.1
|
||||
jsonrepair: 3.15.0
|
||||
koffi: 3.1.0
|
||||
openai: 6.10.0
|
||||
silk-wasm: 3.7.1
|
||||
@@ -1124,6 +1128,21 @@ packages:
|
||||
defer-to-connect: 2.0.1
|
||||
dev: true
|
||||
|
||||
/@tanstack/react-virtual/3.14.6_bokjwhiew3ov3ffvbmafuwoalq:
|
||||
resolution: {integrity: sha512-4+Uq8m0/gzO4kMCHUEpTtGX1RnONK0C+g88b2ltwPMWUBiaVarBuWKoPJaz7gj1cKCVRAdyu+U8GcKhwCc2beA==}
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
dependencies:
|
||||
'@tanstack/virtual-core': 3.17.4
|
||||
react: 19.2.1
|
||||
react-dom: 19.2.1_react@19.2.1
|
||||
dev: false
|
||||
|
||||
/@tanstack/virtual-core/3.17.4:
|
||||
resolution: {integrity: sha512-nGm5KteqxasUdThLc2izl6dHUqLv0LQj7Nuyo5gYalTPf/U8a9ermvsl7reT+6ioBW1l8WfpP/mcU338nLXpqw==}
|
||||
dev: false
|
||||
|
||||
/@tootallnate/once/2.0.0:
|
||||
resolution: {integrity: sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==}
|
||||
engines: {node: '>= 10'}
|
||||
@@ -3414,6 +3433,11 @@ packages:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
/jsonrepair/3.15.0:
|
||||
resolution: {integrity: sha512-wy8OTjwsJwQRnQJkKnMJJ9vcytRdBPAgIF/Hy6+s1dAj42BHMKiyL8JzEieIl3JY7idt8eyHwBWTO8mh/+mtwA==}
|
||||
hasBin: true
|
||||
dev: false
|
||||
|
||||
/jsx-ast-utils/3.3.5:
|
||||
resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==}
|
||||
engines: {node: '>=4.0'}
|
||||
@@ -4037,7 +4061,6 @@ packages:
|
||||
dependencies:
|
||||
react: 19.2.1
|
||||
scheduler: 0.27.0
|
||||
dev: true
|
||||
|
||||
/react-is/16.13.1:
|
||||
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
|
||||
@@ -4051,7 +4074,6 @@ packages:
|
||||
/react/19.2.1:
|
||||
resolution: {integrity: sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
dev: true
|
||||
|
||||
/read-binary-file-arch/1.0.6:
|
||||
resolution: {integrity: sha512-BNg9EN3DD3GsDXX7Aa8O4p92sryjkmzYYgmgTAc6CA4uGLEDzFfxOxugu21akOxpcXHiEgsYkC6nPsQvLLLmEg==}
|
||||
@@ -4234,7 +4256,6 @@ packages:
|
||||
|
||||
/scheduler/0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
dev: true
|
||||
|
||||
/semver/5.7.2:
|
||||
resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==}
|
||||
|
||||
|
Before Width: | Height: | Size: 583 KiB |
|
Before Width: | Height: | Size: 157 KiB |
|
After Width: | Height: | Size: 158 KiB |
@@ -0,0 +1,83 @@
|
||||
import { app, shell } from 'electron'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
|
||||
const MAX_LOG_BYTES = 5 * 1024 * 1024
|
||||
const REDACTED_KEY = /(?:api[-_]?key|authorization|token|secret|password|database[-_]?key)/i
|
||||
|
||||
const sanitize = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 4) return '[depth-limited]'
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
.replace(/\bsk-[a-z0-9_-]{8,}\b/gi, '***')
|
||||
.replace(/\bBearer\s+[a-z0-9._~-]{8,}\b/gi, 'Bearer ***')
|
||||
.slice(0, 2000)
|
||||
}
|
||||
if (Array.isArray(value)) return value.slice(0, 30).map((item) => sanitize(item, depth + 1))
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value as Record<string, unknown>).map(([key, item]) => [
|
||||
key,
|
||||
REDACTED_KEY.test(key) ? '***' : sanitize(item, depth + 1)
|
||||
])
|
||||
)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export class AppLogger {
|
||||
private get logDir(): string {
|
||||
return app.getPath('logs')
|
||||
}
|
||||
|
||||
get logPath(): string {
|
||||
return path.join(this.logDir, 'wechatexplorer.log')
|
||||
}
|
||||
|
||||
private rotateIfNeeded(): void {
|
||||
try {
|
||||
if (!fs.existsSync(this.logPath) || fs.statSync(this.logPath).size < MAX_LOG_BYTES) return
|
||||
const previous = `${this.logPath}.1`
|
||||
if (fs.existsSync(previous)) fs.removeSync(previous)
|
||||
fs.moveSync(this.logPath, previous)
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
write(entry: AppLogEntry): void {
|
||||
try {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
this.rotateIfNeeded()
|
||||
const record = {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: app.isPackaged ? 'packaged' : 'development',
|
||||
level: entry.level,
|
||||
scope: String(entry.scope || 'app').slice(0, 80),
|
||||
message: String(entry.message || '').slice(0, 500),
|
||||
details: sanitize(entry.details || {})
|
||||
}
|
||||
fs.appendFileSync(this.logPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8' })
|
||||
if (!app.isPackaged) {
|
||||
const method =
|
||||
entry.level === 'error'
|
||||
? console.error
|
||||
: entry.level === 'warn'
|
||||
? console.warn
|
||||
: console.log
|
||||
method(`[${record.scope}] ${record.message}`, record.details)
|
||||
}
|
||||
} catch {
|
||||
// Logging must never interrupt the application.
|
||||
}
|
||||
}
|
||||
|
||||
reveal(): void {
|
||||
fs.ensureDirSync(this.logDir)
|
||||
if (!fs.existsSync(this.logPath)) fs.writeFileSync(this.logPath, '', 'utf8')
|
||||
shell.showItemInFolder(this.logPath)
|
||||
}
|
||||
}
|
||||
|
||||
export const appLogger = new AppLogger()
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { Message } from '../shared/types'
|
||||
|
||||
export const exportStyles = `:root{color-scheme:light;--page:#edf2f0;--panel:#fff;--text:#1d2a25;--muted:#68766f;--border:#d8e2dc;--mine:#d9f0e2;--accent:#176b57}*{box-sizing:border-box}body{margin:0;background:var(--page);color:var(--text);font:14px system-ui,-apple-system,"PingFang SC","Microsoft YaHei",sans-serif}.page{max-width:1240px;height:100vh;margin:auto;padding:22px 28px;display:flex;flex-direction:column}.toolbar{display:flex;align-items:center;justify-content:space-between;gap:20px;background:var(--panel);border:1px solid var(--border);border-radius:18px;padding:18px 24px;box-shadow:0 8px 24px #29483b12}.title{font-size:18px;font-weight:750}.meta{color:var(--muted);margin-left:12px;font-size:13px}.controls{display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-top:0}.controls input,.controls button{border:1px solid var(--border);border-radius:10px;padding:9px 12px;background:#fff;font:inherit}.controls input[type=search]{width:260px}.controls input[type=datetime-local],.controls #jump{display:none}.controls button{background:var(--accent);border-color:var(--accent);color:#fff;cursor:pointer}.count{margin-left:8px;color:var(--muted);font-size:13px}.scroll{margin-top:18px;overflow:auto;flex:1;padding:10px 6px 30px;display:flex;flex-direction:column;align-items:center}.message{display:flex;flex-direction:column;gap:6px;width:min(100%,820px);margin:0 0 22px}.message.hidden{display:none}.message.sent{align-items:flex-end;margin-left:auto}.message.system{align-items:center;width:min(100%,820px)}.message.system .row{justify-content:center}.message.system .avatar{display:none}.message.system .bubble{max-width:92%;padding:5px 10px;border:0;border-radius:5px;background:#e9eeeb;color:var(--muted);font-size:11px;text-align:center;box-shadow:none}.message.system .sender{display:none}.time{color:var(--muted);font-size:11px;margin:0 12px}.row{display:flex;gap:12px;align-items:flex-end}.sent .row{flex-direction:row-reverse}.avatar{width:38px;height:38px;flex:0 0 auto;border-radius:50%;overflow:hidden;background:#dcebe4;display:grid;place-items:center}.avatar img{width:100%;height:100%;object-fit:cover}.bubble{max-width:min(78%,760px);padding:13px 15px;border:1px solid var(--border);border-radius:10px 18px 18px 18px;background:#fff;box-shadow:0 4px 12px #29483b0d}.sent .bubble{background:var(--mine);border-color:#c7e6d4;border-radius:18px 10px 18px 18px}.sender{color:var(--muted);font-size:12px;margin-bottom:5px}.content{line-height:1.7;word-break:break-word;white-space:pre-wrap}.audio-wrap{width:260px;min-width:260px}.audio{display:block;width:260px;height:38px}.quote-reference{margin-top:10px;padding:8px 11px;border-left:3px solid #8eb4a3;background:#f1f6f3;color:var(--muted);display:grid;gap:3px}.quote-reference strong{font-weight:650;color:var(--text)}.quote-reference span{white-space:pre-wrap}.media-image{display:block;max-width:100%;max-height:360px;border-radius:12px;object-fit:contain;background:#eef2f5;cursor:zoom-in}.lightbox{position:fixed;inset:0;display:none;place-items:center;background:#14231ddd;z-index:10;padding:24px;overflow:auto}.lightbox.open{display:grid}.lightbox img{width:min(86vw,980px);max-height:88vh;object-fit:contain;cursor:zoom-in;transform:scale(var(--zoom,1));transform-origin:center;transition:transform .12s ease}`
|
||||
const safe = (value: unknown): string =>
|
||||
String(value ?? '').replace(
|
||||
/[&<>"']/g,
|
||||
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c] || c
|
||||
)
|
||||
|
||||
export function renderExportPage(name: string, messages: Message[]): string {
|
||||
const body = messages
|
||||
.map((m) => {
|
||||
const avatar = m.img
|
||||
? `<img src="${safe(m.img)}" alt="">`
|
||||
: safe((m.name || (m.isSender ? '我' : '友')).slice(0, 1))
|
||||
const audio = m.voiceDataUrl
|
||||
? `<div class="audio-wrap"><audio class="audio" controls preload="metadata" src="${m.voiceDataUrl}"></audio></div>`
|
||||
: ''
|
||||
const quote =
|
||||
m.contentData?.type === 'quote'
|
||||
? `<div class="quote-reference"><strong>${safe(m.contentData.quotedSender || '引用消息')}</strong><span>${safe(m.contentData.quotedContent || '[引用消息]')}</span></div>`
|
||||
: ''
|
||||
const media =
|
||||
m.exportMediaUrl && m.exportMediaType === 'image'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="图片">`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'video'
|
||||
? `<video class="media-image" controls src="${safe(m.exportMediaUrl)}"></video>`
|
||||
: m.exportMediaUrl && m.exportMediaType === 'sticker'
|
||||
? `<img class="media-image" src="${safe(m.exportMediaUrl)}" alt="表情包">`
|
||||
: ''
|
||||
const avatarMarkup =
|
||||
m.exportShowAvatar === false
|
||||
? ''
|
||||
: `<div class="avatar">${m.exportAvatarUrl ? `<img src="${safe(m.exportAvatarUrl)}" alt="">` : avatar}</div>`
|
||||
const isPat = m.contentData?.type === 'system' && m.contentData.pat
|
||||
const text = m.content || (m.contentData?.type === 'quote' ? m.contentData.title : '')
|
||||
return `<article class="message${m.isSender ? ' sent' : ''}${isPat ? ' system' : ''}" data-time="${m.createTime || 0}" data-search="${safe(`${m.name || ''} ${m.content || ''} ${m.type}`.toLowerCase())}"><div class="time">${safe(m.datetime)}</div><div class="row">${isPat ? '' : avatarMarkup}<div class="bubble"><div class="sender">${isPat ? '' : safe(m.name || (m.isSender ? '我' : '联系人'))}</div>${media}${audio}${quote}<div class="content">${safe(text || (!media && !audio && !quote ? `[${m.type}]` : ''))}</div></div></div></article>`
|
||||
})
|
||||
.join('')
|
||||
return `<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${safe(name)} - 聊天记录</title><style>${exportStyles}</style></head><body><main class="page"><header class="toolbar"><div><span class="title">${safe(name)}</span><span class="meta">${messages.length.toLocaleString()} 条消息</span></div><div class="controls"><input id="query" type="search" placeholder="搜索消息..."><input id="point" type="datetime-local"><button id="jump">跳转</button><span class="count" id="count"></span></div></header><section class="scroll" id="messages">${body}</section></main><div class="lightbox" id="lightbox"><img id="lightbox-image" alt="预览"></div><script>(()=>{const all=[...document.querySelectorAll('.message')],q=document.querySelector('#query'),d=document.querySelector('#point'),c=document.querySelector('#count'),box=document.querySelector('#lightbox'),preview=document.querySelector('#lightbox-image');let zoom=1;const updateZoom=()=>preview.style.setProperty('--zoom',zoom);const update=()=>{const term=q.value.trim().toLowerCase(),at=d.value?new Date(d.value).getTime()/1000:0;let n=0;all.forEach(x=>{const ok=(!term||x.dataset.search.includes(term))&&(!at||Number(x.dataset.time)>=at);x.classList.toggle('hidden',!ok);if(ok)n++});c.textContent='共 '+n+' 条'};q.addEventListener('input',update);d.addEventListener('change',update);document.querySelector('#jump').onclick=()=>{const at=d.value?new Date(d.value).getTime()/1000:0;all.find(x=>Number(x.dataset.time)>=at)?.scrollIntoView({behavior:'smooth',block:'center'})};document.querySelectorAll('.media-image').forEach(image=>image.addEventListener('click',()=>{if(image.tagName==='IMG'){preview.src=image.src;zoom=1;updateZoom();box.classList.add('open')}}));preview.addEventListener('wheel',event=>{event.preventDefault();zoom=Math.min(5,Math.max(.5,zoom+(event.deltaY<0?.2:-.2)));updateZoom()},{passive:false});preview.addEventListener('dblclick',()=>{zoom=1;updateZoom()});box.addEventListener('click',event=>{if(event.target===box)box.classList.remove('open')});update()})()</script></body></html>`
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { app, BrowserWindow, shell } from 'electron'
|
||||
import { promises as fs } from 'fs'
|
||||
import { extname, join } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as chat from './services/chat-service'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportRequest,
|
||||
ExportResult
|
||||
} from '../shared/export'
|
||||
import type { Message } from '../shared/types'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { renderExportPage } from './export-html-template'
|
||||
import { ImageDecryptService } from './image-decrypt-service'
|
||||
import { ImageKeyConfigService } from './services/image-key-config-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
|
||||
const jobs = new Set<string>()
|
||||
const safeFilePart = (value: string): string =>
|
||||
value.replace(/[\\/:*?"<>|]/g, '_').trim() || '聊天档案'
|
||||
const exportStamp = (): string => {
|
||||
const date = new Date()
|
||||
const pad = (value: number): string => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}${pad(date.getSeconds())}`
|
||||
}
|
||||
const imageKeys = new ImageKeyConfigService()
|
||||
function decodeDataUrl(data: string): { extension: string; buffer: Buffer } | null {
|
||||
const match = /^data:([^;]+);base64,(.+)$/s.exec(data)
|
||||
if (!match) return null
|
||||
return {
|
||||
extension: match[1].split('/')[1] === 'jpeg' ? 'jpg' : match[1].split('/')[1],
|
||||
buffer: Buffer.from(match[2], 'base64')
|
||||
}
|
||||
}
|
||||
const normalizeAssetExtension = (value: string): string => {
|
||||
const extension = value.toLowerCase().replace(/^\./, '')
|
||||
return /^(png|jpg|jpeg|webp|gif)$/.test(extension)
|
||||
? extension === 'jpeg'
|
||||
? 'jpg'
|
||||
: extension
|
||||
: 'jpg'
|
||||
}
|
||||
const detectAssetExtension = (buffer: Buffer): string | null => {
|
||||
if (buffer.subarray(0, 3).toString('ascii') === 'GIF') return 'gif'
|
||||
if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])))
|
||||
return 'png'
|
||||
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return 'jpg'
|
||||
if (
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
)
|
||||
return 'webp'
|
||||
return null
|
||||
}
|
||||
async function readAvatarAsset(
|
||||
source: string
|
||||
): Promise<{ extension: string; buffer: Buffer } | null> {
|
||||
const decoded = decodeDataUrl(source)
|
||||
if (decoded) return { ...decoded, extension: normalizeAssetExtension(decoded.extension) }
|
||||
|
||||
try {
|
||||
if (/^https?:\/\//i.test(source)) {
|
||||
const response = await fetch(source)
|
||||
if (!response.ok) return null
|
||||
const contentType = response.headers.get('content-type')?.split(';')[0].split('/')[1]
|
||||
const extension = normalizeAssetExtension(contentType || extname(new URL(source).pathname))
|
||||
const buffer = Buffer.from(await response.arrayBuffer())
|
||||
return { extension: detectAssetExtension(buffer) || extension, buffer }
|
||||
}
|
||||
const path = source.startsWith('file://') ? fileURLToPath(source) : source
|
||||
const buffer = await fs.readFile(path)
|
||||
return {
|
||||
extension: detectAssetExtension(buffer) || normalizeAssetExtension(extname(path)),
|
||||
buffer
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const kindOf = (message: Message): ExportMessageKind => {
|
||||
const type = message.contentData?.type
|
||||
if (type === 'system' && message.contentData?.pat) return 'text'
|
||||
if (
|
||||
type === 'image' ||
|
||||
type === 'video' ||
|
||||
type === 'voice' ||
|
||||
type === 'sticker' ||
|
||||
type === 'share' ||
|
||||
type === 'location' ||
|
||||
type === 'system'
|
||||
)
|
||||
return type
|
||||
if (message.type === '图片') return 'image'
|
||||
if (message.type === '视频') return 'video'
|
||||
if (message.type === '语音') return 'voice'
|
||||
if (message.type === '表情包') return 'sticker'
|
||||
return 'text'
|
||||
}
|
||||
const csv = (value: unknown): string => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
|
||||
function render(format: ExportRequest['format'], messages: Message[], name: string): string {
|
||||
if (format === 'html') return renderExportPage(name, messages)
|
||||
if (format === 'json')
|
||||
return JSON.stringify({ name, exportedAt: new Date().toISOString(), messages }, null, 2)
|
||||
if (format === 'markdown')
|
||||
return `# ${name}\n\n${messages.map((m) => `**${m.name || (m.isSender ? '我' : '联系人')}** · ${m.datetime}\n\n${m.content || `[${m.type}]`}\n`).join('\n')}`
|
||||
return [
|
||||
'时间,发送者,类型,内容',
|
||||
...messages.map((m) =>
|
||||
[m.datetime, m.name || (m.isSender ? '我' : '联系人'), m.type, m.content].map(csv).join(',')
|
||||
)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export async function runExport(request: ExportRequest, win: BrowserWindow): Promise<ExportResult> {
|
||||
jobs.add(request.jobId)
|
||||
const send = (p: ExportJobProgress): void => {
|
||||
if (!win.isDestroyed()) win.webContents.send('export:progress', p)
|
||||
}
|
||||
try {
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 0, total: 100, percent: 0 })
|
||||
await new Promise<void>((resolve) => setImmediate(resolve))
|
||||
const messages = chat
|
||||
.listMessages(request.userMd5, request.startTime, request.endTime)
|
||||
.filter((m) => request.kinds.includes(kindOf(m)))
|
||||
for (const message of messages) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const mappedName = message.senderId ? request.nameMap?.[message.senderId] : undefined
|
||||
if (mappedName) message.name = mappedName
|
||||
}
|
||||
send({ jobId: request.jobId, phase: 'reading', processed: 10, total: 100, percent: 10 })
|
||||
if (!jobs.has(request.jobId)) {
|
||||
send({ jobId: request.jobId, phase: 'cancelled', processed: 0, percent: 10 })
|
||||
return { success: false, error: '已取消' }
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: 0,
|
||||
total: messages.length,
|
||||
percent: 15
|
||||
})
|
||||
const root = join(app.getPath('documents'), 'WechatExplorer', '导出')
|
||||
await fs.mkdir(root, { recursive: true })
|
||||
const ext = request.format === 'markdown' ? 'md' : request.format
|
||||
const outputFolder = `${safeFilePart(request.outputName)}_${exportStamp()}`
|
||||
const outputDir = join(root, outputFolder)
|
||||
const outputPath =
|
||||
request.format === 'html'
|
||||
? join(outputDir, 'index.html')
|
||||
: join(root, `${outputFolder}.${ext}`)
|
||||
if (request.format === 'html') {
|
||||
await fs.mkdir(join(outputDir, 'voices'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'media'), { recursive: true })
|
||||
await fs.mkdir(join(outputDir, 'avatars'), { recursive: true })
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
const avatarUsernames = Array.from(
|
||||
new Set(
|
||||
messages
|
||||
.map((message) => message.senderId)
|
||||
.filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
const avatarMap =
|
||||
request.includeAvatars === false
|
||||
? {}
|
||||
: { ...chat.getContactAvatars(avatarUsernames), ...(request.avatarUrls || {}) }
|
||||
const imageConfig = imageKeys.getConfig()
|
||||
const imageService =
|
||||
client && imageConfig.aesKey
|
||||
? new ImageDecryptService(imageConfig.xorKey || '0x40', imageConfig.aesKey, client)
|
||||
: null
|
||||
const videoService = client ? new VideoAssetService(client) : null
|
||||
const stickerService = client ? new StickerService(client) : null
|
||||
const exportedAvatars = new Map<string, string>()
|
||||
const voiceService =
|
||||
request.includeMedia && chat.getChatDb()
|
||||
? new VoiceService(chat.getChatDb()!.getWcdb4Client())
|
||||
: null
|
||||
if (voiceService) {
|
||||
for (const [index, message] of messages.entries()) {
|
||||
if (
|
||||
kindOf(message) !== 'voice' ||
|
||||
!message.sessionId ||
|
||||
!message.localId ||
|
||||
!message.createTime
|
||||
)
|
||||
continue
|
||||
const voice = await voiceService.resolveVoice(
|
||||
message.sessionId,
|
||||
message.localId,
|
||||
message.createTime,
|
||||
message.serverId
|
||||
)
|
||||
if (!voice.success || !voice.data) continue
|
||||
const voiceName = `voice_${index + 1}_${message.localId}.wav`
|
||||
const audioBuffer = Buffer.from(voice.data, 'base64')
|
||||
await fs.writeFile(join(outputDir, 'voices', voiceName), audioBuffer)
|
||||
message.voiceDataUrl = `voices/${voiceName}`
|
||||
message.voiceDuration = Math.max(1, Math.round(audioBuffer.length / (24000 * 2)))
|
||||
}
|
||||
}
|
||||
for (const [index, message] of messages.entries()) {
|
||||
message.exportShowAvatar = request.includeAvatars !== false
|
||||
const avatar = (message.senderId ? avatarMap[message.senderId] : undefined) || message.img
|
||||
const resolvedAvatar = avatar ? await readAvatarAsset(avatar) : null
|
||||
const avatarBuffer = resolvedAvatar?.buffer || null
|
||||
const avatarExtension = resolvedAvatar?.extension || 'jpg'
|
||||
if (avatarBuffer) {
|
||||
const avatarKey = message.senderId || `message_${index + 1}`
|
||||
let avatarName = exportedAvatars.get(avatarKey)
|
||||
if (!avatarName) {
|
||||
avatarName = `avatar_${exportedAvatars.size + 1}.${avatarExtension}`
|
||||
await fs.writeFile(join(outputDir, 'avatars', avatarName), avatarBuffer)
|
||||
exportedAvatars.set(avatarKey, avatarName)
|
||||
}
|
||||
message.exportAvatarUrl = `avatars/${avatarName}`
|
||||
}
|
||||
if (!request.includeMedia || !message.contentData) {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
continue
|
||||
}
|
||||
if (message.contentData.type === 'image' && imageService) {
|
||||
const file = imageService.findImageFile(
|
||||
message.contentData.md5,
|
||||
message.contentData.datName,
|
||||
{ allowThumbnail: true }
|
||||
)
|
||||
const decrypted = file ? imageService.decryptImageToBase64WithFallback(file, true) : null
|
||||
const decoded = decrypted ? decodeDataUrl(decrypted.data) : null
|
||||
if (decoded) {
|
||||
const name = `image_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'image'
|
||||
}
|
||||
} else if (message.contentData.type === 'video' && videoService) {
|
||||
const hashes = [
|
||||
message.contentData.md5,
|
||||
message.contentData.newMd5,
|
||||
message.contentData.rawMd5
|
||||
].filter((value): value is string => Boolean(value))
|
||||
const resolved = videoService.resolve(hashes)
|
||||
const token = resolved.url?.split('/').pop()
|
||||
const source = token ? videoService.pathForToken(token) : undefined
|
||||
if (source) {
|
||||
const name = `video_${index + 1}.mp4`
|
||||
await fs.copyFile(source, join(outputDir, 'media', name))
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'video'
|
||||
}
|
||||
} else if (message.contentData.type === 'sticker' && stickerService) {
|
||||
const stickerSource = message.contentData.url || message.contentData.thumbUrl
|
||||
const result = await stickerService.resolveSticker(stickerSource, message.contentData.md5)
|
||||
const decoded = result.data
|
||||
? decodeDataUrl(result.data)
|
||||
: stickerSource
|
||||
? await readAvatarAsset(stickerSource)
|
||||
: null
|
||||
if (decoded) {
|
||||
const name = `sticker_${index + 1}.${decoded.extension}`
|
||||
await fs.writeFile(join(outputDir, 'media', name), decoded.buffer)
|
||||
message.exportMediaUrl = `media/${name}`
|
||||
message.exportMediaType = 'sticker'
|
||||
}
|
||||
}
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: index + 1,
|
||||
total: messages.length,
|
||||
percent: 15 + Math.round(((index + 1) / Math.max(messages.length, 1)) * 75)
|
||||
})
|
||||
}
|
||||
} else {
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'writing',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 90
|
||||
})
|
||||
}
|
||||
await fs.writeFile(outputPath, render(request.format, messages, request.name), 'utf8')
|
||||
send({
|
||||
jobId: request.jobId,
|
||||
phase: 'completed',
|
||||
processed: messages.length,
|
||||
total: messages.length,
|
||||
percent: 100,
|
||||
outputPath
|
||||
})
|
||||
return { success: true, outputPath, messageCount: messages.length }
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
send({ jobId: request.jobId, phase: 'failed', processed: 0, error: message })
|
||||
return { success: false, error: message }
|
||||
} finally {
|
||||
jobs.delete(request.jobId)
|
||||
}
|
||||
}
|
||||
export function cancelExport(jobId: string): void {
|
||||
jobs.delete(jobId)
|
||||
}
|
||||
export async function revealExport(path: string): Promise<void> {
|
||||
shell.showItemInFolder(path)
|
||||
}
|
||||
@@ -4,12 +4,28 @@ import crypto from 'crypto'
|
||||
import os from 'os'
|
||||
import { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
export class ImageDecryptService {
|
||||
private readonly defaultV1AesKey = 'cfcd208495d565ef'
|
||||
const imageDecryptDebugEnabled = process.env['WECHATEXPLORER_DEBUG_IMAGE'] === '1'
|
||||
const imageDecryptLog = (...args: unknown[]): void => {
|
||||
if (imageDecryptDebugEnabled) console.log(...args)
|
||||
}
|
||||
|
||||
type DecodedImage = {
|
||||
data: string
|
||||
filePath: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
const MAX_DECODED_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
|
||||
export class ImageDecryptService {
|
||||
private xorKey: number = 0
|
||||
private aesKey: string = ''
|
||||
private wcdb4Client: Wcdb4Client | null = null
|
||||
private accountDirResolved = false
|
||||
private cachedAccountDir: string | null = null
|
||||
private imagePathCache = new Map<string, string>()
|
||||
private decodedImageCache = new Map<string, DecodedImage>()
|
||||
private decodedImageCacheBytes = 0
|
||||
|
||||
constructor(xorKey: string, aesKey: string, wcdb4Client?: Wcdb4Client | null) {
|
||||
// 解析 XOR Key (支持 0x40 或 64 格式)
|
||||
@@ -29,9 +45,13 @@ export class ImageDecryptService {
|
||||
* 获取账号目录
|
||||
*/
|
||||
private getAccountDir(): string | null {
|
||||
if (this.accountDirResolved) return this.cachedAccountDir
|
||||
this.accountDirResolved = true
|
||||
|
||||
const wcdbAccountRoot = this.wcdb4Client?.getAccountRoot()
|
||||
if (wcdbAccountRoot && existsSync(wcdbAccountRoot)) {
|
||||
return wcdbAccountRoot
|
||||
this.cachedAccountDir = wcdbAccountRoot
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
const homeDir = os.homedir()
|
||||
@@ -41,7 +61,7 @@ export class ImageDecryptService {
|
||||
)
|
||||
|
||||
if (!existsSync(accountRoot)) {
|
||||
console.log('[ImageDecrypt] account root not found:', accountRoot)
|
||||
imageDecryptLog('[ImageDecrypt] account root not found:', accountRoot)
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -61,12 +81,13 @@ export class ImageDecryptService {
|
||||
.sort((a, b) => b.mtime - a.mtime)
|
||||
|
||||
if (accounts.length === 0) {
|
||||
console.log('[ImageDecrypt] no accounts found')
|
||||
imageDecryptLog('[ImageDecrypt] no accounts found')
|
||||
return null
|
||||
}
|
||||
|
||||
// 返回最新的账号目录
|
||||
return join(accountRoot, accounts[0].name)
|
||||
this.cachedAccountDir = join(accountRoot, accounts[0].name)
|
||||
return this.cachedAccountDir
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,15 +96,33 @@ export class ImageDecryptService {
|
||||
findImageFile(
|
||||
md5?: string,
|
||||
imageDatName?: string,
|
||||
options?: { allowThumbnail?: boolean }
|
||||
options?: { allowThumbnail?: boolean; accountDir?: string; preferThumbnail?: boolean }
|
||||
): string | null {
|
||||
const accountDir = this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
const allowThumbnail = options?.allowThumbnail !== false
|
||||
|
||||
const normalizedMd5 = this.normalizeDatBase(md5 || '')
|
||||
const normalizedDatName = this.normalizeDatBase(imageDatName || '')
|
||||
console.log('[ImageDecrypt] findImageFile:', {
|
||||
const pathCacheKey = [
|
||||
normalizedMd5,
|
||||
normalizedDatName,
|
||||
allowThumbnail ? 'thumb' : 'original',
|
||||
options?.preferThumbnail ? 'prefer-thumb' : 'prefer-original',
|
||||
options?.accountDir || ''
|
||||
].join('|')
|
||||
const cachedPath = this.imagePathCache.get(pathCacheKey)
|
||||
if (cachedPath && existsSync(cachedPath)) return cachedPath
|
||||
|
||||
const rememberPath = (path: string | null): string | null => {
|
||||
if (path) this.imagePathCache.set(pathCacheKey, path)
|
||||
return path
|
||||
}
|
||||
|
||||
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
|
||||
const accountDir =
|
||||
options?.accountDir && existsSync(options.accountDir)
|
||||
? options.accountDir
|
||||
: this.getAccountDir()
|
||||
if (!accountDir) return null
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile:', {
|
||||
md5: normalizedMd5,
|
||||
imageDatName: normalizedDatName,
|
||||
accountDir,
|
||||
@@ -94,10 +133,14 @@ export class ImageDecryptService {
|
||||
const hardlink = this.wcdb4Client?.resolveImageHardlink(key)
|
||||
const fullPath = typeof hardlink?.full_path === 'string' ? hardlink.full_path : ''
|
||||
if (fullPath && existsSync(fullPath)) {
|
||||
const selected = this.getPreferredDatVariantPath(fullPath, allowThumbnail)
|
||||
const selected = this.getPreferredDatVariantPath(
|
||||
fullPath,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (allowThumbnail || !this.isThumbnailName(basename(selected))) {
|
||||
console.log('[ImageDecrypt] hardlink hit:', selected)
|
||||
return selected
|
||||
imageDecryptLog('[ImageDecrypt] hardlink hit:', selected)
|
||||
return rememberPath(selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,29 +148,76 @@ export class ImageDecryptService {
|
||||
// 尝试 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)
|
||||
imageDecryptLog('[ImageDecrypt] attach dir not found:', attachDir)
|
||||
return rememberPath(
|
||||
this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
normalizedMd5 || normalizedDatName,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
const searchKeys = this.uniq([normalizedMd5, normalizedDatName])
|
||||
if (searchKeys.length === 0) return null
|
||||
|
||||
for (const key of searchKeys) {
|
||||
const directHit = this.fastProbabilisticSearch(attachDir, key, allowThumbnail)
|
||||
if (directHit) return directHit
|
||||
const directHit = this.fastProbabilisticSearch(
|
||||
attachDir,
|
||||
key,
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (directHit) return rememberPath(directHit)
|
||||
}
|
||||
|
||||
const legacyHit = this.findImageFileInLegacyDirs(accountDir, searchKeys[0], allowThumbnail)
|
||||
if (legacyHit) return legacyHit
|
||||
const legacyHit = this.findImageFileInLegacyDirs(
|
||||
accountDir,
|
||||
searchKeys[0],
|
||||
allowThumbnail,
|
||||
options?.preferThumbnail
|
||||
)
|
||||
if (legacyHit) return rememberPath(legacyHit)
|
||||
|
||||
console.log('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
imageDecryptLog('[ImageDecrypt] findImageFile miss for:', searchKeys)
|
||||
return null
|
||||
}
|
||||
|
||||
getCachedDecodedImage(key: string): DecodedImage | null {
|
||||
const cached = this.decodedImageCache.get(key)
|
||||
if (!cached) return null
|
||||
this.decodedImageCache.delete(key)
|
||||
this.decodedImageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
cacheDecodedImage(key: string, image: DecodedImage): void {
|
||||
const size = image.data.length * 2
|
||||
const previous = this.decodedImageCache.get(key)
|
||||
if (previous) {
|
||||
this.decodedImageCacheBytes -= previous.data.length * 2
|
||||
this.decodedImageCache.delete(key)
|
||||
}
|
||||
this.decodedImageCache.set(key, image)
|
||||
this.decodedImageCacheBytes += size
|
||||
while (
|
||||
this.decodedImageCacheBytes > MAX_DECODED_IMAGE_CACHE_BYTES &&
|
||||
this.decodedImageCache.size > 1
|
||||
) {
|
||||
const oldestKey = this.decodedImageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
const oldest = this.decodedImageCache.get(oldestKey)
|
||||
this.decodedImageCache.delete(oldestKey)
|
||||
this.decodedImageCacheBytes -= oldest?.data.length ? oldest.data.length * 2 : 0
|
||||
}
|
||||
}
|
||||
|
||||
private fastProbabilisticSearch(
|
||||
attachDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -144,9 +234,9 @@ export class ImageDecryptService {
|
||||
join(attachDir, dir1, dir2, 'Image', variant),
|
||||
join(attachDir, dir1, dir2, 'image', variant)
|
||||
]
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail)
|
||||
const found = this.getLargestExistingPath(candidates, allowThumbnail, preferThumbnail)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] prefix path hit:', found)
|
||||
imageDecryptLog('[ImageDecrypt] prefix path hit:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
@@ -172,17 +262,18 @@ export class ImageDecryptService {
|
||||
|
||||
const found = this.getLargestExistingPath(
|
||||
variants.map((variant) => join(imgDir, variant)),
|
||||
allowThumbnail
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) {
|
||||
console.log('[ImageDecrypt] found at:', found)
|
||||
imageDecryptLog('[ImageDecrypt] found at:', found)
|
||||
return found
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[ImageDecrypt]遍历目录失败:', e)
|
||||
imageDecryptLog('[ImageDecrypt]遍历目录失败:', e)
|
||||
}
|
||||
|
||||
return null
|
||||
@@ -191,7 +282,8 @@ export class ImageDecryptService {
|
||||
private findImageFileInLegacyDirs(
|
||||
accountDir: string,
|
||||
datName: string,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const normalized = this.normalizeDatBase(datName)
|
||||
if (!normalized) return null
|
||||
@@ -203,7 +295,7 @@ export class ImageDecryptService {
|
||||
].filter((root) => existsSync(root))
|
||||
|
||||
for (const root of roots) {
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail)
|
||||
const found = this.recursiveFindDat(root, normalized, 5, allowThumbnail, preferThumbnail)
|
||||
if (found) return found
|
||||
}
|
||||
|
||||
@@ -214,30 +306,52 @@ export class ImageDecryptService {
|
||||
dir: string,
|
||||
datName: string,
|
||||
depth: number,
|
||||
allowThumbnail = true
|
||||
allowThumbnail = true,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
if (depth < 0) return null
|
||||
|
||||
try {
|
||||
const variantNames = this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
const variants = new Set(
|
||||
this.buildPreferredDatNames(datName).filter(
|
||||
(name) => allowThumbnail || !this.isThumbnailName(name)
|
||||
)
|
||||
preferThumbnail
|
||||
? [
|
||||
...variantNames.filter((name) => this.isThumbnailName(name)),
|
||||
...variantNames.filter((name) => !this.isThumbnailName(name))
|
||||
]
|
||||
: variantNames
|
||||
)
|
||||
const entries = readdirSync(dir)
|
||||
const matchingFiles: string[] = []
|
||||
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
|
||||
matchingFiles.push(fullPath)
|
||||
}
|
||||
}
|
||||
const preferredFile = this.getLargestExistingPath(
|
||||
matchingFiles,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (preferredFile) {
|
||||
imageDecryptLog('[ImageDecrypt] legacy path hit:', preferredFile)
|
||||
return preferredFile
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dir, entry)
|
||||
if (!statSync(fullPath).isDirectory()) continue
|
||||
const found = this.recursiveFindDat(fullPath, datName, depth - 1, allowThumbnail)
|
||||
const found = this.recursiveFindDat(
|
||||
fullPath,
|
||||
datName,
|
||||
depth - 1,
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (found) return found
|
||||
}
|
||||
} catch {
|
||||
@@ -252,13 +366,13 @@ export class ImageDecryptService {
|
||||
*/
|
||||
decryptImage(datPath: string): Buffer | null {
|
||||
if (!existsSync(datPath)) {
|
||||
console.log('[ImageDecrypt] file not found:', datPath)
|
||||
imageDecryptLog('[ImageDecrypt] file not found:', datPath)
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const version = this.getDatVersion(datPath)
|
||||
console.log(
|
||||
imageDecryptLog(
|
||||
'[ImageDecrypt] dat version:',
|
||||
version,
|
||||
'file:',
|
||||
@@ -268,26 +382,24 @@ export class ImageDecryptService {
|
||||
)
|
||||
|
||||
let decrypted: Buffer
|
||||
if (version === 1) {
|
||||
console.log('[ImageDecrypt] using V1 (default AES key)')
|
||||
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else if (version === 2) {
|
||||
console.log('[ImageDecrypt] using V2 (user AES key)')
|
||||
if (version === 2) {
|
||||
// WeChat 4.0 标准 dat 头: 07 08 56 32 08 07
|
||||
imageDecryptLog('[ImageDecrypt] using WeChat 4.0 (user AES key)')
|
||||
if (!this.aesKey) {
|
||||
console.log('[ImageDecrypt] no AES key configured')
|
||||
imageDecryptLog('[ImageDecrypt] no AES key configured')
|
||||
return null
|
||||
}
|
||||
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
|
||||
decrypted = this.decryptDatV4(datPath, key)
|
||||
} else {
|
||||
console.log('[ImageDecrypt] unsupported dat version:', version)
|
||||
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
|
||||
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
|
||||
return null
|
||||
}
|
||||
|
||||
return decrypted
|
||||
} catch (error) {
|
||||
console.error('[ImageDecrypt] decrypt error:', error)
|
||||
imageDecryptLog('[ImageDecrypt] decrypt error:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -309,7 +421,7 @@ export class ImageDecryptService {
|
||||
const unwrapped = this.unwrapWxgf(decrypted)
|
||||
const ext = this.detectImageExtension(unwrapped)
|
||||
if (!ext) {
|
||||
console.log('[ImageDecrypt] unknown image format')
|
||||
imageDecryptLog('[ImageDecrypt] unknown image format')
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -346,12 +458,13 @@ export class ImageDecryptService {
|
||||
const data = this.decryptImageToBase64(candidate)
|
||||
if (data) return { data, filePath: candidate }
|
||||
}
|
||||
console.warn('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
imageDecryptLog('[ImageDecrypt] all variants failed:', this.uniq(candidates))
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 检测 DAT 文件版本
|
||||
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
|
||||
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
|
||||
*/
|
||||
private getDatVersion(inputPath: string): number {
|
||||
const bytes = readFileSync(inputPath)
|
||||
@@ -360,9 +473,6 @@ export class ImageDecryptService {
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -490,7 +600,11 @@ export class ImageDecryptService {
|
||||
]
|
||||
}
|
||||
|
||||
private getPreferredDatVariantPath(inputPath: string, allowThumbnail: boolean): string {
|
||||
private getPreferredDatVariantPath(
|
||||
inputPath: string,
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string {
|
||||
const actualDir = dirname(inputPath)
|
||||
const base = this.normalizeDatBase(basename(inputPath))
|
||||
const variants = this.buildPreferredDatNames(base)
|
||||
@@ -499,13 +613,18 @@ export class ImageDecryptService {
|
||||
: variants.filter((name) => !this.isThumbnailName(name))
|
||||
const largest = this.getLargestExistingPath(
|
||||
ordered.map((variant) => join(actualDir, variant)),
|
||||
allowThumbnail
|
||||
allowThumbnail,
|
||||
preferThumbnail
|
||||
)
|
||||
if (largest) return largest
|
||||
return inputPath
|
||||
}
|
||||
|
||||
private getLargestExistingPath(paths: string[], allowThumbnail: boolean): string | null {
|
||||
private getLargestExistingPath(
|
||||
paths: string[],
|
||||
allowThumbnail: boolean,
|
||||
preferThumbnail = false
|
||||
): string | null {
|
||||
const toSized = (candidates: string[]): { candidate: string; size: number }[] =>
|
||||
candidates
|
||||
.filter((candidate) => existsSync(candidate))
|
||||
@@ -518,6 +637,10 @@ export class ImageDecryptService {
|
||||
})
|
||||
.sort((left, right) => right.size - left.size)
|
||||
|
||||
const thumbnail = toSized(
|
||||
paths.filter((candidate) => this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
if (preferThumbnail && thumbnail[0]) return thumbnail[0].candidate
|
||||
const nonThumb = toSized(
|
||||
paths.filter((candidate) => !this.isThumbnailName(basename(candidate)))
|
||||
)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import './preload-env'
|
||||
import './preload-env'
|
||||
import {
|
||||
app,
|
||||
shell,
|
||||
@@ -8,14 +8,16 @@ import {
|
||||
clipboard,
|
||||
Menu,
|
||||
Tray,
|
||||
dialog
|
||||
dialog,
|
||||
protocol
|
||||
} from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { existsSync, promises as fsPromises } from 'fs'
|
||||
import { extname } from 'path'
|
||||
import { electronApp, optimizer, is } from '@electron-toolkit/utils'
|
||||
import icon from '../../resources/icon.png?asset'
|
||||
import { WechatDb } from './wechat-db'
|
||||
import { bootstrapWcdbNative } from './wcdb4-client'
|
||||
import { bootstrapWcdbNativeAsync, Wcdb4Client } from './wcdb4-client'
|
||||
import { VoiceService } from './voice-service'
|
||||
import { StickerService } from './sticker-service'
|
||||
import { parseMessageContent } from './message-parser'
|
||||
@@ -59,16 +61,25 @@ import {
|
||||
import type { SaveImageKeyRequest, TestImageDecryptionRequest } from '../shared/image-decryption'
|
||||
import { loadSettings, saveSettings, getSettingsPath, AppSettings } from './services/settings-store'
|
||||
import {
|
||||
flushBootstrapCacheWritesSync,
|
||||
getBootstrapCache,
|
||||
getCachedMessagePage,
|
||||
getCachedMessages,
|
||||
mergeBootstrapAvatars,
|
||||
mergeCachedContactAvatars,
|
||||
saveBootstrapContacts,
|
||||
saveBootstrapSelf,
|
||||
saveCachedGroupSnapshot,
|
||||
saveCachedMessages
|
||||
} from './services/bootstrap-cache'
|
||||
import { installSafeConsole } from './safe-log'
|
||||
import { agentHubService } from './services/agent-hub-service'
|
||||
import { appLogger } from './app-logger'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import { configureRecallArchive, RecallArchiveMonitor } from './services/recall-archive-service'
|
||||
import { VideoAssetService } from './video-asset-service'
|
||||
import { cancelExport, revealExport, runExport } from './export-service'
|
||||
import type { ExportRequest } from '../shared/export'
|
||||
|
||||
// electron-vite can close the child's stdout/stderr after spawning Electron.
|
||||
// Plain console.error then throws EPIPE on a closed pipe and crashes the IPC
|
||||
@@ -78,19 +89,99 @@ installSafeConsole()
|
||||
let voiceService: VoiceService | null = null
|
||||
let imageDecryptService: ImageDecryptService | null = null
|
||||
let stickerService: StickerService | null = null
|
||||
let videoAssetService: VideoAssetService | null = null
|
||||
const databaseKeyStore = new DatabaseKeyStore()
|
||||
const imageKeyConfigService = new ImageKeyConfigService()
|
||||
const aiProviderService = new AIProviderService()
|
||||
const keyServiceMac = new KeyServiceMac()
|
||||
const keyServiceWin = new KeyServiceWin()
|
||||
let tray: Tray | null = null
|
||||
let recallArchiveMonitor: RecallArchiveMonitor | null = null
|
||||
let recallProtectionGeneration = 0
|
||||
let recallJournalTimer: NodeJS.Timeout | null = null
|
||||
let wcdbBootstrapPromise: Promise<unknown> | null = null
|
||||
|
||||
function configureRecallProtection(
|
||||
wcdb4Client: Wcdb4Client,
|
||||
accountRoot: string,
|
||||
enabled: boolean,
|
||||
installJournal = false
|
||||
): void {
|
||||
recallProtectionGeneration += 1
|
||||
const generation = recallProtectionGeneration
|
||||
recallArchiveMonitor?.stop()
|
||||
recallArchiveMonitor = null
|
||||
if (recallJournalTimer) {
|
||||
clearTimeout(recallJournalTimer)
|
||||
recallJournalTimer = null
|
||||
}
|
||||
configureRecallArchive(enabled ? accountRoot : '')
|
||||
if (!enabled) return
|
||||
|
||||
const monitor = new RecallArchiveMonitor(
|
||||
() =>
|
||||
wcdb4Client.getSessions().map((session) => ({
|
||||
md5: wcdb4Client.md5(session.username),
|
||||
m_nsUsrName: session.username,
|
||||
type: session.username.endsWith('@chatroom') ? 'group' : 'user',
|
||||
activityKey: [
|
||||
session.raw['last_timestamp'],
|
||||
session.raw['sort_timestamp'],
|
||||
session.raw['last_msg_locald_id'],
|
||||
session.raw['last_msg_type'],
|
||||
session.raw['summary']
|
||||
].join(':')
|
||||
})),
|
||||
(sessionMd5) =>
|
||||
chat.listMessages(sessionMd5, Math.floor(Date.now() / 1000) - 10 * 60, undefined, {
|
||||
limit: 500
|
||||
})
|
||||
)
|
||||
recallArchiveMonitor = monitor
|
||||
// Session indexing is not required to open the UI. Defer it so large databases
|
||||
// do not make db:init wait for every conversation to be enumerated.
|
||||
setImmediate(() => {
|
||||
if (generation === recallProtectionGeneration && recallArchiveMonitor === monitor) {
|
||||
monitor.seedAll()
|
||||
}
|
||||
})
|
||||
if (!installJournal) return
|
||||
// Creating recall triggers scans every message table and runs synchronously in
|
||||
// the main process. Only do this after the user explicitly enables protection;
|
||||
// existing installations remain active without repeating the scan at startup.
|
||||
recallJournalTimer = setTimeout(() => {
|
||||
recallJournalTimer = null
|
||||
if (generation !== recallProtectionGeneration || recallArchiveMonitor !== monitor) return
|
||||
const result = wcdb4Client.installRecallJournal(
|
||||
wcdb4Client.getSessions().map((session) => session.username)
|
||||
)
|
||||
console.log(
|
||||
`[WCDB4] recall journal ready installed=${result.installed} failed=${result.failed}`
|
||||
)
|
||||
}, 30_000)
|
||||
}
|
||||
|
||||
const packagedIconPath = join(process.resourcesPath, 'resources', 'icon.png')
|
||||
const appIconPath = existsSync(packagedIconPath) ? packagedIconPath : icon
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
scheme: 'wxe-media',
|
||||
privileges: { secure: true, standard: true, stream: true, supportFetchAPI: true }
|
||||
}
|
||||
])
|
||||
|
||||
// WCDB's Windows runtime checks the host application name during wcdb_init.
|
||||
// Mirroring WeFlow's name unblocks the -1006 init failure on Windows.
|
||||
app.setName(process.platform === 'win32' ? 'WeFlow' : 'WechatExplorer')
|
||||
app.setName(
|
||||
process.platform === 'win32'
|
||||
? 'WeFlow'
|
||||
: process.env['WXE_USER_DATA']
|
||||
? 'WechatExplorer Dev'
|
||||
: 'WechatExplorer'
|
||||
)
|
||||
const isolatedUserData = process.env['WXE_USER_DATA']
|
||||
if (isolatedUserData) app.setPath('userData', isolatedUserData)
|
||||
let dbInitInFlight: Promise<{ success: boolean; monitoring?: boolean; error?: string }> | null =
|
||||
null
|
||||
const BUILD_MARK = 'wechat4-local-http-api-2026-07-03'
|
||||
@@ -105,8 +196,65 @@ function getConfiguredImageKeys(): { xorKey: string; aesKey: string } {
|
||||
}
|
||||
}
|
||||
|
||||
async function createLocalMediaResponse(request: Request, filePath: string): Promise<Response> {
|
||||
const { size } = await fsPromises.stat(filePath)
|
||||
const mimeType = extname(filePath).toLowerCase() === '.mp4' ? 'video/mp4' : 'image/jpeg'
|
||||
const commonHeaders = {
|
||||
'Accept-Ranges': 'bytes',
|
||||
'Content-Type': mimeType,
|
||||
'Cache-Control': 'private, max-age=300'
|
||||
}
|
||||
const range = request.headers.get('range')
|
||||
|
||||
if (!range) {
|
||||
const body =
|
||||
request.method === 'HEAD' ? null : Uint8Array.from(await fsPromises.readFile(filePath))
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { ...commonHeaders, 'Content-Length': String(size) }
|
||||
})
|
||||
}
|
||||
|
||||
const match = /^bytes=(\d+)-(\d*)$/i.exec(range.trim())
|
||||
if (!match) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
const start = Number(match[1])
|
||||
const requestedEnd = match[2] ? Number(match[2]) : size - 1
|
||||
const end = Math.min(requestedEnd, size - 1)
|
||||
if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start > end || start >= size) {
|
||||
return new Response(null, {
|
||||
status: 416,
|
||||
headers: { ...commonHeaders, 'Content-Range': `bytes */${size}` }
|
||||
})
|
||||
}
|
||||
|
||||
const length = end - start + 1
|
||||
let body: Buffer | null = null
|
||||
if (request.method !== 'HEAD') {
|
||||
const handle = await fsPromises.open(filePath, 'r')
|
||||
try {
|
||||
body = Buffer.allocUnsafe(length)
|
||||
await handle.read(body, 0, length, start)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
}
|
||||
return new Response(body ? Uint8Array.from(body) : null, {
|
||||
status: 206,
|
||||
headers: {
|
||||
...commonHeaders,
|
||||
'Content-Length': String(length),
|
||||
'Content-Range': `bytes ${start}-${end}/${size}`
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function createWindow(): void {
|
||||
// 鍒涘缓娴忚鍣ㄧ獥鍙?
|
||||
// 创建浏览器窗口
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1400,
|
||||
height: 800,
|
||||
@@ -128,8 +276,8 @@ function createWindow(): void {
|
||||
return { action: 'deny' }
|
||||
})
|
||||
|
||||
// 鍩轰簬 electron-vite cli 鐨勬覆鏌撳櫒 HMR
|
||||
// 鍔犺浇寮€鍙戠幆澧冪殑杩滅▼ URL 鎴栫敓浜х幆澧冪殑鏈湴 html 鏂囦欢
|
||||
// 基于 electron-vite CLI 的渲染器热更新
|
||||
// 加载开发环境的远程 URL,或生产环境的本地 HTML 文件
|
||||
if (is.dev && process.env['ELECTRON_RENDERER_URL']) {
|
||||
mainWindow.loadURL(process.env['ELECTRON_RENDERER_URL'])
|
||||
} else {
|
||||
@@ -137,41 +285,77 @@ function createWindow(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// 褰?Electron 瀹屾垚鍒濆鍖栧苟鍑嗗濂藉垱寤烘祻瑙堝櫒绐楀彛鏃讹紝灏嗚皟鐢ㄦ鏂规硶
|
||||
// 鏌愪簺 API 鍙兘鍦ㄦ浜嬩欢鍙戠敓鍚庝娇鐢?
|
||||
// Electron 初始化完成并准备创建浏览器窗口后,将调用此方法
|
||||
// 某些 API 只能在此事件发生后使用
|
||||
app.whenReady().then(async () => {
|
||||
protocol.handle('wxe-media', async (request) => {
|
||||
const token = new URL(request.url).pathname.replace(/^\/+/, '')
|
||||
const filePath = videoAssetService?.pathForToken(token)
|
||||
if (!filePath) return new Response('Not found', { status: 404 })
|
||||
try {
|
||||
return await createLocalMediaResponse(request, filePath)
|
||||
} catch (error) {
|
||||
console.warn('[Video] local media request failed:', error)
|
||||
return new Response('Media unavailable', { status: 500 })
|
||||
}
|
||||
})
|
||||
console.log(`WechatExplorer main build: ${BUILD_MARK}`)
|
||||
appLogger.write({
|
||||
level: 'info',
|
||||
scope: 'lifecycle',
|
||||
message: 'WechatExplorer 启动',
|
||||
details: { build: BUILD_MARK, platform: process.platform, version: app.getVersion() }
|
||||
})
|
||||
process.on('uncaughtException', (error) => {
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'main-process',
|
||||
message: error.message,
|
||||
details: { stack: error.stack }
|
||||
})
|
||||
})
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
appLogger.write({
|
||||
level: 'error',
|
||||
scope: 'main-process',
|
||||
message: reason instanceof Error ? reason.message : 'Promise 未处理拒绝',
|
||||
details: {
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
reason: reason instanceof Error ? undefined : String(reason)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// WCDB's Windows runtime returns -1006 if wcdb_init is called more than once
|
||||
// per process. Bootstrap native once here so any later Wcdb4Client instance
|
||||
// reuses the already-initialized library and skips wcdb_init.
|
||||
try {
|
||||
bootstrapWcdbNative()
|
||||
console.log('[WCDB4] bootstrap complete at whenReady top')
|
||||
} catch (bootstrapError) {
|
||||
console.error('[WCDB4] bootstrap failed at whenReady top:', bootstrapError)
|
||||
}
|
||||
// Create the renderer before native WCDB bootstrap so startup progress is visible immediately.
|
||||
createWindow()
|
||||
wcdbBootstrapPromise = bootstrapWcdbNativeAsync().then(() => {
|
||||
console.log('[WCDB4] async bootstrap complete')
|
||||
})
|
||||
|
||||
// 涓虹獥鍙h缃簲鐢ㄧ▼搴忕敤鎴锋ā鍨?ID
|
||||
// 设置应用程序用户模型 ID
|
||||
electronApp.setAppUserModelId('com.wechatexplorer.app')
|
||||
|
||||
if (process.platform === 'darwin') app.dock?.setIcon(appIconPath)
|
||||
|
||||
// 鍦ㄥ紑鍙戠幆澧冧腑榛樿鎸?F12 鎵撳紑鎴栧叧闂?DevTools
|
||||
// 鍦ㄧ敓浜х幆澧冧腑蹇界暐 CommandOrControl + R
|
||||
// 鍙傝 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
// 开发环境中默认使用 F12 打开或关闭 DevTools
|
||||
// 生产环境中忽略 CommandOrControl + R
|
||||
// 参见 https://github.com/alex8088/electron-toolkit/tree/master/packages/utils
|
||||
app.on('browser-window-created', (_, window) => {
|
||||
optimizer.watchWindowShortcuts(window)
|
||||
})
|
||||
|
||||
// IPC test
|
||||
ipcMain.on('ping', () => console.log('pong'))
|
||||
ipcMain.handle('app-log:write', (_, entry: AppLogEntry) => appLogger.write(entry))
|
||||
ipcMain.handle('app-log:getPath', () => appLogger.logPath)
|
||||
ipcMain.handle('app-log:reveal', () => appLogger.reveal())
|
||||
|
||||
ipcMain.handle('db:init', async (_, key: string) => {
|
||||
if (dbInitInFlight) return dbInitInFlight
|
||||
|
||||
dbInitInFlight = (async () => {
|
||||
try {
|
||||
if (wcdbBootstrapPromise) await wcdbBootstrapPromise
|
||||
const trimmedKey = String(key || '').trim()
|
||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||
const settings = loadSettings()
|
||||
@@ -186,17 +370,33 @@ app.whenReady().then(async () => {
|
||||
const nextWechatDb = await WechatDb.create(key, settings.dbRoot)
|
||||
const resolvedRoot = nextWechatDb.getWcdb4Client().getAccountRoot()
|
||||
if (resolvedRoot && resolvedRoot !== settings.dbRoot) {
|
||||
saveSettings({ ...settings, dbRoot: resolvedRoot })
|
||||
// 同步更新 imageKeyRoot,避免自动获取图片密钥时扫描到错误目录
|
||||
saveSettings({
|
||||
...settings,
|
||||
dbRoot: resolvedRoot,
|
||||
imageKeyRoot: resolvedRoot
|
||||
})
|
||||
}
|
||||
chat.setChatDb(nextWechatDb)
|
||||
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||
configureRecallProtection(wcdb4Client, resolvedRoot, settings.recallProtectionEnabled)
|
||||
voiceService = new VoiceService(wcdb4Client)
|
||||
stickerService = new StickerService(wcdb4Client)
|
||||
videoAssetService = new VideoAssetService(wcdb4Client)
|
||||
const monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||
wcdb4Client.invalidateSessionCache()
|
||||
recallArchiveMonitor?.handleDatabaseChange(json)
|
||||
for (const window of BrowserWindow.getAllWindows()) {
|
||||
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||
}
|
||||
})
|
||||
setImmediate(() => {
|
||||
const recentSession = wcdb4Client.getSessions()[0]
|
||||
if (!recentSession?.username) return
|
||||
void wcdb4Client
|
||||
.getMessagesAsync(recentSession.username, undefined, undefined, { limit: 1 })
|
||||
.catch((error) => console.warn('[WCDB4] message cursor warmup failed:', error))
|
||||
})
|
||||
imageDecryptService = null
|
||||
return { success: true, monitoring }
|
||||
} catch (error) {
|
||||
@@ -267,7 +467,10 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('key:autoGetImageKey', async (event, options?: { save?: boolean }) => {
|
||||
const settings = loadSettings()
|
||||
const self = chat.getSelfAccountInfo()
|
||||
const accountRoot = settings.imageKeyRoot || self?.accountRoot || settings.dbRoot
|
||||
// 优先级:chat 真实识别到的根 → self.accountRoot → settings.imageKeyRoot → settings.dbRoot
|
||||
// 必须先看 chat.getCurrentAccountRoot(),否则 settings 缓存漂移会导致扫错目录。
|
||||
const accountRoot =
|
||||
chat.getCurrentAccountRoot() || self?.accountRoot || settings.imageKeyRoot || settings.dbRoot
|
||||
const wxid = self?.wxid
|
||||
const onStatus = (message: string): void => {
|
||||
if (!event.sender.isDestroyed()) event.sender.send('key:imageKeyStatus', { message })
|
||||
@@ -334,11 +537,26 @@ app.whenReady().then(async () => {
|
||||
return getBootstrapCache(chat.getCurrentAccountRoot())
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getStartupCache', () => {
|
||||
const settings = loadSettings()
|
||||
return settings.dbRoot ? getBootstrapCache(settings.dbRoot) : null
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getCachedMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
if (!chat.isReady()) return []
|
||||
return getCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime)
|
||||
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||
return accountRoot ? getCachedMessages(accountRoot, userMd5, startTime, endTime) : []
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getCachedMessagePage',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number) => {
|
||||
const accountRoot = chat.isReady() ? chat.getCurrentAccountRoot() : loadSettings().dbRoot
|
||||
return accountRoot
|
||||
? getCachedMessagePage(accountRoot, userMd5, startTime, endTime)
|
||||
: { hit: false, messages: [] }
|
||||
}
|
||||
)
|
||||
|
||||
@@ -361,8 +579,14 @@ app.whenReady().then(async () => {
|
||||
|
||||
ipcMain.handle(
|
||||
'db:getMessages',
|
||||
(_, userMd5: string, startTime?: number, endTime?: number, options?: { limit?: number }) => {
|
||||
const messages = chat.listMessages(userMd5, startTime, endTime, options)
|
||||
async (
|
||||
_,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
) => {
|
||||
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
|
||||
if (chat.isReady()) {
|
||||
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
|
||||
}
|
||||
@@ -370,7 +594,13 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => chat.getGroupSnapshot(userMd5))
|
||||
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
|
||||
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
|
||||
if (snapshot && chat.isReady()) {
|
||||
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
|
||||
}
|
||||
return snapshot
|
||||
})
|
||||
|
||||
ipcMain.handle('db:search', (_, keyword: string) => chat.searchMessages(keyword))
|
||||
|
||||
@@ -413,6 +643,23 @@ app.whenReady().then(async () => {
|
||||
return exportGroupReport(request)
|
||||
})
|
||||
|
||||
ipcMain.handle('export:start', async (event, request: ExportRequest) => {
|
||||
const window = BrowserWindow.fromWebContents(event.sender)
|
||||
if (!window) return { success: false, error: '窗口不可用' }
|
||||
return runExport(request, window)
|
||||
})
|
||||
ipcMain.handle('export:cancel', (_, jobId: string) => {
|
||||
cancelExport(jobId)
|
||||
return { success: true }
|
||||
})
|
||||
ipcMain.handle('export:reveal', async (_, path: string) => {
|
||||
try {
|
||||
await revealExport(path)
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
return { success: false, error: String(error) }
|
||||
}
|
||||
})
|
||||
ipcMain.handle('report:listGenerated', async () => {
|
||||
return listGeneratedReports()
|
||||
})
|
||||
@@ -455,7 +702,7 @@ app.whenReady().then(async () => {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
_sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => {
|
||||
void _sessionId
|
||||
if (!imageDecryptService) {
|
||||
@@ -472,12 +719,28 @@ app.whenReady().then(async () => {
|
||||
|
||||
const imageDatName = typeof imageDatNameOrThumb === 'string' ? imageDatNameOrThumb : undefined
|
||||
const force = options?.force === true
|
||||
const preferThumbnail = options?.preferThumbnail === true
|
||||
const imageCacheKey = [
|
||||
imageMd5 || '',
|
||||
imageDatName || '',
|
||||
force ? 'original' : preferThumbnail ? 'thumbnail' : 'auto'
|
||||
].join('|')
|
||||
const cachedImage = imageDecryptService.getCachedDecodedImage(imageCacheKey)
|
||||
if (cachedImage) {
|
||||
return {
|
||||
success: true,
|
||||
data: cachedImage.data,
|
||||
isThumb: cachedImage.isThumbnail,
|
||||
filePath: cachedImage.filePath
|
||||
}
|
||||
}
|
||||
let filePath = force
|
||||
? imageDecryptService.findImageFile(imageMd5, imageDatName, { allowThumbnail: false })
|
||||
: null
|
||||
if (!filePath) {
|
||||
filePath = imageDecryptService.findImageFile(imageMd5, imageDatName, {
|
||||
allowThumbnail: true
|
||||
allowThumbnail: true,
|
||||
preferThumbnail
|
||||
})
|
||||
}
|
||||
if (!filePath) {
|
||||
@@ -489,12 +752,18 @@ app.whenReady().then(async () => {
|
||||
return { success: false, error: '图片解密失败' }
|
||||
}
|
||||
|
||||
return {
|
||||
const result = {
|
||||
success: true,
|
||||
data: decrypted.data,
|
||||
isThumb: imageDecryptService.isThumbnailFile(decrypted.filePath),
|
||||
filePath: decrypted.filePath
|
||||
}
|
||||
imageDecryptService.cacheDecodedImage(imageCacheKey, {
|
||||
data: result.data,
|
||||
filePath: result.filePath,
|
||||
isThumbnail: result.isThumb
|
||||
})
|
||||
return result
|
||||
}
|
||||
)
|
||||
|
||||
@@ -556,9 +825,9 @@ app.whenReady().then(async () => {
|
||||
if (!provider || !model) {
|
||||
return { success: false, error: '当前 AI 模型不存在' }
|
||||
}
|
||||
if (!model.capabilities.vision) {
|
||||
return { success: false, error: '当前模型不支持图片理解' }
|
||||
}
|
||||
// Capability metadata is stored per machine. A model verified on macOS
|
||||
// may still be unmarked on Windows, so do not reject before making the
|
||||
// real multimodal request. The provider response remains authoritative.
|
||||
// request 来自 renderer,imageHash 是 md5(优先)或 sha256(...),dataUrl 在内部算出
|
||||
// 这里直接调 service,dataUrl 由 renderer 通过 window.api.getImage 拿到再传进来
|
||||
return imageInsightService.analyze(request)
|
||||
@@ -593,6 +862,15 @@ app.whenReady().then(async () => {
|
||||
return stickerService.resolveSticker(cdnUrl, md5)
|
||||
})
|
||||
|
||||
ipcMain.handle('db:getVideo', async (_, hashes: string[]) => {
|
||||
if (!videoAssetService) {
|
||||
const client = chat.getChatDb()?.getWcdb4Client()
|
||||
if (!client) return { success: false, error: '数据库尚未连接' }
|
||||
videoAssetService = new VideoAssetService(client)
|
||||
}
|
||||
return videoAssetService.resolve(Array.isArray(hashes) ? hashes : [])
|
||||
})
|
||||
|
||||
// -------- Settings & API service --------
|
||||
|
||||
ipcMain.handle('settings:get', () => ({
|
||||
@@ -607,13 +885,22 @@ app.whenReady().then(async () => {
|
||||
const xorKey = patch.imageXorKey ?? current.xorKey ?? '0x40'
|
||||
const aesKey = patch.imageAesKey ?? current.aesKey ?? ''
|
||||
const includesImageKey = 'imageXorKey' in patch || 'imageAesKey' in patch
|
||||
const nextSettings = saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
if (includesImageKey) {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
if (aesKey) imageKeyConfigService.save({ resourceRoot, xorKey, aesKey })
|
||||
else imageKeyConfigService.clear()
|
||||
imageDecryptService = null
|
||||
} else {
|
||||
saveSettings({ ...before, ...patch, imageXorKey: '', imageAesKey: '' })
|
||||
}
|
||||
if ('recallProtectionEnabled' in patch && chat.isReady()) {
|
||||
const currentDb = chat.getChatDb()
|
||||
if (currentDb) {
|
||||
configureRecallProtection(
|
||||
currentDb.getWcdb4Client(),
|
||||
chat.getCurrentAccountRoot(),
|
||||
nextSettings.recallProtectionEnabled,
|
||||
nextSettings.recallProtectionEnabled && !before.recallProtectionEnabled
|
||||
)
|
||||
}
|
||||
}
|
||||
return {
|
||||
settings: imageKeyConfigService.getLegacySettingsView(),
|
||||
@@ -635,6 +922,11 @@ app.whenReady().then(async () => {
|
||||
ipcMain.handle('db:reopenWithRoot', (_, accountRoot: string) => {
|
||||
const ok = chat.reopenWithRoot(accountRoot)
|
||||
if (!ok) return { success: false, error: '数据库未初始化或重新打开失败' }
|
||||
// 同步 imageKeyRoot,避免自动获取扫描到旧目录
|
||||
const settings = loadSettings()
|
||||
if (accountRoot && accountRoot !== settings.imageKeyRoot) {
|
||||
saveSettings({ ...settings, imageKeyRoot: accountRoot })
|
||||
}
|
||||
const info = chat.getSelfAccountInfo()
|
||||
return { success: true, info }
|
||||
})
|
||||
@@ -655,9 +947,10 @@ app.whenReady().then(async () => {
|
||||
return error ? { success: false, error } : { success: true }
|
||||
})
|
||||
|
||||
ipcMain.handle('db:disconnect', () => {
|
||||
if (!chat.isReady()) return { success: false, error: '数据库当前未连接' }
|
||||
chat.setChatDb(null)
|
||||
ipcMain.handle('db:disconnect', (_, options?: { closeNative?: boolean }) => {
|
||||
// 断开操作保持幂等:渲染进程可能已标记断开,或主进程连接已先行失效。
|
||||
// 即使当前未就绪,也应让用户正常返回登录页。
|
||||
if (options?.closeNative !== false && chat.isReady()) chat.setChatDb(null)
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -719,9 +1012,7 @@ app.whenReady().then(async () => {
|
||||
return result.canceled ? { canceled: true } : { canceled: false, path: result.filePaths[0] }
|
||||
})
|
||||
|
||||
createWindow()
|
||||
|
||||
// 鍚姩鏈湴 HTTP API(鏍规嵁 settings.apiEnabled 鎺у埗)
|
||||
// 启动本地 HTTP API(由 settings.apiEnabled 控制)
|
||||
const settings = loadSettings()
|
||||
if (settings.apiEnabled) {
|
||||
await apiServer.start(settings.apiHost, settings.apiPort)
|
||||
@@ -735,15 +1026,15 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
|
||||
app.on('activate', function () {
|
||||
// 鍦?macOS 涓婏紝褰撶偣鍑?dock 鍥炬爣涓旀病鏈夊叾浠栫獥鍙f墦寮€鏃讹紝
|
||||
// 閫氬父浼氬湪搴旂敤绋嬪簭涓噸鏂板垱寤轰竴涓獥鍙c€?
|
||||
// 在 macOS 上点击 Dock 图标且没有其他窗口打开时,
|
||||
// 通常会在应用程序中重新创建一个窗口。
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow()
|
||||
})
|
||||
})
|
||||
|
||||
// 褰撴墍鏈夌獥鍙e叧闂椂閫€鍑猴紝闄や簡 macOS銆傚湪閭i噷锛?
|
||||
// 搴旂敤绋嬪簭鍙婂叾鑿滃崟鏍忛€氬父浼氫繚鎸佹椿鍔ㄧ姸鎬侊紝鐩村埌鐢ㄦ埛
|
||||
// 鏄惧紡浣跨敤 Cmd + Q 閫€鍑恒€?
|
||||
// 除 macOS 外,所有窗口关闭时退出应用。在 macOS 上,
|
||||
// 应用程序及其菜单栏通常会保持活动状态,直到用户
|
||||
// 明确使用 Cmd + Q 退出。
|
||||
app.on('window-all-closed', () => {
|
||||
if (TRAY_MODE) return
|
||||
if (process.platform !== 'darwin') {
|
||||
@@ -753,6 +1044,7 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', async () => {
|
||||
agentHubService.stop()
|
||||
flushBootstrapCacheWritesSync()
|
||||
chat.setChatDb(null)
|
||||
await apiServer.stop().catch(() => undefined)
|
||||
if (tray) {
|
||||
|
||||
@@ -889,7 +889,8 @@ export class KeyService {
|
||||
const dirName = normalized.split(/[\\/]/).pop() ?? ''
|
||||
if (dirName.startsWith('wxid_')) pushUnique(dirName)
|
||||
|
||||
const marker = normalized.match(/[\\/]xwechat_files/i) || normalized.match(/[\\/]WeChat Files/i)
|
||||
// 仅支持 WeChat 4.0:路径识别只匹配 xwechat_files
|
||||
const marker = normalized.match(/[\\/]xwechat_files/i)
|
||||
if (marker) {
|
||||
const root = normalized.slice(0, marker.index! + marker[0].length)
|
||||
try {
|
||||
@@ -934,15 +935,49 @@ export class KeyService {
|
||||
onProgress?.('正在查找模板文件...')
|
||||
let result = await this._findTemplateData(userDir, 32)
|
||||
let { ciphertext, xorKey } = result
|
||||
|
||||
const firstDiag = (this as { _imageTemplateDiag?: {
|
||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||
} })._imageTemplateDiag
|
||||
|
||||
// 如果找不到密钥,尝试扫描更多文件
|
||||
if (ciphertext && xorKey === null) {
|
||||
onProgress?.('未找到有效密钥,尝试扫描更多文件...')
|
||||
result = await this._findTemplateData(userDir, 100)
|
||||
xorKey = result.xorKey
|
||||
}
|
||||
|
||||
if (!ciphertext) return { success: false, error: '未找到 V2 模板文件,请先在微信中查看几张图片' }
|
||||
|
||||
if (!ciphertext) {
|
||||
// 用诊断信息给具体提示
|
||||
const diag = (this as { _imageTemplateDiag?: {
|
||||
userDir: string; totalTFiles: number; v2Count: number; nonV2Count: number
|
||||
} })._imageTemplateDiag || firstDiag
|
||||
if (!diag || diag.totalTFiles === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
'在账号目录下未找到任何 _t.dat 图片文件。\n' +
|
||||
`扫描路径:${diag?.userDir || userDir || '(空)'}\n` +
|
||||
'原因:微信没在本地生成缩略图。\n' +
|
||||
'请让用户在微信里打开任意聊天的图片大图(等"原图"按钮可点击),然后再试。'
|
||||
}
|
||||
}
|
||||
if (diag.v2Count === 0 && diag.nonV2Count > 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`找到 ${diag.totalTFiles} 个 _t.dat,但都不是 V2 头(可能图片尚未解密到本地,或微信版本不同)。\n` +
|
||||
`扫描路径:${diag.userDir}\n` +
|
||||
'请让用户在微信里打开 2-3 张不同的图片大图,等"原图"按钮可点击后再试。'
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
`找到 ${diag.totalTFiles} 个 _t.dat,其中 ${diag.v2Count} 个是 V2 头,但没有长度 ≥ 0x1F 的有效模板。\n` +
|
||||
`扫描路径:${diag.userDir}\n` +
|
||||
'请在微信中查看更多图片后再试。'
|
||||
}
|
||||
}
|
||||
if (xorKey === null) return { success: false, error: '未能从模板文件中计算出有效的 XOR 密钥,请确保在微信中查看了多张不同的图片' }
|
||||
|
||||
onProgress?.(`XOR 密钥: 0x${xorKey.toString(16).padStart(2, '0')},正在查找微信进程...`)
|
||||
@@ -1005,6 +1040,8 @@ export class KeyService {
|
||||
|
||||
let ciphertext: Buffer | null = null
|
||||
const tailCounts: Record<string, number> = {}
|
||||
let v2Count = 0
|
||||
let nonV2Count = 0
|
||||
|
||||
for (const f of files.slice(0, 32)) {
|
||||
try {
|
||||
@@ -1013,8 +1050,11 @@ export class KeyService {
|
||||
|
||||
// 统计末尾两字节用于 XOR 密钥
|
||||
if (data.subarray(0, 6).equals(V2_MAGIC) && data.length >= 2) {
|
||||
v2Count++
|
||||
const key = `${data[data.length - 2]}_${data[data.length - 1]}`
|
||||
tailCounts[key] = (tailCounts[key] ?? 0) + 1
|
||||
} else {
|
||||
nonV2Count++
|
||||
}
|
||||
|
||||
// 提取密文(取第一个有效的)
|
||||
@@ -1031,6 +1071,15 @@ export class KeyService {
|
||||
if (count > maxCount) { maxCount = count; const [x, y] = key.split('_').map(Number); const k = x ^ 0xFF; if (k === (y ^ 0xD9)) xorKey = k }
|
||||
}
|
||||
|
||||
// 诊断信息:远程排查时让 UI 直接告诉用户搜到了什么
|
||||
const diag = {
|
||||
userDir,
|
||||
totalTFiles: files.length,
|
||||
v2Count,
|
||||
nonV2Count
|
||||
}
|
||||
;(this as { _imageTemplateDiag?: unknown })._imageTemplateDiag = diag
|
||||
|
||||
return { ciphertext, xorKey }
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,22 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
description?: string
|
||||
appName?: string
|
||||
iconUrl?: string
|
||||
thumbMd5?: string
|
||||
thumbDatName?: string
|
||||
thumbDataUrl?: string
|
||||
}
|
||||
type RedPacketContent = {
|
||||
type: 'redPacket'
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
@@ -24,6 +40,15 @@ type ImageContent = {
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type VideoContent = {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -40,8 +65,23 @@ type QuoteContent = {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
quotedImageMd5?: string
|
||||
quotedImageDatName?: string
|
||||
}
|
||||
type SystemContent = {
|
||||
type: 'system'
|
||||
content: string
|
||||
raw?: string
|
||||
pat?: boolean
|
||||
recall?: {
|
||||
targetId?: string
|
||||
targetIds?: string[]
|
||||
replacement: string
|
||||
actor?: string
|
||||
sessionId?: string
|
||||
recallTime?: number
|
||||
}
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
@@ -50,8 +90,11 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
@@ -69,6 +112,8 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
return parseImageMessage(normalized)
|
||||
case 42:
|
||||
return parseCardMessage(normalized)
|
||||
case 43:
|
||||
return parseVideoMessage(normalized)
|
||||
case 47:
|
||||
return parseStickerMessage(normalized)
|
||||
case 48:
|
||||
@@ -85,9 +130,31 @@ export function parseMessageContent(content: string, messageType: number): Parse
|
||||
}
|
||||
}
|
||||
|
||||
function parseVideoMessage(content: string): ParsedContent {
|
||||
const decoded = decodeXmlEntities(stripChatroomPrefix(content))
|
||||
const md5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'md5'))
|
||||
const newMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'newmd5'))
|
||||
const rawMd5 = normalizeMd5(extractXmlAttribute(decoded, 'videomsg', 'rawmd5'))
|
||||
if (!md5 && !newMd5 && !rawMd5) return { type: 'unknown', raw: content }
|
||||
|
||||
const duration = Number(extractXmlAttribute(decoded, 'videomsg', 'playlength')) || undefined
|
||||
const width = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbwidth')) || undefined
|
||||
const height = Number(extractXmlAttribute(decoded, 'videomsg', 'cdnthumbheight')) || undefined
|
||||
return { type: 'video', md5, newMd5, rawMd5, duration, width, height }
|
||||
}
|
||||
|
||||
function parseSystemMessage(content: string): ParsedContent {
|
||||
const stripped = stripChatroomPrefix(content)
|
||||
const decoded = decodeXmlEntities(stripped)
|
||||
const recall = extractRecallMessage(decoded)
|
||||
if (recall) {
|
||||
return {
|
||||
type: 'system',
|
||||
content: recall.replacement,
|
||||
raw: content,
|
||||
recall
|
||||
}
|
||||
}
|
||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||
if (delChatroomMemberText) {
|
||||
return {
|
||||
@@ -113,6 +180,50 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
}
|
||||
}
|
||||
|
||||
function extractRecallMessage(xml: string):
|
||||
| {
|
||||
targetId?: string
|
||||
targetIds?: string[]
|
||||
replacement: string
|
||||
actor?: string
|
||||
sessionId?: string
|
||||
recallTime?: number
|
||||
}
|
||||
| undefined {
|
||||
if (!/<revokemsg\b/i.test(xml)) return undefined
|
||||
|
||||
const replacement = normalizeSystemText(
|
||||
extractXmlValue(xml, 'replacemsg') ||
|
||||
extractXmlNodeText(xml, 'replacemsg') ||
|
||||
extractXmlValue(xml, 'content') ||
|
||||
extractXmlNodeText(xml, 'content')
|
||||
)
|
||||
if (!replacement) return undefined
|
||||
|
||||
const actorMatch =
|
||||
/^["“](.+?)["”]\s*撤回了一条消息/.exec(replacement) ||
|
||||
/^(.+?)\s*撤回了一条消息/.exec(replacement)
|
||||
|
||||
const targetIds = Array.from(
|
||||
new Set(
|
||||
[
|
||||
extractXmlValue(xml, 'newmsgid'),
|
||||
extractXmlValue(xml, 'msgid'),
|
||||
extractXmlValue(xml, 'clientmsgid')
|
||||
].filter(Boolean)
|
||||
)
|
||||
)
|
||||
|
||||
return {
|
||||
targetId: targetIds[0] || undefined,
|
||||
targetIds,
|
||||
replacement,
|
||||
actor: actorMatch?.[1]?.trim() || undefined,
|
||||
sessionId: extractXmlValue(xml, 'session') || undefined,
|
||||
recallTime: Number(extractXmlValue(xml, 'revoketime')) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
function parseImageMessage(content: string): ParsedContent {
|
||||
// 尝试 XML 格式: <img md5="..." aeskey="..."/>
|
||||
let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || ''
|
||||
@@ -301,6 +412,10 @@ function parseLocationMessage(content: string): ParsedContent {
|
||||
|
||||
function parseShareMessage(content: string): ParsedContent {
|
||||
const appMsgType = extractAppMsgType(content)
|
||||
if (appMsgType === '47' || /<(?:emoji|sticker|emoticon)\b/i.test(content)) {
|
||||
const sticker = parseStickerMessage(content)
|
||||
if (sticker.type === 'sticker') return sticker
|
||||
}
|
||||
if (appMsgType === '57' || content.includes('<refermsg>')) {
|
||||
const quote = parseQuoteMessage(content)
|
||||
const title = extractXmlValue(content, 'title') || undefined
|
||||
@@ -310,7 +425,34 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
content: title,
|
||||
quotedContent: quote.content || '[引用消息]',
|
||||
quotedSender: quote.sender,
|
||||
quotedType: quote.type
|
||||
quotedType: quote.type,
|
||||
quotedImageMd5: quote.imageMd5,
|
||||
quotedImageDatName: quote.imageDatName
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '33' || appMsgType === '36') {
|
||||
return {
|
||||
type: 'miniProgram',
|
||||
title: extractXmlValue(content, 'title') || '小程序',
|
||||
description: extractXmlValue(content, 'des') || undefined,
|
||||
appName:
|
||||
extractXmlValue(content, 'sourcedisplayname') ||
|
||||
extractXmlValue(content, 'appname') ||
|
||||
'小程序',
|
||||
iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined,
|
||||
thumbMd5: normalizeMd5(
|
||||
extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (appMsgType === '2001') {
|
||||
return {
|
||||
type: 'redPacket',
|
||||
title: extractXmlValue(content, 'title') || '微信红包',
|
||||
description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利',
|
||||
url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined
|
||||
}
|
||||
}
|
||||
|
||||
@@ -327,7 +469,13 @@ function parseShareMessage(content: string): ParsedContent {
|
||||
return { type: 'share', title, des, url, appname, typeVal }
|
||||
}
|
||||
|
||||
function parseQuoteMessage(content: string): { content?: string; sender?: string; type?: string } {
|
||||
function parseQuoteMessage(content: string): {
|
||||
content?: string
|
||||
sender?: string
|
||||
type?: string
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
} {
|
||||
const referMsgStart = content.indexOf('<refermsg>')
|
||||
const referMsgEnd = content.indexOf('</refermsg>')
|
||||
if (referMsgStart === -1 || referMsgEnd === -1) return {}
|
||||
@@ -343,8 +491,16 @@ function parseQuoteMessage(content: string): { content?: string; sender?: string
|
||||
switch (referType) {
|
||||
case '1':
|
||||
return { sender, content: sanitizeQuotedContent(referContent), type: referType }
|
||||
case '3':
|
||||
return { sender, content: '[图片]', type: referType }
|
||||
case '3': {
|
||||
const image = parseImageMessage(referContent)
|
||||
return {
|
||||
sender,
|
||||
content: '[图片]',
|
||||
type: referType,
|
||||
imageMd5: image.type === 'image' ? image.md5 : undefined,
|
||||
imageDatName: image.type === 'image' ? image.datName : undefined
|
||||
}
|
||||
}
|
||||
case '34':
|
||||
return { sender, content: '[语音]', type: referType }
|
||||
case '43':
|
||||
@@ -372,6 +528,10 @@ function extractAppMsgType(content: string): string {
|
||||
const inner = appmsgMatch[1]
|
||||
.replace(/<refermsg[\s\S]*?<\/refermsg>/gi, '')
|
||||
.replace(/<patMsg[\s\S]*?<\/patMsg>/gi, '')
|
||||
.replace(/<weappinfo[\s\S]*?<\/weappinfo>/gi, '')
|
||||
.replace(/<appattach[\s\S]*?<\/appattach>/gi, '')
|
||||
.replace(/<wcpayinfo[\s\S]*?<\/wcpayinfo>/gi, '')
|
||||
.replace(/<findernamecard[\s\S]*?<\/findernamecard>/gi, '')
|
||||
const typeMatch = /<type>([\s\S]*?)<\/type>/i.exec(inner)
|
||||
return typeMatch?.[1]?.trim() || ''
|
||||
}
|
||||
@@ -433,7 +593,10 @@ function extractXmlValue(xml: string, tagName: string): string {
|
||||
}
|
||||
|
||||
function extractXmlAttribute(xml: string, tagName: string, attrName: string): string {
|
||||
const pattern = new RegExp(`<${tagName}[^>]*${attrName}=["']([^"']*)["']`, 'i')
|
||||
const pattern = new RegExp(
|
||||
`<${tagName}\\b[^>]*?(?:\\s|^)${attrName}\\s*=\\s*["']([^"']*)["']`,
|
||||
'i'
|
||||
)
|
||||
const match = xml.match(pattern)
|
||||
return match ? match[1].trim() : ''
|
||||
}
|
||||
@@ -550,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record<string, unknown>): string |
|
||||
return hexMatch?.[1]?.toLowerCase()
|
||||
}
|
||||
|
||||
export function parseImageBufferDataUrlFromRow(
|
||||
row: Record<string, unknown>
|
||||
): string | undefined {
|
||||
const raw = pickRowString(row, [
|
||||
'ImgBuf',
|
||||
'imgBuf',
|
||||
'img_buf',
|
||||
'imageBuffer',
|
||||
'image_buffer',
|
||||
'thumbBuffer',
|
||||
'thumb_buffer',
|
||||
'WCDB_CT_img_buf',
|
||||
'WCDB_CT_ImgBuf'
|
||||
])
|
||||
const buffer = decodeInlineImageBuffer(raw)
|
||||
if (!buffer || buffer.length === 0) return undefined
|
||||
const mime = detectImageMime(buffer)
|
||||
return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined
|
||||
}
|
||||
|
||||
function decodeInlineImageBuffer(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 === 'object') {
|
||||
const record = raw as { buffer?: unknown; data?: unknown }
|
||||
return decodeInlineImageBuffer(record.buffer ?? record.data)
|
||||
}
|
||||
if (typeof raw !== 'string') return null
|
||||
const value = raw.trim()
|
||||
const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value)
|
||||
const encoded = dataUrl?.[1] || value
|
||||
if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null
|
||||
try {
|
||||
return Buffer.from(encoded, 'base64')
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function detectImageMime(buffer: Buffer): string | undefined {
|
||||
if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) {
|
||||
return 'image/jpeg'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 8 &&
|
||||
buffer[0] === 0x89 &&
|
||||
buffer.subarray(1, 4).toString('ascii') === 'PNG'
|
||||
) {
|
||||
return 'image/png'
|
||||
}
|
||||
if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) {
|
||||
return 'image/gif'
|
||||
}
|
||||
if (
|
||||
buffer.length >= 12 &&
|
||||
buffer.subarray(0, 4).toString('ascii') === 'RIFF' &&
|
||||
buffer.subarray(8, 12).toString('ascii') === 'WEBP'
|
||||
) {
|
||||
return 'image/webp'
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function pickRowString(row: Record<string, unknown>, keys: string[]): unknown {
|
||||
for (const key of keys) {
|
||||
if (Object.prototype.hasOwnProperty.call(row, key)) return row[key]
|
||||
|
||||
@@ -5,6 +5,7 @@ import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
@@ -68,14 +69,39 @@ export async function generateAgentGroupReport(
|
||||
{ role: 'user', content: input.prompt }
|
||||
])
|
||||
if (!ai.success || !ai.data) return { success: false, error: ai.error || 'AI 总结失败' }
|
||||
const report = parseGroupDailyReport(
|
||||
ai.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
const parseReport = (raw: string): ReturnType<typeof parseGroupDailyReport> =>
|
||||
parseGroupDailyReport(
|
||||
raw,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard,
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseReport(ai.data)
|
||||
} catch (parseError) {
|
||||
const repaired = await aiProvider.chat([
|
||||
{ role: 'system', content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: ai.data }
|
||||
])
|
||||
if (!repaired.success || !repaired.data) {
|
||||
const cause = parseError instanceof Error ? parseError.message : String(parseError)
|
||||
return {
|
||||
success: false,
|
||||
error: `${repaired.error || 'AI 修复日报 JSON 失败'}(原始错误:${cause})`
|
||||
}
|
||||
}
|
||||
try {
|
||||
report = parseReport(repaired.data)
|
||||
} catch (repairError) {
|
||||
return {
|
||||
success: false,
|
||||
error: repairError instanceof Error ? repairError.message : String(repairError)
|
||||
}
|
||||
}
|
||||
}
|
||||
const exported = await exportGroupReport({ report, metadata: input.metadata })
|
||||
if (!exported.success || !exported.pngPath) {
|
||||
return { success: false, error: exported.error || '总结图片生成失败' }
|
||||
|
||||
@@ -14,7 +14,15 @@ import type {
|
||||
} from '../../shared/agent-hub'
|
||||
import type { AppSettings } from './settings-store'
|
||||
import { generateAgentGroupReport } from './agent-group-report-service'
|
||||
import { isReady, listMessages, listRecentChat, resolveMd5 } from './chat-service'
|
||||
import { AIProviderService } from './ai-provider-service'
|
||||
import {
|
||||
getGroupSnapshot,
|
||||
isReady,
|
||||
listContacts,
|
||||
listMessages,
|
||||
listRecentChat,
|
||||
resolveMd5
|
||||
} from './chat-service'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const HEALTH_INTERVAL_MS = 5_000
|
||||
@@ -39,8 +47,24 @@ interface GroupReportIntent {
|
||||
interface ContactChatIntent {
|
||||
contact: string
|
||||
limit: number
|
||||
summarize: boolean
|
||||
}
|
||||
|
||||
interface GroupMemberChatIntent {
|
||||
group: string
|
||||
member: string
|
||||
range: 'today' | 'yesterday' | '7days'
|
||||
days: number
|
||||
goal: string
|
||||
}
|
||||
|
||||
interface NaturalLanguageResult {
|
||||
command?: string
|
||||
reply?: string
|
||||
}
|
||||
|
||||
const agentAIProvider = new AIProviderService()
|
||||
|
||||
function resolveBundledBinary(
|
||||
resourceSegments: string[],
|
||||
executable: string,
|
||||
@@ -314,6 +338,7 @@ class AgentHubService {
|
||||
this.addLog('agent-hub', 'info', `收到微信消息 message_id=${messageId || 'unknown'}`)
|
||||
|
||||
const reportIntent = this.matchGroupReportIntent(text)
|
||||
|
||||
if (reportIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
this.addLog(
|
||||
@@ -328,20 +353,39 @@ class AgentHubService {
|
||||
return this.sendHubJson(response, 202, { status: 'generating' })
|
||||
}
|
||||
|
||||
const groupMemberIntent = this.matchGroupMemberChatIntent(text)
|
||||
if (groupMemberIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
void this.summarizeGroupMemberChat(inbound, groupMemberIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating', mode: 'group-member-summary' })
|
||||
}
|
||||
|
||||
const contactChatIntent = this.matchContactChatIntent(text)
|
||||
if (contactChatIntent) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
if (contactChatIntent.summarize) {
|
||||
void this.summarizeContactChat(inbound, contactChatIntent)
|
||||
return this.sendHubJson(response, 202, { status: 'generating', mode: 'contact-summary' })
|
||||
}
|
||||
await this.replyContactChat(inbound, contactChatIntent)
|
||||
return this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
const limit = this.matchRecentChatIntent(text)
|
||||
if (limit === null) {
|
||||
const recentChatLimit = this.matchRecentChatIntent(text)
|
||||
if (recentChatLimit === null && text.trim()) {
|
||||
if (messageId) this.processedMessages.set(messageId, Date.now())
|
||||
void this.handleNaturalLanguage(inbound, text)
|
||||
return this.sendHubJson(response, 202, {
|
||||
status: 'processing',
|
||||
mode: 'natural-language'
|
||||
})
|
||||
}
|
||||
if (recentChatLimit === null) {
|
||||
this.addLog('agent-hub', 'info', '消息已忽略:没有匹配到支持的意图')
|
||||
return this.sendHubJson(response, 202, { status: 'ignored', reason: 'no matching intent' })
|
||||
}
|
||||
if (!isReady()) return this.sendHubJson(response, 502, { error: 'upstream query failed' })
|
||||
const items = listRecentChat(limit)
|
||||
const items = listRecentChat(recentChatLimit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
@@ -360,6 +404,140 @@ class AgentHubService {
|
||||
this.sendHubJson(response, 200, { status: 'ok' })
|
||||
}
|
||||
|
||||
private async handleNaturalLanguage(inbound: InboundMessage, text: string): Promise<void> {
|
||||
try {
|
||||
const result = await this.resolveNaturalLanguage(text)
|
||||
if (result.reply) {
|
||||
await this.sendConnector(inbound, this.formatAIReply(result.reply))
|
||||
this.addLog('agent-hub', 'info', '自然语言回复已发送')
|
||||
return
|
||||
}
|
||||
if (!result.command) {
|
||||
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
|
||||
return
|
||||
}
|
||||
|
||||
this.addLog('agent-hub', 'info', `自然语言已理解为:${result.command}`)
|
||||
const reportIntent = this.matchGroupReportIntent(result.command)
|
||||
if (reportIntent) {
|
||||
await this.sendConnector(inbound, '收到!正在生成群聊总结,请等待…').catch(() => undefined)
|
||||
await this.generateAndSendReport(inbound, reportIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const groupMemberIntent = this.matchGroupMemberChatIntent(result.command)
|
||||
if (groupMemberIntent) {
|
||||
await this.summarizeGroupMemberChat(inbound, groupMemberIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const contactIntent = this.matchContactChatIntent(result.command)
|
||||
if (contactIntent) {
|
||||
if (contactIntent.summarize) await this.summarizeContactChat(inbound, contactIntent)
|
||||
else await this.replyContactChat(inbound, contactIntent)
|
||||
return
|
||||
}
|
||||
|
||||
const recentLimit = this.matchRecentChatIntent(result.command)
|
||||
if (recentLimit !== null) {
|
||||
await this.replyRecentChats(inbound, recentLimit)
|
||||
return
|
||||
}
|
||||
await this.sendConnector(inbound, '暂时没有理解你的意思,可以换一种说法再试。')
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `自然语言处理失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `处理失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async replyRecentChats(inbound: InboundMessage, limit: number): Promise<void> {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const items = listRecentChat(limit)
|
||||
const lines = items.map((item, index) => {
|
||||
const name = item.m_nsNickName.trim() || item.m_nsUsrName.trim()
|
||||
return `${index + 1}. ${name}(${item.type === 'group' ? '群聊' : '联系人'})`
|
||||
})
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
lines.length ? `最近 ${items.length} 个会话:\n${lines.join('\n')}` : '暂时没有找到最近会话。'
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `最近会话回复已发送(${items.length} 条)`)
|
||||
}
|
||||
|
||||
private async resolveNaturalLanguage(text: string): Promise<NaturalLanguageResult> {
|
||||
const result = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content: `你是 WechatExplorer 微信机器人的意图理解器。只能输出一行 JSON,不要 Markdown。
|
||||
支持的工具:
|
||||
1. recent:查看最近会话,参数 limit 为 1-20。
|
||||
2. contact:查看我与某个联系人的最近聊天,参数 contact 和 limit。
|
||||
3. report:生成某个群的群聊总结图片,参数 group 和 range(today、yesterday、7days)。
|
||||
4. group_member:分析某个群里某位成员的发言,参数 group、member、range(today、yesterday、7days)、days(1-30)和 goal(保留用户希望总结、研究人物、提取观点等完整目标)。只要用户同时提到群聊和群成员,应优先使用 group_member,不能识别成 contact。
|
||||
5. chat:不需要工具的普通对话,reply 用简洁中文直接回答。
|
||||
输出格式:{"type":"recent|contact|report|group_member|chat","limit":5,"contact":"","group":"","member":"","range":"today","days":3,"goal":"","reply":""}
|
||||
不要声称已经读取未调用的聊天记录,不要执行电脑控制、文件操作、付款或发送给其他联系人。`
|
||||
},
|
||||
{ role: 'user', content: text.slice(0, 1000) }
|
||||
])
|
||||
if (!result.success || !result.data) {
|
||||
this.addLog('agent-hub', 'warn', `自然语言理解不可用:${result.error || 'AI 未返回内容'}`)
|
||||
return {}
|
||||
}
|
||||
|
||||
try {
|
||||
const json = result.data.match(/\{[\s\S]*\}/)?.[0]
|
||||
if (!json) return {}
|
||||
const parsed = JSON.parse(json) as Record<string, unknown>
|
||||
const limit = Math.max(1, Math.min(20, Number(parsed['limit']) || 5))
|
||||
if (parsed['type'] === 'recent') return { command: `最近${limit}条消息` }
|
||||
if (parsed['type'] === 'contact' && String(parsed['contact'] || '').trim()) {
|
||||
return { command: `我和${String(parsed['contact']).trim()}最近${limit}条聊了什么` }
|
||||
}
|
||||
if (parsed['type'] === 'report' && String(parsed['group'] || '').trim()) {
|
||||
const range =
|
||||
parsed['range'] === '7days'
|
||||
? '最近7天'
|
||||
: parsed['range'] === 'yesterday'
|
||||
? '昨天'
|
||||
: '今天'
|
||||
return { command: `生成${String(parsed['group']).trim()}${range}的群聊总结图片` }
|
||||
}
|
||||
if (
|
||||
parsed['type'] === 'group_member' &&
|
||||
String(parsed['group'] || '').trim() &&
|
||||
String(parsed['member'] || '').trim()
|
||||
) {
|
||||
const range =
|
||||
parsed['range'] === 'yesterday'
|
||||
? '昨天'
|
||||
: parsed['range'] === 'today'
|
||||
? '今天'
|
||||
: '最近7天'
|
||||
const group = String(parsed['group'] || '')
|
||||
.trim()
|
||||
.replace(/(?:群聊|群)+$/g, '')
|
||||
const days = Math.max(1, Math.min(30, Number(parsed['days']) || 7))
|
||||
const goal = String(parsed['goal'] || '总结发言').trim()
|
||||
return {
|
||||
command: `看看${group}群里${String(parsed['member']).trim()}${range === '最近7天' ? `最近${days}天` : range}说了什么,${goal}`
|
||||
}
|
||||
}
|
||||
if (parsed['type'] === 'chat') {
|
||||
const reply = String(parsed['reply'] || '').trim()
|
||||
return reply ? { reply: reply.slice(0, 1500) } : {}
|
||||
}
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'warn', `自然语言结果解析失败:${this.errorMessage(error)}`)
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
private async replyContactChat(
|
||||
inbound: InboundMessage,
|
||||
intent: ContactChatIntent
|
||||
@@ -395,6 +573,180 @@ class AgentHubService {
|
||||
this.addLog('agent-hub', 'info', `联系人聊天回复已发送(${recent.length} 条)`)
|
||||
}
|
||||
|
||||
private async summarizeContactChat(
|
||||
inbound: InboundMessage,
|
||||
intent: ContactChatIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const contact = resolveMd5(intent.contact)
|
||||
if (!contact || contact.type !== 'user') {
|
||||
await this.sendConnector(inbound, `没有找到联系人“${intent.contact}”。`)
|
||||
return
|
||||
}
|
||||
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`收到!正在整理和${contact.m_nsNickName}的近期聊天,请等待…`
|
||||
)
|
||||
const endTime = Math.floor(Date.now() / 1000)
|
||||
const startTime = endTime - 7 * 24 * 60 * 60
|
||||
const messages = listMessages(contact.md5, startTime, endTime, { limit: 300 }).slice(-300)
|
||||
if (!messages.length) {
|
||||
await this.sendConnector(inbound, `最近 7 天没有找到和${contact.m_nsNickName}的聊天记录。`)
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map((message) => {
|
||||
const speaker = message.isSender ? '我' : contact.m_nsNickName
|
||||
return `[${message.datetime}] ${speaker}:${this.describeChatMessage(message.content, message.type)}`
|
||||
})
|
||||
.join('\n')
|
||||
const summary = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是私人聊天记录总结助手。仅根据提供的记录总结,不编造。按日期或主题整理关键进展、双方观点、决定、待办和未解决问题;忽略无意义表情,保留重要数字与事实。使用适合微信阅读的简洁中文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `请总结我和“${contact.m_nsNickName}”最近 7 天聊了什么。\n\n聊天记录:\n${transcript}`
|
||||
}
|
||||
])
|
||||
if (!summary.success || !summary.data?.trim()) {
|
||||
throw new Error(summary.error || 'AI 未返回总结')
|
||||
}
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
this.formatAIReply(
|
||||
`和${contact.m_nsNickName}最近聊天总结(近 7 天,共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
|
||||
)
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `联系人聊天总结已发送(${messages.length} 条)`)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `联系人聊天总结失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `聊天总结失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async summarizeGroupMemberChat(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupMemberChatIntent
|
||||
): Promise<void> {
|
||||
try {
|
||||
if (!isReady()) {
|
||||
await this.sendConnector(inbound, 'WechatExplorer 本地数据库尚未连接,请连接后再试。')
|
||||
return
|
||||
}
|
||||
const group = this.resolveGroup(intent.group)
|
||||
if (!group) {
|
||||
await this.sendConnector(inbound, `没有找到群聊“${intent.group}”。`)
|
||||
return
|
||||
}
|
||||
const snapshot = getGroupSnapshot(group.md5)
|
||||
const memberQuery = intent.member.trim().toLowerCase()
|
||||
const member = snapshot?.members.find((item) =>
|
||||
[item.groupNickname, item.wechatNickname, item.remark, item.nickname, item.wxid].some(
|
||||
(name) =>
|
||||
String(name || '')
|
||||
.trim()
|
||||
.toLowerCase() === memberQuery
|
||||
)
|
||||
)
|
||||
if (!member) {
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`没有在“${group.m_nsNickName}”找到成员“${intent.member}”。`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const displayName =
|
||||
member.groupNickname || member.wechatNickname || member.remark || member.nickname
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`收到!正在整理${displayName}在“${group.m_nsNickName}”的近期发言,请等待…`
|
||||
)
|
||||
const now = new Date()
|
||||
const todayStart = Math.floor(
|
||||
new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
|
||||
)
|
||||
const startTime =
|
||||
intent.range === 'today'
|
||||
? todayStart
|
||||
: intent.range === 'yesterday'
|
||||
? todayStart - 24 * 60 * 60
|
||||
: Math.floor(Date.now() / 1000) - intent.days * 24 * 60 * 60
|
||||
const endTime = intent.range === 'yesterday' ? todayStart - 1 : Math.floor(Date.now() / 1000)
|
||||
const aliases = new Set(
|
||||
[member.groupNickname, member.wechatNickname, member.remark, member.nickname]
|
||||
.map((name) =>
|
||||
String(name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
const messages = listMessages(group.md5, startTime, endTime, { limit: 10_000 })
|
||||
.filter(
|
||||
(message) =>
|
||||
String(message.senderId || '').trim() === member.wxid ||
|
||||
aliases.has(
|
||||
String(message.name || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
)
|
||||
.slice(-1000)
|
||||
if (!messages.length) {
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
`所选时间范围没有找到${displayName}在“${group.m_nsNickName}”的发言。`
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`[${message.datetime}] ${this.describeChatMessage(message.content, message.type)}`
|
||||
)
|
||||
.join('\n')
|
||||
const summary = await agentAIProvider.chat([
|
||||
{
|
||||
role: 'system',
|
||||
content:
|
||||
'你是擅长分析微信群聊的助手。严格依据提供的发言完成用户的原始要求,输出结构和侧重点由内容决定,不套固定模板。可以归纳人物特征、兴趣、表达习惯和群内角色,但必须区分事实与推测,为推测说明依据和不确定性,不得编造。使用适合微信阅读的中文。'
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `用户原始要求:${intent.goal}\n分析对象:“${displayName}”在群聊“${group.m_nsNickName}”中的发言。\n时间范围:${intent.range === 'today' ? '今天' : intent.range === 'yesterday' ? '昨天' : `最近 ${intent.days} 天`}。\n共提供 ${messages.length} 条发言。\n\n发言记录:\n${transcript}`
|
||||
}
|
||||
])
|
||||
if (!summary.success || !summary.data?.trim()) {
|
||||
throw new Error(summary.error || 'AI 未返回总结')
|
||||
}
|
||||
await this.sendConnector(
|
||||
inbound,
|
||||
this.formatAIReply(
|
||||
`${displayName}在“${group.m_nsNickName}”的发言总结(共 ${messages.length} 条):\n\n${summary.data.trim().slice(0, 3500)}`
|
||||
)
|
||||
)
|
||||
this.addLog('agent-hub', 'info', `群成员发言总结已发送(${messages.length} 条)`)
|
||||
} catch (error) {
|
||||
this.addLog('agent-hub', 'error', `群成员发言总结失败:${this.errorMessage(error)}`)
|
||||
await this.sendConnector(inbound, `群成员发言总结失败:${this.errorMessage(error)}`).catch(
|
||||
() => undefined
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private describeChatMessage(content: string, type: string): string {
|
||||
const normalized = String(content || '')
|
||||
.replace(/\s+/g, ' ')
|
||||
@@ -404,6 +756,15 @@ class AgentHubService {
|
||||
return `[${label}]`
|
||||
}
|
||||
|
||||
private formatAIReply(content: string): string {
|
||||
return content
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.replace(/[ \t]*•[ \t]*/g, '\n• ')
|
||||
.replace(/[ \t]+(?=\d+[.、][ \t])/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
private async generateAndSendReport(
|
||||
inbound: InboundMessage,
|
||||
intent: GroupReportIntent
|
||||
@@ -465,7 +826,62 @@ class AgentHubService {
|
||||
if (!contact) return null
|
||||
|
||||
const limit = Number(normalized.match(/最近(\d{1,2})条/)?.[1] || 10)
|
||||
return { contact, limit: Math.max(1, Math.min(20, limit)) }
|
||||
const summarize = /(聊了什么|聊什么|说了什么|谈了什么|总结)/.test(normalized)
|
||||
return { contact, limit: Math.max(1, Math.min(20, limit)), summarize }
|
||||
}
|
||||
|
||||
private matchGroupMemberChatIntent(text: string): GroupMemberChatIntent | null {
|
||||
const normalized = text.trim().replace(/[,。!??::]/g, '')
|
||||
const timePattern = '(今天|今日|昨天|昨日|最近\\d{1,2}天|近\\d{1,2}天|最近|近来|这几天)'
|
||||
const actionPattern = '(?:说了什么|聊了什么|发言|说过什么|都聊什么|都说什么|干了什么)'
|
||||
const patterns = [
|
||||
new RegExp(
|
||||
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)[\\s,,]+(.+?)${timePattern}${actionPattern}`
|
||||
),
|
||||
new RegExp(
|
||||
`(?:看一下|看看|看下|查一下|总结一下)?(.+?群(?:聊)?)(?:里|中的)(.+?)${timePattern}${actionPattern}`
|
||||
)
|
||||
]
|
||||
for (const pattern of patterns) {
|
||||
const match = normalized.match(pattern)
|
||||
if (match?.[1]?.trim() && match[2]?.trim()) {
|
||||
const range = /昨天|昨日/.test(match[3] || '')
|
||||
? 'yesterday'
|
||||
: /今天|今日/.test(match[3] || '')
|
||||
? 'today'
|
||||
: '7days'
|
||||
const days = Math.max(1, Math.min(30, Number((match[3] || '').match(/\d{1,2}/)?.[0]) || 7))
|
||||
return {
|
||||
group: match[1].trim(),
|
||||
member: match[2].trim(),
|
||||
range,
|
||||
days,
|
||||
goal: normalized
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private resolveGroup(query: string): ReturnType<typeof resolveMd5> {
|
||||
const normalize = (value: string): string =>
|
||||
value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[\s,,。!??::、“”'‘’]/g, '')
|
||||
.replace(/(?:群聊|群)+$/g, '')
|
||||
const target = normalize(query)
|
||||
if (!target) return null
|
||||
|
||||
const groups = listContacts().filter((contact) => contact.type === 'group')
|
||||
return (
|
||||
groups.find((contact) => normalize(contact.m_nsNickName) === target) ||
|
||||
groups.find((contact) => {
|
||||
const name = normalize(contact.m_nsNickName)
|
||||
return name.includes(target) || target.includes(name)
|
||||
}) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private matchGroupReportIntent(text: string): GroupReportIntent | null {
|
||||
|
||||
@@ -41,7 +41,6 @@ export class AIProviderService {
|
||||
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
|
||||
|
||||
list(): AIProviderListResult {
|
||||
this.ensureEnvironmentMigration()
|
||||
try {
|
||||
const data = this.readMetadata()
|
||||
return {
|
||||
@@ -195,6 +194,9 @@ export class AIProviderService {
|
||||
const imageError = validateVisionImage(imagePart.dataUrl)
|
||||
if (imageError) throw new Error(imageError)
|
||||
const result = await this.request(messages as AIMessage[], options)
|
||||
if (options?.providerId && options.modelId) {
|
||||
this.markCapabilities(options.providerId, options.modelId, { vision: true, ocr: true })
|
||||
}
|
||||
return { success: true, ...result }
|
||||
} catch (error) {
|
||||
return { success: false, error: safeAIError(error) }
|
||||
@@ -335,18 +337,6 @@ export class AIProviderService {
|
||||
if (changed) this.writeMetadata(data)
|
||||
}
|
||||
|
||||
private ensureEnvironmentMigration(): void {
|
||||
const data = this.readMetadata()
|
||||
if (data.providers.length) return
|
||||
const apiKey = String(import.meta.env.VITE_DEEPSEEK_API_KEY || '').trim()
|
||||
if (!apiKey) return
|
||||
this.migrateLegacy({
|
||||
apiKey,
|
||||
baseUrl: String(import.meta.env.VITE_AI_BASE_URL || ''),
|
||||
model: String(import.meta.env.VITE_AI_MODEL || '')
|
||||
})
|
||||
}
|
||||
|
||||
private toSummary(
|
||||
provider: Omit<AIProviderSummary, 'hasApiKey' | 'isDefault'>,
|
||||
defaultProviderId?: string
|
||||
|
||||
@@ -11,6 +11,19 @@ export interface CachedSelfInfo {
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
export interface CachedGroupSnapshot {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
|
||||
interface CachedMessageBucket {
|
||||
updatedAt: number
|
||||
startTime?: number
|
||||
@@ -26,11 +39,16 @@ interface BootstrapCacheFile {
|
||||
self?: CachedSelfInfo
|
||||
contacts?: Contact[]
|
||||
messages?: Record<string, CachedMessageBucket>
|
||||
groupSnapshots?: Record<string, { updatedAt: number; snapshot: CachedGroupSnapshot }>
|
||||
}
|
||||
|
||||
const CACHE_VERSION = 1
|
||||
const MAX_MESSAGE_BUCKETS = 24
|
||||
const MAX_MESSAGES_PER_BUCKET = 1200
|
||||
const MAX_MESSAGE_BUCKETS = 768
|
||||
const MAX_MESSAGES_PER_BUCKET = 120
|
||||
const WRITE_DEBOUNCE_MS = 300
|
||||
const memoryCache = new Map<string, BootstrapCacheFile>()
|
||||
const writeTimers = new Map<string, NodeJS.Timeout>()
|
||||
const writeQueues = new Map<string, Promise<void>>()
|
||||
|
||||
function normalizeRoot(accountRoot?: string): string {
|
||||
return String(accountRoot || '').trim()
|
||||
@@ -43,27 +61,38 @@ function getCacheFile(accountRoot?: string): string {
|
||||
.update(`${process.platform}:${normalizedRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
return path.join(app.getPath('userData'), 'cache', 'bootstrap', `${process.platform}-${hash}.json`)
|
||||
return path.join(
|
||||
app.getPath('userData'),
|
||||
'cache',
|
||||
'bootstrap',
|
||||
`${process.platform}-${hash}.json`
|
||||
)
|
||||
}
|
||||
|
||||
function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
const file = getCacheFile(normalizedRoot)
|
||||
const cached = memoryCache.get(file)
|
||||
if (cached) return cached
|
||||
try {
|
||||
if (!fs.existsSync(file)) return null
|
||||
const raw = fs.readJsonSync(file) as Partial<BootstrapCacheFile>
|
||||
if (raw.version !== CACHE_VERSION || raw.platform !== process.platform) return null
|
||||
if (normalizeRoot(raw.accountRoot) !== normalizedRoot) return null
|
||||
return {
|
||||
const result: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Number(raw.updatedAt) || 0,
|
||||
self: raw.self,
|
||||
contacts: Array.isArray(raw.contacts) ? raw.contacts : [],
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {}
|
||||
messages: raw.messages && typeof raw.messages === 'object' ? raw.messages : {},
|
||||
groupSnapshots:
|
||||
raw.groupSnapshots && typeof raw.groupSnapshots === 'object' ? raw.groupSnapshots : {}
|
||||
}
|
||||
memoryCache.set(file, result)
|
||||
return result
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] read failed:', error)
|
||||
return null
|
||||
@@ -71,34 +100,82 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
|
||||
}
|
||||
|
||||
function writeCacheFile(cache: BootstrapCacheFile): void {
|
||||
try {
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeJsonSync(file, cache, { spaces: 2 })
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
}
|
||||
const file = getCacheFile(cache.accountRoot)
|
||||
memoryCache.set(file, cache)
|
||||
const existingTimer = writeTimers.get(file)
|
||||
if (existingTimer) clearTimeout(existingTimer)
|
||||
writeTimers.set(
|
||||
file,
|
||||
setTimeout(() => {
|
||||
writeTimers.delete(file)
|
||||
const serialized = JSON.stringify(memoryCache.get(file) || cache)
|
||||
const previous = writeQueues.get(file) || Promise.resolve()
|
||||
const next = previous
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(file))
|
||||
await fs.writeFile(file, serialized, 'utf8')
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[BootstrapCache] write failed:', error)
|
||||
})
|
||||
.finally(() => {
|
||||
if (writeQueues.get(file) === next) writeQueues.delete(file)
|
||||
})
|
||||
writeQueues.set(file, next)
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
|
||||
const normalizedRoot = normalizeRoot(accountRoot)
|
||||
if (!normalizedRoot) return null
|
||||
return (
|
||||
readCacheFile(normalizedRoot) || {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {}
|
||||
}
|
||||
)
|
||||
const existing = readCacheFile(normalizedRoot)
|
||||
if (existing) return existing
|
||||
const created: BootstrapCacheFile = {
|
||||
version: CACHE_VERSION,
|
||||
platform: process.platform,
|
||||
accountRoot: normalizedRoot,
|
||||
updatedAt: Date.now(),
|
||||
contacts: [],
|
||||
messages: {},
|
||||
groupSnapshots: {}
|
||||
}
|
||||
memoryCache.set(getCacheFile(normalizedRoot), created)
|
||||
return created
|
||||
}
|
||||
|
||||
function messageBucketKey(userMd5: string, startTime?: number, endTime?: number): string {
|
||||
return `${userMd5}:${startTime ?? ''}:${endTime ?? ''}`
|
||||
}
|
||||
|
||||
function cachedMessageIdentity(message: Message): string {
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
return `id:${message.id}`
|
||||
}
|
||||
|
||||
function containsLegacyMisparsedAppMessage(items: Message[]): boolean {
|
||||
return items.some((message) => {
|
||||
const content = message.contentData
|
||||
if (content?.type === 'system' && content.raw) {
|
||||
return (
|
||||
/<weappinfo\b/i.test(content.raw) &&
|
||||
/<type>\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw)
|
||||
)
|
||||
}
|
||||
if (
|
||||
content?.type === 'share' &&
|
||||
((content.typeVal === '3' && !content.url) || content.typeVal === '2001')
|
||||
) {
|
||||
return true
|
||||
}
|
||||
if (content?.type !== 'sticker') return false
|
||||
const url = String(content.url || content.thumbUrl || '')
|
||||
return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url)
|
||||
})
|
||||
}
|
||||
|
||||
function pruneMessageBuckets(messages: Record<string, CachedMessageBucket>): void {
|
||||
const entries = Object.entries(messages)
|
||||
if (entries.length <= MAX_MESSAGE_BUCKETS) return
|
||||
@@ -144,7 +221,9 @@ export function mergeCachedContactAvatars(accountRoot: string, contacts: Contact
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
cache.contacts
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
if (avatarByUsername.size === 0 && nameByUsername.size === 0) return contacts
|
||||
@@ -179,7 +258,9 @@ export function saveBootstrapContacts(accountRoot: string, contacts: Contact[]):
|
||||
)
|
||||
const nameByUsername = new Map(
|
||||
(cache.contacts || [])
|
||||
.filter((contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact))
|
||||
.filter(
|
||||
(contact) => contact.m_nsUsrName && contact.m_nsNickName && !isRawContactName(contact)
|
||||
)
|
||||
.map((contact) => [contact.m_nsUsrName, contact.m_nsNickName])
|
||||
)
|
||||
cache.contacts = contacts.map((contact) => ({
|
||||
@@ -223,6 +304,71 @@ export function getCachedMessages(
|
||||
return bucket?.items || []
|
||||
}
|
||||
|
||||
export function getCachedMessagePage(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): { hit: boolean; messages: Message[]; groupSnapshot?: CachedGroupSnapshot } {
|
||||
const cache = readCacheFile(accountRoot)
|
||||
const key = messageBucketKey(userMd5, startTime, endTime)
|
||||
let bucket = cache?.messages?.[key]
|
||||
if (!bucket && cache?.messages && startTime === undefined && endTime === undefined) {
|
||||
const merged = new Map<string, Message>()
|
||||
for (const [cachedKey, candidate] of Object.entries(cache.messages)) {
|
||||
if (!cachedKey.startsWith(`${userMd5}:`)) continue
|
||||
for (const message of candidate.items || []) {
|
||||
merged.set(cachedMessageIdentity(message), message)
|
||||
}
|
||||
}
|
||||
const migratedMessages = Array.from(merged.values())
|
||||
.sort((left, right) => (left.createTime || 0) - (right.createTime || 0))
|
||||
.slice(-MAX_MESSAGES_PER_BUCKET)
|
||||
if (migratedMessages.length > 0) {
|
||||
bucket = {
|
||||
updatedAt: Date.now(),
|
||||
items: migratedMessages
|
||||
}
|
||||
cache.messages[key] = bucket
|
||||
cache.updatedAt = Date.now()
|
||||
pruneMessageBuckets(cache.messages)
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
}
|
||||
return {
|
||||
hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []),
|
||||
messages: bucket?.items || [],
|
||||
groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedGroupSnapshot(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
snapshot: CachedGroupSnapshot
|
||||
): void {
|
||||
const cache = loadOrCreate(accountRoot)
|
||||
if (!cache) return
|
||||
cache.groupSnapshots ||= {}
|
||||
cache.groupSnapshots[userMd5] = { updatedAt: Date.now(), snapshot }
|
||||
cache.updatedAt = Date.now()
|
||||
writeCacheFile(cache)
|
||||
}
|
||||
|
||||
export function flushBootstrapCacheWritesSync(): void {
|
||||
for (const [file, cache] of memoryCache) {
|
||||
const timer = writeTimers.get(file)
|
||||
if (timer) clearTimeout(timer)
|
||||
writeTimers.delete(file)
|
||||
try {
|
||||
fs.ensureDirSync(path.dirname(file))
|
||||
fs.writeFileSync(file, JSON.stringify(cache), 'utf8')
|
||||
} catch (error) {
|
||||
console.warn('[BootstrapCache] flush failed:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function saveCachedMessages(
|
||||
accountRoot: string,
|
||||
userMd5: string,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WechatDb, WechatMessage } from '../wechat-db'
|
||||
import {
|
||||
parseImageBufferDataUrlFromRow,
|
||||
parseImageDatNameFromRow,
|
||||
parseMessageContent,
|
||||
parseStickerMessageFromRow
|
||||
@@ -8,6 +9,7 @@ import type {
|
||||
DatabaseKeyValidationCode,
|
||||
DatabaseKeyValidationResult
|
||||
} from '../../shared/database-key'
|
||||
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
|
||||
|
||||
export function getCurrentKey(): string {
|
||||
if (!dbRef) return ''
|
||||
@@ -33,6 +35,8 @@ export interface FormattedContact {
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface FormattedMessage {
|
||||
@@ -48,9 +52,17 @@ export interface FormattedMessage {
|
||||
contentData?: ReturnType<typeof parseMessageContent>
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
localId?: number
|
||||
serverId?: string
|
||||
createTime?: number
|
||||
sessionId?: string
|
||||
recalled?: boolean
|
||||
recalledBy?: string
|
||||
}
|
||||
|
||||
export interface GroupSnapshot {
|
||||
@@ -126,29 +138,35 @@ export function listContacts(filter?: string): FormattedContact[] {
|
||||
m_nsNickName: user.nickname || '未知用户',
|
||||
md5,
|
||||
type: isGroup ? 'group' : 'user',
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined
|
||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||
wechatNickname: user.wechatNickname,
|
||||
remark: user.remark
|
||||
})
|
||||
}
|
||||
|
||||
const chatTables = dbRef.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
if (existingMd5s.has(md5)) continue
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5,
|
||||
type: 'user'
|
||||
})
|
||||
// The session list already covers normal conversations. Only scan Chat_*
|
||||
// tables as a recovery fallback when the session query returned nothing.
|
||||
if (userList.length === 0) {
|
||||
const chatTables = dbRef.getAllChatTables()
|
||||
for (const table of chatTables) {
|
||||
if (!table.name.startsWith('Chat_')) continue
|
||||
const md5 = table.name.substring(5)
|
||||
if (existingMd5s.has(md5)) continue
|
||||
if (groupContacts[md5]) {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Group_${md5}`,
|
||||
m_nsNickName: groupContacts[md5],
|
||||
md5,
|
||||
type: 'group'
|
||||
})
|
||||
} else {
|
||||
contacts.push({
|
||||
m_nsUsrName: `Unknown_${md5}`,
|
||||
m_nsNickName: `Chat_${md5}`,
|
||||
md5,
|
||||
type: 'user'
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return contacts
|
||||
@@ -163,11 +181,12 @@ export function getContactAvatars(usernames: string[]): Record<string, string> {
|
||||
return dbRef.getWcdb4Client().getAvatarUrls(normalized)
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
function listSourceMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
options?: { limit?: number },
|
||||
rawMessagesOverride?: WechatMessage[]
|
||||
): FormattedMessage[] {
|
||||
if (!dbRef) return []
|
||||
|
||||
@@ -178,7 +197,8 @@ export function listMessages(
|
||||
console.log(
|
||||
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
|
||||
)
|
||||
const rawMessages = dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||
const rawMessages =
|
||||
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
|
||||
console.log(
|
||||
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
@@ -216,17 +236,62 @@ export function listMessages(
|
||||
|
||||
let contentData: ReturnType<typeof parseMessageContent> | undefined
|
||||
let displayType = MSG_TYPE_DICT[msgType] || msg.messageType
|
||||
const rawContent = String(content || '')
|
||||
const isPatMessage =
|
||||
/<patinfo\b|<type>\s*62\s*<\/type>/i.test(rawContent) ||
|
||||
([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent))
|
||||
if (isPatMessage) {
|
||||
const system = parseMessageContent(content, 10000)
|
||||
const patContent =
|
||||
system.type === 'system'
|
||||
? { ...system, pat: true }
|
||||
: { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true }
|
||||
contentData = patContent
|
||||
content = patContent.content
|
||||
displayType = '系统消息'
|
||||
}
|
||||
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, 10000, 10002].includes(inferredMsgType)) {
|
||||
if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const parsed =
|
||||
inferredMsgType === 47
|
||||
const isQuotePayload = /<refermsg\b/i.test(content)
|
||||
const hasStickerPayload =
|
||||
/<(?:emoji|sticker|emoticon)\b/i.test(content) ||
|
||||
/<type>\s*47\s*<\/type>/i.test(content)
|
||||
const rowSticker =
|
||||
inferredMsgType === 47 ||
|
||||
(inferredMsgType === 49 && !isQuotePayload && hasStickerPayload)
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: parseMessageContent(content, inferredMsgType)
|
||||
: undefined
|
||||
const parsedContent = parseMessageContent(content, inferredMsgType)
|
||||
const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : ''
|
||||
const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : ''
|
||||
const redPacketUrl = rowStickerUrl || parsedShareUrl
|
||||
const isRedPacketFallback =
|
||||
(parsedContent.type === 'share' && parsedContent.typeVal === '2001') ||
|
||||
/wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl)
|
||||
const parsed: ReturnType<typeof parseMessageContent> =
|
||||
parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket'
|
||||
? parsedContent
|
||||
: isRedPacketFallback
|
||||
? {
|
||||
type: 'redPacket',
|
||||
title:
|
||||
parsedContent.type === 'share' && parsedContent.title
|
||||
? parsedContent.title
|
||||
: '微信红包',
|
||||
description:
|
||||
parsedContent.type === 'share' && parsedContent.des
|
||||
? parsedContent.des
|
||||
: '恭喜发财,大吉大利',
|
||||
url: redPacketUrl || undefined
|
||||
}
|
||||
: rowSticker?.type === 'sticker'
|
||||
? rowSticker
|
||||
: parsedContent
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
@@ -236,6 +301,13 @@ export function listMessages(
|
||||
if (parsed.type === 'image') {
|
||||
const imageDatName = parseImageDatNameFromRow(msg)
|
||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||
} else if (parsed.type === 'miniProgram') {
|
||||
contentData = {
|
||||
...parsed,
|
||||
thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg),
|
||||
thumbDataUrl:
|
||||
parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg)
|
||||
}
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
@@ -245,6 +317,17 @@ export function listMessages(
|
||||
if (inferredMsgType !== msgType || rawMsgType !== msgType) {
|
||||
displayType = MSG_TYPE_DICT[inferredMsgType] || displayType
|
||||
}
|
||||
if (parsed.type === 'quote') displayType = '引用消息'
|
||||
if (parsed.type === 'sticker') displayType = '表情包'
|
||||
if (parsed.type === 'miniProgram') displayType = '小程序'
|
||||
if (parsed.type === 'redPacket') displayType = '微信红包'
|
||||
if (parsed.type === 'share') {
|
||||
if (parsed.typeVal === '5') displayType = '公众号链接'
|
||||
if (parsed.typeVal === '6') displayType = '文件'
|
||||
if (parsed.typeVal === '74') displayType = '文件发送中'
|
||||
if (parsed.typeVal === '51') displayType = '视频号'
|
||||
if (parsed.typeVal === '2000') displayType = '转账'
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
@@ -264,8 +347,12 @@ export function listMessages(
|
||||
|
||||
if (msgType === 34) content = '[语音消息]'
|
||||
|
||||
const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered'])
|
||||
|
||||
return {
|
||||
id: msg.mesLocalID || Math.random().toString(),
|
||||
id: recoveredFromRecallJournal
|
||||
? `recovered:${msg.mesLocalID || msg.serverId || createTime}`
|
||||
: msg.mesLocalID || Math.random().toString(),
|
||||
from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user',
|
||||
isSender: isMine,
|
||||
type: displayType,
|
||||
@@ -276,7 +363,9 @@ export function listMessages(
|
||||
senderId,
|
||||
sessionId: username,
|
||||
localId,
|
||||
serverId: typeof msg.serverId === 'string' ? msg.serverId : undefined,
|
||||
createTime,
|
||||
recoveredFromRecallJournal,
|
||||
contentData
|
||||
}
|
||||
})
|
||||
@@ -287,6 +376,39 @@ export function listMessages(
|
||||
return formatted
|
||||
}
|
||||
|
||||
export function listMessages(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
): FormattedMessage[] {
|
||||
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options)
|
||||
if (!dbRef) return sourceMessages
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export async function listMessagesAsync(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: { limit?: number }
|
||||
): Promise<FormattedMessage[]> {
|
||||
if (!dbRef) return []
|
||||
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
|
||||
const sourceMessages = listSourceMessages(
|
||||
userMd5,
|
||||
startTime,
|
||||
endTime,
|
||||
options,
|
||||
rawMessages
|
||||
)
|
||||
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
|
||||
recordRecallArchiveMessages(userMd5, username, sourceMessages)
|
||||
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
|
||||
}
|
||||
|
||||
export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
@@ -308,6 +430,25 @@ export function getGroupSnapshot(userMd5: string): GroupSnapshot | null {
|
||||
return { roomId, memberCount: members.length, members }
|
||||
}
|
||||
|
||||
export async function getGroupSnapshotAsync(userMd5: string): Promise<GroupSnapshot | null> {
|
||||
if (!dbRef) return null
|
||||
const wcdb4Client = dbRef.getWcdb4Client()
|
||||
const roomId = wcdb4Client.getUsernameByMd5(userMd5)
|
||||
if (!roomId || !roomId.endsWith('@chatroom')) return null
|
||||
|
||||
const members = (await wcdb4Client.getGroupMembersAsync(roomId))
|
||||
.filter((member) => member?.m_nsUsrName)
|
||||
.map((member) => ({
|
||||
wxid: member.m_nsUsrName,
|
||||
nickname: member.nickname || '',
|
||||
groupNickname: member.groupNickname || '',
|
||||
wechatNickname: member.wechatNickname || '',
|
||||
remark: member.remark || '',
|
||||
avatar: member.m_nsHeadImgUrl || ''
|
||||
}))
|
||||
return { roomId, memberCount: members.length, members }
|
||||
}
|
||||
|
||||
export function searchMessages(keyword: string): string | null {
|
||||
if (!dbRef) return null
|
||||
return dbRef.searchAllMessages(keyword)
|
||||
|
||||
@@ -17,7 +17,9 @@ import { isWechatRunning } from './wechat-process-status'
|
||||
export async function inspectImageDecryptionStatus(
|
||||
config: ImageKeyConfigResult
|
||||
): Promise<ImageDecryptionStatus> {
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot
|
||||
// 状态面板的"图片资源目录"始终等于当前识别到的微信账号根目录;
|
||||
// 仅在微信未连接时回退到上次配置中的 resourceRoot,避免空白。
|
||||
const accountRoot = chat.getCurrentAccountRoot() || config.resourceRoot || ''
|
||||
const imageDirectoryFound = hasImageDirectory(accountRoot)
|
||||
const stickerCacheFound =
|
||||
fs.existsSync(path.join(accountRoot, 'cache')) ||
|
||||
@@ -65,7 +67,10 @@ export function testImageDecryption(
|
||||
.reverse()
|
||||
.find((message) => message.contentData?.type === 'image')
|
||||
if (!imageMessage || imageMessage.contentData?.type !== 'image') {
|
||||
return failure('NO_IMAGE_MESSAGE', '所选聊天最近没有可测试的图片消息')
|
||||
return failure(
|
||||
'NO_IMAGE_MESSAGE',
|
||||
'所选聊天最近 300 条消息内没有可测试的图片,请换一个含图片的会话'
|
||||
)
|
||||
}
|
||||
|
||||
const service = new ImageDecryptService(
|
||||
@@ -74,26 +79,51 @@ export function testImageDecryption(
|
||||
chat.getChatDb()?.getWcdb4Client()
|
||||
)
|
||||
const image = imageMessage.contentData
|
||||
let filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: false })
|
||||
// 测试时优先使用用户在下方"图片资源目录"输入框填写的目录;
|
||||
// 找不到再退回默认 accountDir。
|
||||
const testAccountDir = normalized.resourceRoot || undefined
|
||||
let filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: false,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath)
|
||||
filePath = service.findImageFile(image.md5, image.datName, { allowThumbnail: true })
|
||||
filePath = service.findImageFile(image.md5, image.datName, {
|
||||
allowThumbnail: true,
|
||||
accountDir: testAccountDir
|
||||
})
|
||||
if (!filePath) return failure('FILE_NOT_FOUND', '图片文件不存在')
|
||||
|
||||
const data = service.decryptImageToBase64(filePath)
|
||||
if (!data) {
|
||||
// 三步联动:解密失败 → fileFound/decrypted/readable 都为 false。
|
||||
return {
|
||||
...failure('DECRYPT_FAILED', '无法解析媒体文件'),
|
||||
fileFound: true
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '无法解析媒体文件',
|
||||
fileFound: false,
|
||||
decrypted: false,
|
||||
readable: false
|
||||
}
|
||||
}
|
||||
const readable = data.startsWith('data:image/')
|
||||
if (!readable) {
|
||||
// 三步联动:解密成功但字节流不可读 → 前一步打勾(确实找到了 dat),
|
||||
// 但 decrypted/readable 全为 false,让 UI 表达"找到但解析失败"。
|
||||
return {
|
||||
success: false,
|
||||
code: 'DECRYPT_FAILED',
|
||||
error: '图片解密结果不可读取',
|
||||
fileFound: true,
|
||||
decrypted: false,
|
||||
readable: false,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: readable,
|
||||
code: readable ? undefined : 'DECRYPT_FAILED',
|
||||
error: readable ? undefined : '图片解密结果不可读取',
|
||||
success: true,
|
||||
fileFound: true,
|
||||
decrypted: true,
|
||||
readable,
|
||||
readable: true,
|
||||
isThumbnail: service.isThumbnailFile(filePath)
|
||||
}
|
||||
} catch {
|
||||
|
||||
@@ -92,9 +92,10 @@ export class ImageKeyConfigService {
|
||||
if (!result.success || !result.entry) {
|
||||
return { ...this.getEmptyConfig(), error: result.error || '图片密钥保存失败' }
|
||||
}
|
||||
// 注意:normalized.resourceRoot 不再写回 imageKeyRoot。
|
||||
// 下方的"图片资源目录"输入框仅用于本次手动测试,不再污染状态面板上方显示。
|
||||
saveSettings({
|
||||
...settings,
|
||||
imageKeyRoot: normalized.resourceRoot,
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false
|
||||
@@ -106,7 +107,9 @@ export class ImageKeyConfigService {
|
||||
encryptionAvailable: true,
|
||||
source: 'secure-storage',
|
||||
accountId: context.accountId,
|
||||
resourceRoot: normalized.resourceRoot,
|
||||
// 返回的 resourceRoot 始终是识别到的默认目录,与状态面板一致;
|
||||
// 不返回 normalized.resourceRoot,避免把用户输入的测试目录当成状态写回。
|
||||
resourceRoot: context.resourceRoot,
|
||||
xorKey: result.entry.xorKey,
|
||||
aesKey: result.entry.aesKey,
|
||||
updatedAt: result.entry.updatedAt
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import { app } from 'electron'
|
||||
import crypto from 'crypto'
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import type { Message } from '../../shared/types'
|
||||
|
||||
type RecallRecord = {
|
||||
targetIds: string[]
|
||||
actor?: string
|
||||
noticeId: string
|
||||
createTime: number
|
||||
}
|
||||
|
||||
type ArchivedMessage = Message & {
|
||||
archivedAt: number
|
||||
recallNotice?: boolean
|
||||
}
|
||||
|
||||
type SessionArchive = {
|
||||
username: string
|
||||
updatedAt: number
|
||||
messages: ArchivedMessage[]
|
||||
recalls: RecallRecord[]
|
||||
}
|
||||
|
||||
type RecallArchiveFile = {
|
||||
version: 1
|
||||
accountRoot: string
|
||||
updatedAt: number
|
||||
sessions: Record<string, SessionArchive>
|
||||
}
|
||||
|
||||
const ARCHIVE_VERSION = 1
|
||||
const MAX_MESSAGES_PER_SESSION = 3000
|
||||
const MAX_RECALLS_PER_SESSION = 500
|
||||
const WRITE_DEBOUNCE_MS = 250
|
||||
|
||||
let archive: RecallArchiveFile | null = null
|
||||
let archivePath = ''
|
||||
let writeTimer: NodeJS.Timeout | null = null
|
||||
let writeQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
function messageIdentity(message: Message): string {
|
||||
if (message.recoveredFromRecallJournal) {
|
||||
return `recovered:${message.localId || 0}:${message.serverId || message.id}`
|
||||
}
|
||||
if (message.localId) return `local:${message.localId}`
|
||||
if (message.serverId) return `server:${message.serverId}`
|
||||
if (message.id) return `id:${message.id}`
|
||||
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||
}
|
||||
|
||||
function messageTargetIds(message: Message): string[] {
|
||||
return Array.from(
|
||||
new Set(
|
||||
[
|
||||
message.serverId,
|
||||
message.localId ? String(message.localId) : '',
|
||||
message.id ? String(message.id) : ''
|
||||
].filter((value): value is string => Boolean(value))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function isRecallNotice(message: Message): boolean {
|
||||
return message.contentData?.type === 'system' && Boolean(message.contentData.recall)
|
||||
}
|
||||
|
||||
function matchesRecall(message: Message, recall: RecallRecord): boolean {
|
||||
const messageIds = messageTargetIds(message)
|
||||
return recall.targetIds.some((targetId) => messageIds.includes(targetId))
|
||||
}
|
||||
|
||||
function inferRecallTarget(
|
||||
messages: Iterable<ArchivedMessage>,
|
||||
notice: Message,
|
||||
actor?: string,
|
||||
claimedTargetIds: Set<string> = new Set()
|
||||
): ArchivedMessage | undefined {
|
||||
const noticeTime = notice.createTime || 0
|
||||
const expectsMine = /^(你|我)$/.test(String(actor || '').trim()) || /^你撤回/.test(notice.content)
|
||||
const eligible = Array.from(messages)
|
||||
.filter((message) => {
|
||||
if (message.type === '系统消息' || message.from === 'system' || message.recallNotice)
|
||||
return false
|
||||
if (!message.recoveredFromRecallJournal) return false
|
||||
if (messageTargetIds(message).some((targetId) => claimedTargetIds.has(targetId))) return false
|
||||
if (noticeTime && message.createTime && message.createTime > noticeTime) return false
|
||||
if (noticeTime && message.createTime && noticeTime - message.createTime > 180) return false
|
||||
return true
|
||||
})
|
||||
.sort((left, right) => {
|
||||
const timeDelta = (right.createTime || 0) - (left.createTime || 0)
|
||||
if (timeDelta) return timeDelta
|
||||
return (right.localId || 0) - (left.localId || 0)
|
||||
})
|
||||
|
||||
const strict = eligible.find((message) => {
|
||||
if (expectsMine) return message.isSender
|
||||
if (!actor) return true
|
||||
const names = [message.name, message.senderId].map((value) => String(value || '').trim())
|
||||
return !names.some(Boolean) || names.includes(actor)
|
||||
})
|
||||
|
||||
// Journal rows are captured at deletion time, so the fallback never points at
|
||||
// an unrelated visible message merely because it happened to be nearby.
|
||||
return strict || eligible[0]
|
||||
}
|
||||
|
||||
function annotateFromRecall(message: Message, recalls: RecallRecord[]): Message {
|
||||
if (isRecallNotice(message)) return message
|
||||
const recall = recalls.find((item) => matchesRecall(message, item))
|
||||
if (!recall) return message
|
||||
const recalledByMe = /^(你|我)$/.test(String(recall.actor || '').trim())
|
||||
return {
|
||||
...message,
|
||||
from: recalledByMe ? 'assistant' : message.from,
|
||||
isSender: recalledByMe ? true : message.isSender,
|
||||
recalled: true,
|
||||
recalledBy: recall.actor
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleWrite(): void {
|
||||
if (!archive || !archivePath) return
|
||||
if (writeTimer) clearTimeout(writeTimer)
|
||||
writeTimer = setTimeout(() => {
|
||||
writeTimer = null
|
||||
if (!archive || !archivePath) return
|
||||
const targetPath = archivePath
|
||||
const serialized = JSON.stringify(archive)
|
||||
writeQueue = writeQueue
|
||||
.catch(() => undefined)
|
||||
.then(async () => {
|
||||
await fs.ensureDir(path.dirname(targetPath))
|
||||
const temporaryPath = `${targetPath}.tmp`
|
||||
await fs.writeFile(temporaryPath, serialized, 'utf8')
|
||||
await fs.move(temporaryPath, targetPath, { overwrite: true })
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('[RecallArchive] write failed:', error)
|
||||
})
|
||||
}, WRITE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
function loadArchive(accountRoot: string): RecallArchiveFile {
|
||||
const hash = crypto
|
||||
.createHash('sha1')
|
||||
.update(`${process.platform}:${accountRoot}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16)
|
||||
archivePath = path.join(app.getPath('userData'), 'recall-archive', `${hash}.json`)
|
||||
|
||||
try {
|
||||
if (fs.existsSync(archivePath)) {
|
||||
const stored = fs.readJsonSync(archivePath) as Partial<RecallArchiveFile>
|
||||
if (
|
||||
stored.version === ARCHIVE_VERSION &&
|
||||
stored.accountRoot === accountRoot &&
|
||||
stored.sessions &&
|
||||
typeof stored.sessions === 'object'
|
||||
) {
|
||||
return stored as RecallArchiveFile
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[RecallArchive] read failed:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
version: ARCHIVE_VERSION,
|
||||
accountRoot,
|
||||
updatedAt: Date.now(),
|
||||
sessions: {}
|
||||
}
|
||||
}
|
||||
|
||||
export function configureRecallArchive(accountRoot: string): void {
|
||||
const normalizedRoot = String(accountRoot || '').trim()
|
||||
if (!normalizedRoot) {
|
||||
archive = null
|
||||
archivePath = ''
|
||||
return
|
||||
}
|
||||
if (archive?.accountRoot === normalizedRoot) return
|
||||
archive = loadArchive(normalizedRoot)
|
||||
}
|
||||
|
||||
export function recordRecallArchiveMessages(
|
||||
sessionMd5: string,
|
||||
username: string,
|
||||
messages: Message[]
|
||||
): void {
|
||||
if (!archive || !sessionMd5 || messages.length === 0) return
|
||||
|
||||
const bucket =
|
||||
archive.sessions[sessionMd5] ||
|
||||
({
|
||||
username,
|
||||
updatedAt: Date.now(),
|
||||
messages: [],
|
||||
recalls: []
|
||||
} satisfies SessionArchive)
|
||||
if (username) bucket.username = username
|
||||
|
||||
const byIdentity = new Map(bucket.messages.map((message) => [messageIdentity(message), message]))
|
||||
let changed = false
|
||||
const targetlessNoticeIds = new Set(
|
||||
messages
|
||||
.filter((message) => {
|
||||
const recall =
|
||||
message.contentData?.type === 'system' ? message.contentData.recall : undefined
|
||||
return Boolean(recall && !(recall.targetIds?.length || recall.targetId))
|
||||
})
|
||||
.map(messageIdentity)
|
||||
)
|
||||
if (targetlessNoticeIds.size > 0) {
|
||||
const retained = bucket.recalls.filter((record) => !targetlessNoticeIds.has(record.noticeId))
|
||||
if (retained.length !== bucket.recalls.length) {
|
||||
bucket.recalls = retained
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
for (const sourceMessage of messages) {
|
||||
const recallData =
|
||||
sourceMessage.contentData?.type === 'system' ? sourceMessage.contentData.recall : undefined
|
||||
if (recallData) {
|
||||
let targetIds = Array.from(
|
||||
new Set([...(recallData.targetIds || []), recallData.targetId || ''].filter(Boolean))
|
||||
)
|
||||
const noticeId = messageIdentity(sourceMessage)
|
||||
const noticeTargetIds = new Set(messageTargetIds(sourceMessage))
|
||||
const existingRecordIndex = bucket.recalls.findIndex((record) => record.noticeId === noticeId)
|
||||
const existingRecord =
|
||||
existingRecordIndex >= 0 ? bucket.recalls[existingRecordIndex] : undefined
|
||||
const existingTargetsNotice =
|
||||
existingRecord &&
|
||||
existingRecord.targetIds.length > 0 &&
|
||||
existingRecord.targetIds.every((targetId) => noticeTargetIds.has(targetId))
|
||||
|
||||
if (
|
||||
targetIds.length === 0 &&
|
||||
existingRecord &&
|
||||
!existingTargetsNotice &&
|
||||
!targetlessNoticeIds.has(noticeId)
|
||||
) {
|
||||
targetIds = existingRecord.targetIds
|
||||
}
|
||||
if (targetIds.length === 0) {
|
||||
const claimedTargetIds = new Set(bucket.recalls.flatMap((record) => record.targetIds))
|
||||
const inferred = inferRecallTarget(
|
||||
byIdentity.values(),
|
||||
sourceMessage,
|
||||
recallData.actor,
|
||||
claimedTargetIds
|
||||
)
|
||||
if (inferred) targetIds = messageTargetIds(inferred)
|
||||
}
|
||||
if (targetIds.length > 0) {
|
||||
const nextRecord: RecallRecord = {
|
||||
targetIds,
|
||||
actor: recallData.actor,
|
||||
noticeId,
|
||||
createTime: sourceMessage.createTime || 0
|
||||
}
|
||||
const unchanged =
|
||||
existingRecord &&
|
||||
existingRecord.targetIds.length === targetIds.length &&
|
||||
existingRecord.targetIds.every((targetId) => targetIds.includes(targetId)) &&
|
||||
existingRecord.actor === nextRecord.actor
|
||||
if (!unchanged) {
|
||||
if (existingRecordIndex >= 0) bucket.recalls.splice(existingRecordIndex, 1, nextRecord)
|
||||
else bucket.recalls.push(nextRecord)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const annotated = annotateFromRecall(sourceMessage, bucket.recalls)
|
||||
const next: ArchivedMessage = {
|
||||
...annotated,
|
||||
archivedAt: Date.now(),
|
||||
recallNotice: isRecallNotice(sourceMessage)
|
||||
}
|
||||
const identity = messageIdentity(next)
|
||||
const previous = byIdentity.get(identity)
|
||||
if (
|
||||
!previous ||
|
||||
previous.recalled !== next.recalled ||
|
||||
previous.content !== next.content ||
|
||||
previous.serverId !== next.serverId
|
||||
) {
|
||||
byIdentity.set(identity, next)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
const recalls = bucket.recalls.slice(-MAX_RECALLS_PER_SESSION)
|
||||
const nextMessages = Array.from(byIdentity.values())
|
||||
.map((message) => annotateFromRecall(message, recalls) as ArchivedMessage)
|
||||
.sort((left, right) => {
|
||||
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||
})
|
||||
.slice(-MAX_MESSAGES_PER_SESSION)
|
||||
|
||||
if (!changed && nextMessages.every((message, index) => message === bucket.messages[index])) return
|
||||
|
||||
bucket.messages = nextMessages
|
||||
bucket.recalls = recalls
|
||||
bucket.updatedAt = Date.now()
|
||||
archive.sessions[sessionMd5] = bucket
|
||||
archive.updatedAt = Date.now()
|
||||
scheduleWrite()
|
||||
}
|
||||
|
||||
export function mergeRecallArchiveMessages<T extends Message>(
|
||||
sessionMd5: string,
|
||||
sourceMessages: T[],
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
limit?: number
|
||||
): T[] {
|
||||
const bucket = archive?.sessions[sessionMd5]
|
||||
if (!bucket) return sourceMessages
|
||||
|
||||
const merged = new Map<string, Message>()
|
||||
for (const message of sourceMessages) {
|
||||
const archived = bucket.messages.find(
|
||||
(candidate) => messageIdentity(candidate) === messageIdentity(message)
|
||||
)
|
||||
merged.set(
|
||||
messageIdentity(message),
|
||||
annotateFromRecall(
|
||||
archived?.recalled
|
||||
? { ...message, recalled: true, recalledBy: archived.recalledBy }
|
||||
: message,
|
||||
bucket.recalls
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
for (const archived of bucket.messages) {
|
||||
if (!archived.recalled && !archived.recallNotice) continue
|
||||
const createTime = archived.createTime || 0
|
||||
if (startTime && createTime < startTime) continue
|
||||
if (endTime && createTime > endTime) continue
|
||||
const identity = messageIdentity(archived)
|
||||
if (!merged.has(identity)) merged.set(identity, archived)
|
||||
}
|
||||
|
||||
const result = Array.from(merged.values()).sort((left, right) => {
|
||||
const timeDelta = (left.createTime || 0) - (right.createTime || 0)
|
||||
return timeDelta || messageIdentity(left).localeCompare(messageIdentity(right))
|
||||
})
|
||||
const visible = limit && result.length > limit ? result.slice(-limit) : result
|
||||
return visible as T[]
|
||||
}
|
||||
|
||||
export function extractChangedConversationMd5(payload: string): string[] {
|
||||
const matches = String(payload || '').match(/(?:Chat_|chat_)?([a-f0-9]{32})/gi) || []
|
||||
return Array.from(
|
||||
new Set(
|
||||
matches
|
||||
.map((value) => value.replace(/^chat_/i, '').toLowerCase())
|
||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
type ArchiveContact = {
|
||||
md5: string
|
||||
m_nsUsrName: string
|
||||
type: 'user' | 'group'
|
||||
activityKey?: string
|
||||
}
|
||||
|
||||
export class RecallArchiveMonitor {
|
||||
private pending = new Set<string>()
|
||||
private activityBySession = new Map<string, string>()
|
||||
private timer: NodeJS.Timeout | null = null
|
||||
private stopped = false
|
||||
|
||||
constructor(
|
||||
private readonly listContacts: () => ArchiveContact[],
|
||||
private readonly loadMessages: (sessionMd5: string) => Message[]
|
||||
) {}
|
||||
|
||||
seedAll(): void {
|
||||
const groups = this.conversationContacts()
|
||||
this.activityBySession = new Map(
|
||||
groups.map((contact) => [contact.md5, String(contact.activityKey || '')])
|
||||
)
|
||||
const allowed = new Set(groups.map((contact) => contact.md5))
|
||||
if (archive) {
|
||||
let pruned = false
|
||||
for (const sessionMd5 of Object.keys(archive.sessions)) {
|
||||
if (allowed.has(sessionMd5)) continue
|
||||
delete archive.sessions[sessionMd5]
|
||||
pruned = true
|
||||
}
|
||||
if (pruned) {
|
||||
archive.updatedAt = Date.now()
|
||||
scheduleWrite()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
handleDatabaseChange(payload: string): void {
|
||||
// Ordinary message inserts are already visible in the main reader. The
|
||||
// archive scan is only needed when the native event indicates a recall.
|
||||
if (!/(recall|revoke|revokemsg|撤回)/i.test(String(payload || ''))) return
|
||||
const groups = this.conversationContacts()
|
||||
const knownMd5 = new Set(groups.map((contact) => contact.md5.toLowerCase()))
|
||||
const payloadTargets = extractChangedConversationMd5(payload).filter((md5) => knownMd5.has(md5))
|
||||
const changedActivity = groups
|
||||
.filter((contact) => {
|
||||
const nextKey = String(contact.activityKey || '')
|
||||
const previousKey = this.activityBySession.get(contact.md5)
|
||||
this.activityBySession.set(contact.md5, nextKey)
|
||||
return Boolean(nextKey && previousKey !== undefined && previousKey !== nextKey)
|
||||
})
|
||||
.map((contact) => contact.md5)
|
||||
const targets = Array.from(new Set([...payloadTargets, ...changedActivity]))
|
||||
if (targets.length > 0) {
|
||||
console.log(`[RecallArchive] database change queued=${targets.length}`)
|
||||
this.enqueue(targets)
|
||||
}
|
||||
}
|
||||
|
||||
private conversationContacts(): ArchiveContact[] {
|
||||
return this.listContacts().filter(
|
||||
(contact) =>
|
||||
contact.m_nsUsrName &&
|
||||
!contact.m_nsUsrName.startsWith('@placeholder') &&
|
||||
contact.m_nsUsrName !== 'brandsessionholder' &&
|
||||
contact.m_nsUsrName !== 'brandservicesessionholder'
|
||||
)
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
this.stopped = true
|
||||
this.pending.clear()
|
||||
if (this.timer) clearTimeout(this.timer)
|
||||
this.timer = null
|
||||
}
|
||||
|
||||
private enqueue(sessionMd5s: string[]): void {
|
||||
if (this.stopped) return
|
||||
for (const md5 of sessionMd5s) {
|
||||
if (md5) this.pending.add(md5)
|
||||
}
|
||||
this.schedule()
|
||||
}
|
||||
|
||||
private schedule(): void {
|
||||
if (this.stopped || this.timer || this.pending.size === 0) return
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = null
|
||||
const md5 = this.pending.values().next().value as string | undefined
|
||||
if (!md5) return
|
||||
this.pending.delete(md5)
|
||||
try {
|
||||
this.loadMessages(md5)
|
||||
} catch (error) {
|
||||
console.warn(`[RecallArchive] background scan failed md5=${md5}:`, error)
|
||||
}
|
||||
this.schedule()
|
||||
}, 20)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,22 @@ import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import os from 'os'
|
||||
|
||||
/**
|
||||
* 把 V3 时代的 "...\\Documents\\WeChat Files" 路径重定向到
|
||||
* "...\\Documents\\xwechat_files"(V4)。如果 xwechat_files 不存在则保留原值。
|
||||
* 仅支持 WeChat 4.0:自动纠正用户机器上残留的旧路径。
|
||||
*/
|
||||
function redirectLegacyWeChatFilesToXwechat(candidate: string): string {
|
||||
if (!candidate) return candidate
|
||||
const normalized = candidate.replace(/[\\/]+$/, '')
|
||||
const lowered = normalized.toLowerCase()
|
||||
const legacyMarker = `${path.sep}wechat files`
|
||||
if (!lowered.endsWith(legacyMarker)) return candidate
|
||||
const redirected = `${normalized.slice(0, -legacyMarker.length)}${path.sep}xwechat_files`
|
||||
if (fs.existsSync(redirected)) return redirected
|
||||
return candidate
|
||||
}
|
||||
|
||||
export interface AppSettings {
|
||||
dbRoot: string
|
||||
apiEnabled: boolean
|
||||
@@ -12,6 +28,8 @@ export interface AppSettings {
|
||||
imageXorKey: string
|
||||
imageAesKey: string
|
||||
imageKeyFallbackDisabled: boolean
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
}
|
||||
@@ -24,28 +42,20 @@ function getDefaultDbRoot(): string {
|
||||
|
||||
function getDefaultDbRootCandidates(home: string): string[] {
|
||||
if (process.platform !== 'win32') {
|
||||
// macOS 仅支持 WeChat 4.0 路径(xwechat_files)
|
||||
return [
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||
]
|
||||
}
|
||||
|
||||
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
|
||||
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
|
||||
const candidates = [
|
||||
...getWeflowDbPathCandidates(home),
|
||||
path.join(home, 'Documents', 'WeChat Files'),
|
||||
path.join(home, 'Documents', 'xwechat_files'),
|
||||
path.join(home, 'WeChat Files'),
|
||||
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
|
||||
for (const drive of getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
|
||||
return unique(candidates)
|
||||
}
|
||||
|
||||
@@ -68,32 +78,6 @@ function getWeflowDbPathCandidates(home: string): string[] {
|
||||
return candidates
|
||||
}
|
||||
|
||||
function getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
function listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values))
|
||||
}
|
||||
@@ -121,6 +105,8 @@ const DEFAULT_SETTINGS: AppSettings = {
|
||||
imageXorKey: '',
|
||||
imageAesKey: '',
|
||||
imageKeyFallbackDisabled: false,
|
||||
recallProtectionEnabled: false,
|
||||
debugEnabled: false,
|
||||
autoLogin: ['1', 'true', 'yes', 'on'].includes(
|
||||
String(import.meta.env.VITE_AUTO_LOGIN || '')
|
||||
.trim()
|
||||
@@ -155,9 +141,19 @@ export function loadSettings(): AppSettings {
|
||||
if (process.platform === 'win32' && !isUsableDbRoot(cache.dbRoot)) {
|
||||
cache.dbRoot = getDefaultDbRoot()
|
||||
}
|
||||
if (!cache.imageKeyRoot) {
|
||||
// 同步:imageKeyRoot 必须跟随 dbRoot 更新,
|
||||
// 否则自动获取会扫错目录(旧 bug:状态面板显示 D 盘,自动获取扫 C 盘)。
|
||||
if (!cache.imageKeyRoot || !isUsableDbRoot(cache.imageKeyRoot)) {
|
||||
cache.imageKeyRoot = cache.dbRoot
|
||||
}
|
||||
// V4-only 兜底:如果 imageKeyRoot 指向旧的 "WeChat Files"(V3 路径),
|
||||
// 重定向到同一父目录下的 xwechat_files(V4)。
|
||||
if (cache.imageKeyRoot) {
|
||||
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
|
||||
}
|
||||
if (cache.dbRoot) {
|
||||
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
|
||||
}
|
||||
return cache
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -21,6 +21,18 @@ export class StickerService {
|
||||
const normalizedMd5 = this.normalizeMd5(md5)
|
||||
let url = String(cdnUrl || '').trim()
|
||||
|
||||
const cacheKey =
|
||||
normalizedMd5 || (url ? crypto.createHash('md5').update(url).digest('hex') : '')
|
||||
if (cacheKey) {
|
||||
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 }
|
||||
}
|
||||
|
||||
if (!url && normalizedMd5 && this.wcdb4Client) {
|
||||
url = this.wcdb4Client.resolveEmoticonCdnUrl(normalizedMd5) || ''
|
||||
if (!url) {
|
||||
@@ -32,30 +44,26 @@ export class StickerService {
|
||||
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 }
|
||||
const resolvedCacheKey = cacheKey || crypto.createHash('md5').update(url).digest('hex')
|
||||
|
||||
if (normalizedMd5 && this.wcdb4Client) {
|
||||
const wechatCached = await this.readWechatEmoticonCache(normalizedMd5)
|
||||
if (wechatCached) return { success: true, data: wechatCached }
|
||||
}
|
||||
|
||||
const pending = downloadCache.get(cacheKey)
|
||||
const pending = downloadCache.get(resolvedCacheKey)
|
||||
if (pending) return pending
|
||||
|
||||
const task = this.downloadToDataUrl(url, cacheKey)
|
||||
downloadCache.set(cacheKey, task)
|
||||
const task = this.downloadToDataUrl(url, resolvedCacheKey)
|
||||
downloadCache.set(resolvedCacheKey, task)
|
||||
try {
|
||||
return await task
|
||||
} finally {
|
||||
downloadCache.delete(cacheKey)
|
||||
downloadCache.delete(resolvedCacheKey)
|
||||
}
|
||||
}
|
||||
|
||||
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')]
|
||||
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}`)
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
import crypto from 'crypto'
|
||||
import type { Wcdb4Client } from './wcdb4-client'
|
||||
|
||||
type VideoAsset = {
|
||||
filePath: string
|
||||
posterPath?: string
|
||||
}
|
||||
|
||||
export class VideoAssetService {
|
||||
private readonly urlTokens = new Map<string, string>()
|
||||
private index: Map<string, VideoAsset> | null = null
|
||||
|
||||
constructor(private readonly client: Wcdb4Client) {}
|
||||
|
||||
resolve(hashes: string[]): { success: boolean; url?: string; poster?: string; error?: string } {
|
||||
const candidates = Array.from(
|
||||
new Set(
|
||||
hashes
|
||||
.map((value) =>
|
||||
String(value || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
)
|
||||
.filter((value) => /^[a-f0-9]{32}$/.test(value))
|
||||
)
|
||||
)
|
||||
if (candidates.length === 0) return { success: false, error: '视频标识为空' }
|
||||
|
||||
const hardlinkDb = path.join(
|
||||
this.client.getAccountRoot(),
|
||||
'db_storage',
|
||||
'hardlink',
|
||||
'hardlink.db'
|
||||
)
|
||||
const lookupKeys = [...candidates]
|
||||
if (fs.existsSync(hardlinkDb)) {
|
||||
for (const hash of candidates) {
|
||||
const resolved = this.client.resolveVideoHardlink(hash, hardlinkDb)?.resolved_md5
|
||||
if (resolved) lookupKeys.unshift(String(resolved).trim().toLowerCase())
|
||||
}
|
||||
}
|
||||
|
||||
const index = this.getIndex()
|
||||
for (const key of lookupKeys) {
|
||||
const asset = index.get(key) || index.get(`${key}_raw`)
|
||||
if (!asset) continue
|
||||
return {
|
||||
success: true,
|
||||
url: this.createUrl(asset.filePath),
|
||||
poster: asset.posterPath ? this.createUrl(asset.posterPath) : undefined
|
||||
}
|
||||
}
|
||||
return { success: false, error: '本地未找到该视频文件' }
|
||||
}
|
||||
|
||||
pathForToken(token: string): string | undefined {
|
||||
const filePath = this.urlTokens.get(token)
|
||||
if (!filePath || !fs.existsSync(filePath)) return undefined
|
||||
return filePath
|
||||
}
|
||||
|
||||
private createUrl(filePath: string): string {
|
||||
const token = crypto.randomBytes(18).toString('hex')
|
||||
this.urlTokens.set(token, filePath)
|
||||
if (this.urlTokens.size > 500) {
|
||||
const first = this.urlTokens.keys().next().value
|
||||
if (first) this.urlTokens.delete(first)
|
||||
}
|
||||
return `wxe-media://local/${token}`
|
||||
}
|
||||
|
||||
private getIndex(): Map<string, VideoAsset> {
|
||||
if (this.index) return this.index
|
||||
const result = new Map<string, VideoAsset>()
|
||||
const root = path.join(this.client.getAccountRoot(), 'msg', 'video')
|
||||
if (!fs.existsSync(root)) {
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
|
||||
for (const month of fs.readdirSync(root)) {
|
||||
const monthPath = path.join(root, month)
|
||||
if (!fs.statSync(monthPath).isDirectory()) continue
|
||||
for (const name of fs.readdirSync(monthPath)) {
|
||||
const match = /^([a-f0-9]{32})(?:(_raw))?\.(mp4|jpg)$/i.exec(name)
|
||||
if (!match) continue
|
||||
const key = `${match[1].toLowerCase()}${match[2] || ''}`
|
||||
const fullPath = path.join(monthPath, name)
|
||||
const existing = result.get(key) || { filePath: '' }
|
||||
if (match[3].toLowerCase() === 'mp4') existing.filePath = fullPath
|
||||
else if (!existing.posterPath) existing.posterPath = fullPath
|
||||
result.set(key, existing)
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, asset] of result) {
|
||||
if (!asset.filePath) result.delete(key)
|
||||
}
|
||||
this.index = result
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -10,11 +10,14 @@ export interface Wcdb4Session {
|
||||
username: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
raw: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface Wcdb4Message {
|
||||
mesLocalID: string
|
||||
serverId?: string
|
||||
mesDes: number
|
||||
messageType: string
|
||||
msgCreateTime: string
|
||||
@@ -29,6 +32,11 @@ export interface Wcdb4MessageQueryOptions {
|
||||
limit?: number
|
||||
}
|
||||
|
||||
type Wcdb4MessageStore = {
|
||||
tableName: string
|
||||
dbPath: string
|
||||
}
|
||||
|
||||
export interface Wcdb4GroupMember {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
@@ -44,6 +52,11 @@ export interface Wcdb4ImageHardlink {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export interface Wcdb4VideoHardlink {
|
||||
resolved_md5?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
type KoffiModule = {
|
||||
load: (libraryPath: string) => KoffiLibrary
|
||||
decode: (ptr: unknown, type: string, length: number) => string
|
||||
@@ -55,6 +68,8 @@ type KoffiModule = {
|
||||
// library reference for every Wcdb4Client instance.
|
||||
let wcdbBootstrapLib: KoffiLibrary | null = null
|
||||
|
||||
let wcdbBootstrapAsyncPromise: Promise<KoffiLibrary> | null = null
|
||||
|
||||
export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string): KoffiLibrary {
|
||||
if (wcdbBootstrapLib) return wcdbBootstrapLib
|
||||
|
||||
@@ -128,8 +143,82 @@ export function bootstrapWcdbNative(libPath?: string, libDirOverride?: string):
|
||||
return lib
|
||||
}
|
||||
|
||||
export function bootstrapWcdbNativeAsync(
|
||||
libPath?: string,
|
||||
libDirOverride?: string
|
||||
): Promise<KoffiLibrary> {
|
||||
if (wcdbBootstrapLib) return Promise.resolve(wcdbBootstrapLib)
|
||||
if (wcdbBootstrapAsyncPromise) return wcdbBootstrapAsyncPromise
|
||||
|
||||
wcdbBootstrapAsyncPromise = (async () => {
|
||||
const koffi = nodeRequire('koffi') as KoffiModule
|
||||
const resolvedLibPath = libPath || Wcdb4Client.resolveNativeLibrary()
|
||||
const libDir = libDirOverride || path.dirname(resolvedLibPath)
|
||||
for (const name of process.platform === 'win32'
|
||||
? ['WCDB.dll', 'SDL2.dll']
|
||||
: process.platform === 'darwin'
|
||||
? ['libWCDB.dylib']
|
||||
: []) {
|
||||
const preloadPath = path.join(libDir, name)
|
||||
if (!fs.existsSync(preloadPath)) continue
|
||||
try {
|
||||
koffi.load(preloadPath)
|
||||
} catch {
|
||||
// The main library may still resolve its dependencies through the loader.
|
||||
}
|
||||
}
|
||||
const lib = koffi.load(resolvedLibPath)
|
||||
const initProtection = lib.func('int32 InitProtection(const char* resourcePath)') as (
|
||||
resourcePath: string
|
||||
) => number
|
||||
const resourceRoots = Array.from(
|
||||
new Set(
|
||||
[libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter(
|
||||
Boolean
|
||||
)
|
||||
)
|
||||
)
|
||||
let initOk = false
|
||||
for (const resourceRoot of resourceRoots) {
|
||||
try {
|
||||
if (Number(initProtection(resourceRoot)) === 0) {
|
||||
initOk = true
|
||||
break
|
||||
}
|
||||
} catch {
|
||||
// Try the next resource root.
|
||||
}
|
||||
}
|
||||
if (initOk) {
|
||||
const wcdbInit = lib.func('int32 wcdb_init()') as KoffiAsyncFunction
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
wcdbInit.async((error: unknown, result: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
if (Number(result) !== 0) {
|
||||
console.warn(`[WCDB4] async wcdb_init rc=${Number(result)}`)
|
||||
}
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
wcdbBootstrapLib = lib
|
||||
return lib
|
||||
})().catch((error) => {
|
||||
wcdbBootstrapAsyncPromise = null
|
||||
throw error
|
||||
})
|
||||
return wcdbBootstrapAsyncPromise
|
||||
}
|
||||
|
||||
type KoffiLibrary = {
|
||||
func: (signature: string) => (...args: unknown[]) => unknown
|
||||
func: (signature: string) => KoffiAsyncFunction
|
||||
}
|
||||
|
||||
type KoffiAsyncFunction = ((...args: unknown[]) => unknown) & {
|
||||
async: (...args: unknown[]) => void
|
||||
}
|
||||
|
||||
type WcdbVoidOut = [unknown]
|
||||
@@ -157,6 +246,7 @@ export class Wcdb4Client {
|
||||
private wcdbOpenAccount:
|
||||
| ((sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number)
|
||||
| null = null
|
||||
private wcdbOpenAccountAsync: KoffiAsyncFunction | null = null
|
||||
private wcdbSetMyWxid: ((handle: number, wxid: string) => number) | null = null
|
||||
private wcdbFreeString: ((ptr: unknown) => void) | null = null
|
||||
private wcdbGetSessions: ((handle: number, outJson: WcdbVoidOut) => number) | null = null
|
||||
@@ -216,6 +306,9 @@ export class Wcdb4Client {
|
||||
private wcdbResolveImageHardlink:
|
||||
| ((handle: number, md5: string, accountDir: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbResolveVideoHardlink:
|
||||
| ((handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
private wcdbGetEmoticonCdnUrl:
|
||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||
| null = null
|
||||
@@ -296,24 +389,17 @@ export class Wcdb4Client {
|
||||
private static getDefaultRootCandidates(): string[] {
|
||||
const home = os.homedir()
|
||||
if (process.platform === 'win32') {
|
||||
// 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选
|
||||
const candidates = [
|
||||
...Wcdb4Client.getWeflowDbPathCandidates(home),
|
||||
path.join(home, 'Documents', 'WeChat Files'),
|
||||
path.join(home, 'Documents', 'xwechat_files'),
|
||||
path.join(home, 'WeChat Files'),
|
||||
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
|
||||
]
|
||||
for (const drive of Wcdb4Client.getWindowsDrives()) {
|
||||
candidates.push(path.join(`${drive}:\\`, 'xwechat_files'))
|
||||
candidates.push(path.join(`${drive}:\\`, 'WeChat Files'))
|
||||
for (const child of Wcdb4Client.listDirectories(`${drive}:\\`)) {
|
||||
candidates.push(path.join(child, 'xwechat_files'))
|
||||
candidates.push(path.join(child, 'WeChat Files'))
|
||||
}
|
||||
}
|
||||
return Array.from(new Set(candidates))
|
||||
}
|
||||
return [path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')]
|
||||
return [
|
||||
path.join(home, 'Library/Containers/com.tencent.xinWeChat/Data/Documents/xwechat_files')
|
||||
]
|
||||
}
|
||||
|
||||
private static getWeflowDbPathCandidates(home: string): string[] {
|
||||
@@ -335,35 +421,11 @@ export class Wcdb4Client {
|
||||
return candidates
|
||||
}
|
||||
|
||||
private static getWindowsDrives(): string[] {
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const drive = String.fromCharCode(code)
|
||||
if (fs.existsSync(`${drive}:\\`)) drives.push(drive)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
private static listDirectories(root: string): string[] {
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(root)
|
||||
.map((name) => path.join(root, name))
|
||||
.filter((candidate) => {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory()
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private static hasDbStorage(candidate: string): boolean {
|
||||
try {
|
||||
return fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
return (
|
||||
fs.statSync(candidate).isDirectory() && fs.existsSync(path.join(candidate, 'db_storage'))
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
@@ -443,6 +505,39 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
async openAsync(): Promise<void> {
|
||||
this.loadNativeLibrary()
|
||||
if (!this.wcdbOpenAccountAsync) {
|
||||
throw new Error('WCDB 4.0 native open async interface unavailable')
|
||||
}
|
||||
|
||||
const handleOut: WcdbHandleOut = [0]
|
||||
const openResult = await new Promise<number>((resolve, reject) => {
|
||||
this.wcdbOpenAccountAsync!.async(
|
||||
this.sessionDbPath,
|
||||
this.key,
|
||||
handleOut,
|
||||
(error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
}
|
||||
)
|
||||
})
|
||||
if (openResult !== 0 || handleOut[0] <= 0) {
|
||||
throw new Error(
|
||||
`wcdb_open_account failed, code=${openResult}; sessionDb=${this.sessionDbPath}; accountRoot=${this.accountRoot}; wxid=${this.wxid}; keyLength=${this.key.length}`
|
||||
)
|
||||
}
|
||||
this.handle = handleOut[0]
|
||||
if (this.wcdbSetMyWxid) {
|
||||
try {
|
||||
this.wcdbSetMyWxid(this.handle, this.wxid)
|
||||
} catch {
|
||||
// Optional helper. Failure does not block message reads.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.stopMonitor()
|
||||
if (this.handle === null || !this.wcdbShutdown) return
|
||||
@@ -620,6 +715,11 @@ export class Wcdb4Client {
|
||||
return this.cachedSessions
|
||||
}
|
||||
|
||||
invalidateSessionCache(): void {
|
||||
this.cachedSessions = null
|
||||
this.cachedChatTables = null
|
||||
}
|
||||
|
||||
getChatTables(): { name: string; db_number: string }[] {
|
||||
if (this.cachedChatTables) return this.cachedChatTables
|
||||
const sessions =
|
||||
@@ -648,10 +748,12 @@ export class Wcdb4Client {
|
||||
try {
|
||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||
if (cursorMessages) {
|
||||
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||
console.log(
|
||||
`[WCDB4] getMessages cursor ok username=${username} rows=${cursorMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
`[WCDB4] getMessages cursor ok username=${username} rows=${mergedMessages.length} recovered=${recoveredMessages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return cursorMessages
|
||||
return mergedMessages
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] cursor messages failed username=${username}:`, error)
|
||||
@@ -680,10 +782,7 @@ export class Wcdb4Client {
|
||||
this.wcdbGetMessages!(handle, username, limit, offset, outJson)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[WCDB4] get_messages failed username=${username} offset=${offset}:`,
|
||||
error
|
||||
)
|
||||
console.warn(`[WCDB4] get_messages failed username=${username} offset=${offset}:`, error)
|
||||
break
|
||||
}
|
||||
const batch = Array.isArray(rows) ? rows : []
|
||||
@@ -710,6 +809,21 @@ export class Wcdb4Client {
|
||||
return messages
|
||||
}
|
||||
|
||||
async getMessagesAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options: Wcdb4MessageQueryOptions = {}
|
||||
): Promise<Wcdb4Message[]> {
|
||||
const startedAt = Date.now()
|
||||
const maxRows = this.normalizeMessageLimit(options.limit)
|
||||
const messages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
|
||||
console.log(
|
||||
`[WCDB4] getMessages async username=${username} rows=${messages.length} cost=${Date.now() - startedAt}ms`
|
||||
)
|
||||
return messages
|
||||
}
|
||||
|
||||
private readSessionRows(): Record<string, unknown>[] {
|
||||
if (!this.wcdbGetSessions) return []
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
@@ -726,17 +840,9 @@ export class Wcdb4Client {
|
||||
): Wcdb4Message[] {
|
||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
||||
|
||||
let tables: { tableName: string; dbPath: string }[] = []
|
||||
let tables: Wcdb4MessageStore[] = []
|
||||
try {
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||
)
|
||||
tables = (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => ({
|
||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||
}))
|
||||
.filter((row) => row.tableName && row.dbPath)
|
||||
tables = this.listMessageStores(username)
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] message table stats failed username=${username}:`, error)
|
||||
return []
|
||||
@@ -771,6 +877,172 @@ export class Wcdb4Client {
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
|
||||
const stores = new Map<string, Wcdb4MessageStore>()
|
||||
for (const username of this.uniq(usernames)) {
|
||||
try {
|
||||
for (const store of this.listMessageStores(username)) {
|
||||
stores.set(`${store.dbPath}\u0000${store.tableName}`, store)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] recall journal table discovery failed username=${username}:`, error)
|
||||
}
|
||||
}
|
||||
|
||||
let installed = 0
|
||||
let failed = 0
|
||||
for (const store of stores.values()) {
|
||||
try {
|
||||
const columns = this.readMessageColumns(store)
|
||||
if (columns.length === 0) throw new Error('消息表没有可归档列')
|
||||
this.ensureRecallJournalTable(store, columns)
|
||||
this.createRecallJournalTrigger(store, columns)
|
||||
installed += 1
|
||||
} catch (error) {
|
||||
failed += 1
|
||||
console.warn(
|
||||
`[WCDB4] recall journal install failed db=${store.dbPath} table=${store.tableName}:`,
|
||||
error
|
||||
)
|
||||
}
|
||||
}
|
||||
return { installed, failed }
|
||||
}
|
||||
|
||||
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
||||
if (!this.wcdbGetMessageTableStats) return []
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
||||
)
|
||||
return (Array.isArray(rows) ? rows : [])
|
||||
.map((row) => ({
|
||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||
dbPath: this.pickString(row, ['db_path', 'dbPath', 'path'])
|
||||
}))
|
||||
.filter((row) => row.tableName && row.dbPath)
|
||||
}
|
||||
|
||||
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
|
||||
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||
this.wcdbExecQuery!(handle, 'message', store.dbPath, sql, outJson)
|
||||
)
|
||||
return Array.isArray(rows) ? rows : []
|
||||
}
|
||||
|
||||
private readMessageColumns(store: Wcdb4MessageStore): { name: string; declaration: string }[] {
|
||||
const rows = this.executeMessageSql(
|
||||
store,
|
||||
`PRAGMA table_info(${this.quoteSqlIdentifier(store.tableName)})`
|
||||
)
|
||||
return rows
|
||||
.map((row) => {
|
||||
const name = this.pickString(row, ['name'])
|
||||
const type = this.pickString(row, ['type']).toUpperCase()
|
||||
const declaration = /^(INTEGER|REAL|TEXT|BLOB|NUMERIC)$/.test(type) ? type : 'BLOB'
|
||||
return { name, declaration }
|
||||
})
|
||||
.filter((column) => column.name)
|
||||
}
|
||||
|
||||
private ensureRecallJournalTable(
|
||||
store: Wcdb4MessageStore,
|
||||
columns: { name: string; declaration: string }[]
|
||||
): void {
|
||||
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||
const definitions = columns
|
||||
.map((column) => `${this.quoteSqlIdentifier(column.name)} ${column.declaration}`)
|
||||
.join(', ')
|
||||
this.executeMessageSql(
|
||||
store,
|
||||
`CREATE TABLE IF NOT EXISTS ${journal} (${definitions}, "_wxe_source_table" TEXT NOT NULL, "_wxe_captured_at" INTEGER NOT NULL)`
|
||||
)
|
||||
}
|
||||
|
||||
private createRecallJournalTrigger(
|
||||
store: Wcdb4MessageStore,
|
||||
columns: { name: string; declaration: string }[]
|
||||
): void {
|
||||
const table = this.quoteSqlIdentifier(store.tableName)
|
||||
const triggerName = this.quoteSqlIdentifier(
|
||||
`_wxe_capture_${crypto.createHash('sha1').update(store.tableName).digest('hex').slice(0, 16)}`
|
||||
)
|
||||
const journal = this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))
|
||||
const targetColumns = [
|
||||
...columns.map((column) => this.quoteSqlIdentifier(column.name)),
|
||||
'"_wxe_source_table"',
|
||||
'"_wxe_captured_at"'
|
||||
].join(', ')
|
||||
const sourceValues = [
|
||||
...columns.map((column) => `OLD.${this.quoteSqlIdentifier(column.name)}`),
|
||||
`'${store.tableName.replace(/'/g, "''")}'`,
|
||||
`CAST(strftime('%s', 'now') AS INTEGER)`
|
||||
].join(', ')
|
||||
this.executeMessageSql(
|
||||
store,
|
||||
`CREATE TRIGGER IF NOT EXISTS ${triggerName} BEFORE DELETE ON ${table} BEGIN INSERT INTO ${journal} (${targetColumns}) VALUES (${sourceValues}); END`
|
||||
)
|
||||
}
|
||||
|
||||
private readRecallJournal(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
): Wcdb4Message[] {
|
||||
const recoveredRows: Record<string, unknown>[] = []
|
||||
for (const store of this.listMessageStores(username)) {
|
||||
try {
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const where = [
|
||||
`"_wxe_source_table" = '${store.tableName.replace(/'/g, "''")}'`,
|
||||
begin > 0 ? `"create_time" >= ${begin}` : '',
|
||||
end > 0 ? `"create_time" <= ${end}` : ''
|
||||
].filter(Boolean)
|
||||
const rows = this.executeMessageSql(
|
||||
store,
|
||||
`SELECT *, 1 AS "_wxe_recovered" FROM ${this.quoteSqlIdentifier(this.recallJournalTableName(store.tableName))} WHERE ${where.join(' AND ')} ORDER BY "create_time" ASC LIMIT 500`
|
||||
)
|
||||
recoveredRows.push(...rows)
|
||||
} catch {
|
||||
// The journal is optional until installation has completed for this store.
|
||||
}
|
||||
}
|
||||
return this.finalizeMessages(username, recoveredRows, startTime, endTime)
|
||||
}
|
||||
|
||||
private mergeMessageRows(
|
||||
current: Wcdb4Message[],
|
||||
recovered: Wcdb4Message[],
|
||||
limit?: number
|
||||
): Wcdb4Message[] {
|
||||
const merged = new Map<string, Wcdb4Message>()
|
||||
for (const message of [...recovered, ...current]) {
|
||||
const recoveredRow = Boolean(message.raw?.['_wxe_recovered'])
|
||||
const identity = message.mesLocalID
|
||||
? `local:${message.mesLocalID}`
|
||||
: message.serverId
|
||||
? `server:${message.serverId}`
|
||||
: `${message.msgCreateTime}:${message.msgContent}`
|
||||
const key = recoveredRow ? `recovered:${identity}` : identity
|
||||
merged.set(key, message)
|
||||
}
|
||||
const messages = Array.from(merged.values()).sort(
|
||||
(left, right) =>
|
||||
Number(left.msgCreateTime || 0) - Number(right.msgCreateTime || 0) ||
|
||||
Number(left.mesLocalID || 0) - Number(right.mesLocalID || 0)
|
||||
)
|
||||
return limit && messages.length > limit ? messages.slice(-limit) : messages
|
||||
}
|
||||
|
||||
private recallJournalTableName(messageTableName: string): string {
|
||||
return `_wxe_recall_journal_${crypto
|
||||
.createHash('sha1')
|
||||
.update(messageTableName)
|
||||
.digest('hex')
|
||||
.slice(0, 16)}`
|
||||
}
|
||||
|
||||
getMyAvatarUrl(): string | undefined {
|
||||
const candidates = this.getMyUsernameCandidates()
|
||||
this.hydrateAvatarUrls(candidates)
|
||||
@@ -888,26 +1160,25 @@ export class Wcdb4Client {
|
||||
|
||||
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
|
||||
|
||||
return visibleMessages
|
||||
.map((message) => {
|
||||
if (!message.sender) return message
|
||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||
const shouldPrefixSender =
|
||||
username.endsWith('@chatroom') &&
|
||||
message.mesDes === 1 &&
|
||||
message.sender &&
|
||||
message.msgContent &&
|
||||
!message.msgContent.startsWith(`${message.sender}:`)
|
||||
return {
|
||||
...message,
|
||||
senderNickname,
|
||||
senderAvatar,
|
||||
msgContent: shouldPrefixSender
|
||||
? `${message.sender}:\n${message.msgContent}`
|
||||
: message.msgContent
|
||||
}
|
||||
})
|
||||
return visibleMessages.map((message) => {
|
||||
if (!message.sender) return message
|
||||
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
|
||||
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
|
||||
const shouldPrefixSender =
|
||||
username.endsWith('@chatroom') &&
|
||||
message.mesDes === 1 &&
|
||||
message.sender &&
|
||||
message.msgContent &&
|
||||
!message.msgContent.startsWith(`${message.sender}:`)
|
||||
return {
|
||||
...message,
|
||||
senderNickname,
|
||||
senderAvatar,
|
||||
msgContent: shouldPrefixSender
|
||||
? `${message.sender}:\n${message.msgContent}`
|
||||
: message.msgContent
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private normalizeMessageLimit(limit?: number): number | undefined {
|
||||
@@ -947,11 +1218,7 @@ export class Wcdb4Client {
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, [
|
||||
'displayName',
|
||||
'display_name',
|
||||
'name'
|
||||
])
|
||||
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
@@ -965,8 +1232,7 @@ export class Wcdb4Client {
|
||||
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname:
|
||||
groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
@@ -998,6 +1264,140 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async getMessagesByCursorAsync(
|
||||
username: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
limit?: number
|
||||
): Promise<Wcdb4Message[]> {
|
||||
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
|
||||
|
||||
const handle = this.ensureHandle()
|
||||
const batchSize = limit ? Math.min(500, limit) : 1000
|
||||
const cursorOut: WcdbHandleOut = [0]
|
||||
const begin = this.normalizeTimestamp(startTime || 0)
|
||||
const end = this.normalizeTimestamp(endTime || 0)
|
||||
const ascending = limit ? 0 : 1
|
||||
const openResult = await this.callAsyncCode(
|
||||
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
|
||||
handle,
|
||||
username,
|
||||
batchSize,
|
||||
ascending,
|
||||
begin,
|
||||
end,
|
||||
cursorOut
|
||||
)
|
||||
if (openResult !== 0 || cursorOut[0] <= 0) return []
|
||||
|
||||
const cursor = cursorOut[0]
|
||||
const allRows: Record<string, unknown>[] = []
|
||||
try {
|
||||
while (true) {
|
||||
const outJson: WcdbVoidOut = [null]
|
||||
const outHasMore: [number] = [0]
|
||||
const fetchResult = await this.callAsyncCode(
|
||||
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
|
||||
handle,
|
||||
cursor,
|
||||
outJson,
|
||||
outHasMore
|
||||
)
|
||||
if (fetchResult !== 0 || !outJson[0]) break
|
||||
try {
|
||||
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||
const batch = JSON.parse(json) as Record<string, unknown>[]
|
||||
if (Array.isArray(batch)) allRows.push(...batch)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outJson[0])
|
||||
}
|
||||
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
this.wcdbCloseMessageCursor?.(handle, cursor)
|
||||
} catch {
|
||||
// Best-effort cursor cleanup.
|
||||
}
|
||||
}
|
||||
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
|
||||
}
|
||||
|
||||
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
|
||||
if (!this.wcdbGetGroupMembers || !chatroomId) return []
|
||||
|
||||
try {
|
||||
const groupNicknames = await this.getGroupNicknamesAsync(chatroomId)
|
||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
||||
this.wcdbGetGroupMembers as unknown as KoffiAsyncFunction,
|
||||
chatroomId
|
||||
)
|
||||
const members = (Array.isArray(rows) ? rows : []).map((row) => {
|
||||
const username = this.pickString(row, [
|
||||
'username',
|
||||
'userName',
|
||||
'user_name',
|
||||
'member_username',
|
||||
'm_nsUsrName'
|
||||
])
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'm_nsNickName'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
const memberNickname = this.pickString(row, ['displayName', 'display_name', 'name'])
|
||||
const avatar = this.pickString(row, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl'
|
||||
])
|
||||
if (username && avatar) this.avatarCache.set(username, avatar)
|
||||
return {
|
||||
m_nsUsrName: username,
|
||||
nickname: groupNicknames.get(username) || remark || wechatNickname || memberNickname,
|
||||
groupNickname: groupNicknames.get(username) || '',
|
||||
wechatNickname: wechatNickname || memberNickname,
|
||||
remark,
|
||||
m_nsHeadImgUrl: avatar
|
||||
}
|
||||
})
|
||||
|
||||
const missingNames = members
|
||||
.filter((member) => !member.nickname)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
const missingAvatars = members
|
||||
.filter((member) => !member.m_nsHeadImgUrl)
|
||||
.map((member) => member.m_nsUsrName)
|
||||
.filter(Boolean)
|
||||
await Promise.all([
|
||||
this.hydrateDisplayNamesAsync(missingNames),
|
||||
this.hydrateAvatarUrlsAsync(missingAvatars)
|
||||
])
|
||||
return members.map((member) => ({
|
||||
...member,
|
||||
nickname:
|
||||
member.nickname || this.displayNameCache.get(member.m_nsUsrName) || member.m_nsUsrName,
|
||||
wechatNickname:
|
||||
member.wechatNickname || this.displayNameCache.get(member.m_nsUsrName) || '',
|
||||
m_nsHeadImgUrl: member.m_nsHeadImgUrl || this.avatarCache.get(member.m_nsUsrName) || ''
|
||||
}))
|
||||
} catch (error) {
|
||||
console.warn(`[WCDB4] async group members failed chatroom=${chatroomId}:`, error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getGroupNicknames(chatroomId: string): Map<string, string> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
@@ -1026,6 +1426,28 @@ export class Wcdb4Client {
|
||||
return nicknames
|
||||
}
|
||||
|
||||
private async getGroupNicknamesAsync(chatroomId: string): Promise<Map<string, string>> {
|
||||
const cached = this.groupNicknameCache.get(chatroomId)
|
||||
if (cached) return cached
|
||||
const nicknames = new Map<string, string>()
|
||||
if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames
|
||||
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId)
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'nickName',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'groupNickname',
|
||||
'group_nickname',
|
||||
'name'
|
||||
]).forEach((nickname, username) => nicknames.set(username, nickname))
|
||||
this.groupNicknameCache.set(chatroomId, nicknames)
|
||||
return nicknames
|
||||
}
|
||||
|
||||
async getVoiceData(
|
||||
sessionId: string,
|
||||
createTime: number,
|
||||
@@ -1105,6 +1527,23 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
resolveVideoHardlink(md5: string, dbPath: string): Wcdb4VideoHardlink | null {
|
||||
if (!this.wcdbResolveVideoHardlink) return null
|
||||
const normalizedMd5 = String(md5 || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (!/^[a-f0-9]{32}$/.test(normalizedMd5) || !dbPath) return null
|
||||
|
||||
try {
|
||||
return this.callJson<Wcdb4VideoHardlink>((handle, outJson) =>
|
||||
this.wcdbResolveVideoHardlink!(handle, normalizedMd5, dbPath, outJson)
|
||||
)
|
||||
} catch (error) {
|
||||
console.warn('[WCDB4] resolve video hardlink failed:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
resolveEmoticonCdnUrl(md5: string): string | undefined {
|
||||
if (!this.wcdbGetEmoticonCdnUrl) {
|
||||
console.warn(`[WCDB4] wcdb_get_emoticon_cdn_url unavailable for md5=${md5}`)
|
||||
@@ -1196,9 +1635,11 @@ export class Wcdb4Client {
|
||||
}
|
||||
|
||||
this.wcdbShutdown = lib.func('int32 wcdb_shutdown()') as () => number
|
||||
this.wcdbOpenAccount = lib.func(
|
||||
const openAccount = lib.func(
|
||||
'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)'
|
||||
) as (sessionDbPath: string, key: string, handleOut: WcdbHandleOut) => number
|
||||
this.wcdbOpenAccount = openAccount
|
||||
this.wcdbOpenAccountAsync = openAccount as unknown as KoffiAsyncFunction
|
||||
this.wcdbFreeString = lib.func('void wcdb_free_string(void* ptr)') as (ptr: unknown) => void
|
||||
this.wcdbGetSessions = lib.func(
|
||||
'int32 wcdb_get_sessions(int64 handle, _Out_ void** outJson)'
|
||||
@@ -1318,6 +1759,14 @@ export class Wcdb4Client {
|
||||
this.wcdbResolveImageHardlink = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbResolveVideoHardlink = lib.func(
|
||||
'int32 wcdb_resolve_video_hardlink_md5(int64 handle, const char* md5, const char* dbPath, _Out_ void** outJson)'
|
||||
) as (handle: number, md5: string, dbPath: string, outJson: WcdbVoidOut) => number
|
||||
} catch {
|
||||
this.wcdbResolveVideoHardlink = null
|
||||
}
|
||||
|
||||
try {
|
||||
this.wcdbGetEmoticonCdnUrl = lib.func(
|
||||
'int32 wcdb_get_emoticon_cdn_url(int64 handle, const char* dbPath, const char* md5, _Out_ void** outUrl)'
|
||||
@@ -1480,6 +1929,41 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private callJsonAsync<T>(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<T> {
|
||||
const handle = this.ensureHandle()
|
||||
const outJson: WcdbVoidOut = [null]
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
fn.async(handle, ...args, outJson, (error: unknown, result: unknown) => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
const code = Number(result)
|
||||
if (code !== 0 || !outJson[0]) {
|
||||
reject(new Error(`WCDB async call failed, code: ${code}`))
|
||||
return
|
||||
}
|
||||
try {
|
||||
const json = this.koffi!.decode(outJson[0], 'char', -1)
|
||||
resolve(JSON.parse(json) as T)
|
||||
} catch (decodeError) {
|
||||
reject(decodeError)
|
||||
} finally {
|
||||
this.wcdbFreeString?.(outJson[0])
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private callAsyncCode(fn: KoffiAsyncFunction, ...args: unknown[]): Promise<number> {
|
||||
return new Promise<number>((resolve, reject) => {
|
||||
fn.async(...args, (error: unknown, result: unknown) => {
|
||||
if (error) reject(error)
|
||||
else resolve(Number(result))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private ensureHandle(): number {
|
||||
if (!this.handle) throw new Error('微信 4.0 数据库未打开')
|
||||
return this.handle
|
||||
@@ -1506,7 +1990,21 @@ export class Wcdb4Client {
|
||||
'remark',
|
||||
'name'
|
||||
])
|
||||
return { username, nickname, raw: row }
|
||||
const wechatNickname = this.pickString(row, [
|
||||
'wechatNickname',
|
||||
'wechat_nickname',
|
||||
'nickname',
|
||||
'nickName',
|
||||
'name'
|
||||
])
|
||||
const remark = this.pickString(row, [
|
||||
'remark',
|
||||
'remarkName',
|
||||
'remark_name',
|
||||
'contactRemark',
|
||||
'contact_remark'
|
||||
])
|
||||
return { username, nickname, wechatNickname, remark, raw: row }
|
||||
}
|
||||
|
||||
private normalizeMessage(row: Record<string, unknown>): Wcdb4Message {
|
||||
@@ -1552,6 +2050,19 @@ export class Wcdb4Client {
|
||||
'mesLocalID',
|
||||
'id'
|
||||
])
|
||||
const serverId = this.pickString(row, [
|
||||
'server_id',
|
||||
'serverId',
|
||||
'svr_id',
|
||||
'svrId',
|
||||
'msg_svr_id',
|
||||
'msgSvrId',
|
||||
'message_id',
|
||||
'messageId',
|
||||
'new_msg_id',
|
||||
'newMsgId',
|
||||
'WCDB_CT_server_id'
|
||||
])
|
||||
const messageType = this.pickString(row, [
|
||||
'local_type',
|
||||
'localType',
|
||||
@@ -1573,6 +2084,7 @@ export class Wcdb4Client {
|
||||
|
||||
return {
|
||||
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||
serverId: serverId || undefined,
|
||||
mesDes: isSend ? 0 : 1,
|
||||
messageType: messageType || '1',
|
||||
msgCreateTime: String(createTime),
|
||||
@@ -1616,6 +2128,26 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateDisplayNamesAsync(usernames: string[]): Promise<void> {
|
||||
if (!this.wcdbGetDisplayNames) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
this.readStringMap(rows, [
|
||||
'nickname',
|
||||
'displayName',
|
||||
'display_name',
|
||||
'remark',
|
||||
'name'
|
||||
]).forEach((name, username) => this.displayNameCache.set(username, name))
|
||||
} catch {
|
||||
// Names are optional; usernames remain usable.
|
||||
}
|
||||
}
|
||||
|
||||
private hydrateAvatarUrls(usernames: string[]): void {
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
@@ -1650,6 +2182,27 @@ export class Wcdb4Client {
|
||||
}
|
||||
}
|
||||
|
||||
private async hydrateAvatarUrlsAsync(usernames: string[]): Promise<void> {
|
||||
if (!this.wcdbGetAvatarUrls) return
|
||||
const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username))
|
||||
if (missing.length === 0) return
|
||||
try {
|
||||
const rows = await this.callJsonAsync<
|
||||
Record<string, string> | Record<string, unknown>[]
|
||||
>(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing))
|
||||
this.readStringMap(rows, [
|
||||
'avatarUrl',
|
||||
'avatar_url',
|
||||
'headImgUrl',
|
||||
'm_nsHeadImgUrl',
|
||||
'big_head_img_url',
|
||||
'small_head_img_url'
|
||||
]).forEach((avatar, username) => this.avatarCache.set(username, avatar))
|
||||
} catch {
|
||||
// Avatars are optional.
|
||||
}
|
||||
}
|
||||
|
||||
private readContactAvatarUrls(usernames: string[]): Map<string, string> {
|
||||
const result = new Map<string, string>()
|
||||
if (!this.wcdbExecQuery || usernames.length === 0) return result
|
||||
|
||||
@@ -4,6 +4,8 @@ export interface UserContact {
|
||||
m_nsUsrName: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface WechatMessage {
|
||||
@@ -33,20 +35,12 @@ export interface GroupMemberInfo {
|
||||
export class WechatDb {
|
||||
private wcdb4Client: Wcdb4Client
|
||||
private chatMd5ToUsername = new Map<string, string>()
|
||||
private chatTableMappingLoaded = false
|
||||
|
||||
static async create(rawKey: string, accountRoot?: string): Promise<WechatDb> {
|
||||
// WCDB native init must run on the Electron main process; worker threads
|
||||
// get -1006 from wcdb_init. Initialize the client synchronously here
|
||||
// (and keep create() async for callers that already await it).
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
client.open()
|
||||
resolve(new WechatDb(rawKey, accountRoot, client))
|
||||
} catch (error) {
|
||||
reject(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
const client = new Wcdb4Client(rawKey, accountRoot)
|
||||
await client.openAsync()
|
||||
return new WechatDb(rawKey, accountRoot, client)
|
||||
}
|
||||
|
||||
constructor(
|
||||
@@ -56,15 +50,25 @@ export class WechatDb {
|
||||
initialChatTables?: { name: string; db_number: string }[]
|
||||
) {
|
||||
console.log(`Initializing WechatDb with key length: ${rawKey.trim().length}`)
|
||||
const client =
|
||||
clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
const client = clientOverride || new Wcdb4Client(rawKey, accountRoot)
|
||||
if (!clientOverride) client.open()
|
||||
this.wcdb4Client = client
|
||||
for (const table of initialChatTables || client.getChatTables()) {
|
||||
for (const table of initialChatTables || []) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
this.chatTableMappingLoaded = Boolean(initialChatTables)
|
||||
}
|
||||
|
||||
private ensureChatTableMapping(): void {
|
||||
if (this.chatTableMappingLoaded) return
|
||||
for (const table of this.wcdb4Client.getChatTables()) {
|
||||
if (table.name.startsWith('Chat_')) {
|
||||
this.chatMd5ToUsername.set(table.name.substring(5), table.db_number)
|
||||
}
|
||||
}
|
||||
this.chatTableMappingLoaded = true
|
||||
}
|
||||
|
||||
public getUserList(nicknameFilter?: string): UserContact[] {
|
||||
@@ -74,7 +78,9 @@ export class WechatDb {
|
||||
.map((session) => ({
|
||||
m_nsUsrName: session.username,
|
||||
nickname: session.nickname || session.username,
|
||||
avatar: session.avatar
|
||||
avatar: session.avatar,
|
||||
wechatNickname: session.wechatNickname,
|
||||
remark: session.remark
|
||||
}))
|
||||
.filter((contact) => {
|
||||
if (!keyword) return true
|
||||
@@ -109,6 +115,7 @@ export class WechatDb {
|
||||
}
|
||||
|
||||
public getGroupMembersForChat(userMd5: string): Record<string, string> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username || !username.endsWith('@chatroom')) return {}
|
||||
|
||||
@@ -124,9 +131,8 @@ export class WechatDb {
|
||||
public getGroupMember(wxid: string, chatroomId?: string): GroupMemberInfo | null {
|
||||
if (!chatroomId) return null
|
||||
return (
|
||||
this.wcdb4Client
|
||||
.getGroupMembers(chatroomId)
|
||||
.find((member) => member.m_nsUsrName === wxid) || null
|
||||
this.wcdb4Client.getGroupMembers(chatroomId).find((member) => member.m_nsUsrName === wxid) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -152,6 +158,7 @@ export class WechatDb {
|
||||
endTime?: number,
|
||||
options?: Wcdb4MessageQueryOptions
|
||||
): WechatMessage[] {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
return this.wcdb4Client.getMessages(username, startTime, endTime, options).map((message) => ({
|
||||
@@ -160,6 +167,24 @@ export class WechatDb {
|
||||
}))
|
||||
}
|
||||
|
||||
public async getUserMessagesAsync(
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number,
|
||||
options?: Wcdb4MessageQueryOptions
|
||||
): Promise<WechatMessage[]> {
|
||||
this.ensureChatTableMapping()
|
||||
const username = this.chatMd5ToUsername.get(userMd5)
|
||||
if (!username) return []
|
||||
const messages = await this.wcdb4Client.getMessagesAsync(
|
||||
username,
|
||||
startTime,
|
||||
endTime,
|
||||
options
|
||||
)
|
||||
return messages.map((message) => ({ ...message, ...message.raw }))
|
||||
}
|
||||
|
||||
public searchAllMessages(keyword: string): string | null {
|
||||
const lowerKeyword = keyword.trim().toLowerCase()
|
||||
if (!lowerKeyword) return null
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'fs-extra'
|
||||
import path from 'path'
|
||||
|
||||
// 仅支持 WeChat 4.0:只扫描 xwechat_files;旧 V3 时代的 "WeChat Files" 不再纳入候选
|
||||
const DB_ROOT_NAMES = new Set(['xwechat_files'])
|
||||
const SKIPPED_DIRECTORY_NAMES = new Set([
|
||||
'$recycle.bin',
|
||||
'system volume information',
|
||||
'windows',
|
||||
'program files',
|
||||
'program files (x86)',
|
||||
'programdata',
|
||||
'recovery'
|
||||
])
|
||||
const MAX_VISITED_DIRECTORIES_PER_DRIVE = 20_000
|
||||
let cachedDiscoveredRoots: string[] | null = null
|
||||
|
||||
function unique(values: string[]): string[] {
|
||||
return Array.from(new Set(values.map((value) => path.normalize(value))))
|
||||
}
|
||||
|
||||
export function getWindowsDrives(): string[] {
|
||||
if (process.platform !== 'win32') return []
|
||||
const drives: string[] = []
|
||||
for (let code = 67; code <= 90; code += 1) {
|
||||
const root = `${String.fromCharCode(code)}:\\`
|
||||
if (fs.existsSync(root)) drives.push(root)
|
||||
}
|
||||
return drives
|
||||
}
|
||||
|
||||
export function scanWindowsDbRoots(driveRoots: string[], maxDepth = 3): string[] {
|
||||
const results: string[] = []
|
||||
|
||||
for (const driveRoot of driveRoots) {
|
||||
const queue: Array<{ directory: string; depth: number }> = [{ directory: driveRoot, depth: 0 }]
|
||||
let visited = 0
|
||||
|
||||
while (queue.length > 0 && visited < MAX_VISITED_DIRECTORIES_PER_DRIVE) {
|
||||
const current = queue.shift()
|
||||
if (!current || current.depth >= maxDepth) continue
|
||||
|
||||
let entries: fs.Dirent[]
|
||||
try {
|
||||
entries = fs.readdirSync(current.directory, { withFileTypes: true })
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isDirectory() || entry.isSymbolicLink()) continue
|
||||
const lowered = entry.name.toLowerCase()
|
||||
const fullPath = path.join(current.directory, entry.name)
|
||||
const depth = current.depth + 1
|
||||
visited += 1
|
||||
|
||||
if (DB_ROOT_NAMES.has(lowered)) {
|
||||
results.push(fullPath)
|
||||
continue
|
||||
}
|
||||
if (depth < maxDepth && !SKIPPED_DIRECTORY_NAMES.has(lowered)) {
|
||||
queue.push({ directory: fullPath, depth })
|
||||
}
|
||||
if (visited >= MAX_VISITED_DIRECTORIES_PER_DRIVE) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique(results)
|
||||
}
|
||||
|
||||
export function discoverWindowsDbRoots(): string[] {
|
||||
if (!cachedDiscoveredRoots) {
|
||||
cachedDiscoveredRoots = scanWindowsDbRoots(getWindowsDrives(), 3)
|
||||
}
|
||||
return [...cachedDiscoveredRoots]
|
||||
}
|
||||
@@ -38,15 +38,44 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubActionResult, AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { ExportRequest, ExportJobProgress, ExportResult } from '../shared/export'
|
||||
|
||||
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: 'share'
|
||||
title: string
|
||||
des?: string
|
||||
url: string
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
| {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
description?: string
|
||||
appName?: string
|
||||
iconUrl?: string
|
||||
thumbMd5?: string
|
||||
thumbDatName?: string
|
||||
thumbDataUrl?: string
|
||||
}
|
||||
| { type: 'redPacket'; title: string; description?: string; url?: string }
|
||||
| { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
| { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number }
|
||||
| {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
| {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -71,6 +100,9 @@ declare global {
|
||||
interface Window {
|
||||
electron: ElectronAPI
|
||||
api: {
|
||||
writeAppLog: (entry: AppLogEntry) => Promise<void>
|
||||
getAppLogPath: () => Promise<string>
|
||||
revealAppLog: () => Promise<void>
|
||||
initDb: (
|
||||
key: string
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
@@ -79,6 +111,11 @@ declare global {
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null>
|
||||
getStartupCache: () => Promise<{
|
||||
self?: { wxid: string; nickname: string; avatar?: string; accountRoot: string }
|
||||
contacts: Contact[]
|
||||
updatedAt: number
|
||||
} | null>
|
||||
getContacts: (filter?: string) => Promise<Contact[]>
|
||||
getContactAvatars: (usernames: string[]) => Promise<Record<string, string>>
|
||||
getCachedMessages: (
|
||||
@@ -86,6 +123,26 @@ declare global {
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
) => Promise<Message[]>
|
||||
getCachedMessagePage: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
endTime?: number
|
||||
) => Promise<{
|
||||
hit: boolean
|
||||
messages: Message[]
|
||||
groupSnapshot?: {
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}[]
|
||||
}
|
||||
}>
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
@@ -139,7 +196,7 @@ declare global {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => Promise<{
|
||||
success: boolean
|
||||
data?: string
|
||||
@@ -147,10 +204,17 @@ declare global {
|
||||
isThumb?: boolean
|
||||
filePath?: string
|
||||
}>
|
||||
getVideo: (
|
||||
hashes: string[]
|
||||
) => Promise<{ success: boolean; url?: string; poster?: string; error?: string }>
|
||||
getSticker: (
|
||||
cdnUrl?: string,
|
||||
md5?: string
|
||||
) => Promise<{ success: boolean; data?: string; error?: string }>
|
||||
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
|
||||
exportGroupReport: (request: GroupReportExportRequest) => Promise<GroupReportExportResult>
|
||||
listGeneratedReports: () => Promise<ReportHistoryResult>
|
||||
saveGeneratedReport: (
|
||||
@@ -187,6 +251,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
@@ -213,6 +279,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
@@ -227,6 +295,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
@@ -239,6 +309,8 @@ declare global {
|
||||
apiHost: string
|
||||
apiPort: number
|
||||
imageKeyRoot: string
|
||||
recallProtectionEnabled: boolean
|
||||
debugEnabled: boolean
|
||||
autoLogin: boolean
|
||||
autoLoginPreferenceSet: boolean
|
||||
imageXorKey: string
|
||||
@@ -261,7 +333,9 @@ declare global {
|
||||
}>
|
||||
selectDbRoot: () => Promise<{ canceled: boolean; path?: string }>
|
||||
openAccountRoot: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: () => Promise<{ success: boolean; error?: string }>
|
||||
disconnectDb: (options?: {
|
||||
closeNative?: boolean
|
||||
}) => Promise<{ success: boolean; error?: string }>
|
||||
apiStatus: () => Promise<{
|
||||
running: boolean
|
||||
host: string
|
||||
|
||||
@@ -16,15 +16,23 @@ import type {
|
||||
ImageInsight
|
||||
} from '../shared/image-insight'
|
||||
import type { AgentHubLogEntry, AgentHubStatus } from '../shared/agent-hub'
|
||||
import type { AppLogEntry } from '../shared/app-log'
|
||||
import type { ExportRequest, ExportJobProgress } from '../shared/export'
|
||||
|
||||
// 渲染器的自定义 API
|
||||
const api = {
|
||||
writeAppLog: (entry: AppLogEntry) => ipcRenderer.invoke('app-log:write', entry),
|
||||
getAppLogPath: () => ipcRenderer.invoke('app-log:getPath'),
|
||||
revealAppLog: () => ipcRenderer.invoke('app-log:reveal'),
|
||||
initDb: (key: string) => ipcRenderer.invoke('db:init', key),
|
||||
getBootstrapCache: () => ipcRenderer.invoke('db:getBootstrapCache'),
|
||||
getStartupCache: () => ipcRenderer.invoke('db:getStartupCache'),
|
||||
getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter),
|
||||
getContactAvatars: (usernames: string[]) => ipcRenderer.invoke('db:getContactAvatars', usernames),
|
||||
getCachedMessages: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getCachedMessages', userMd5, startTime, endTime),
|
||||
getCachedMessagePage: (userMd5: string, startTime?: number, endTime?: number) =>
|
||||
ipcRenderer.invoke('db:getCachedMessagePage', userMd5, startTime, endTime),
|
||||
getMessages: (
|
||||
userMd5: string,
|
||||
startTime?: number,
|
||||
@@ -53,9 +61,19 @@ const api = {
|
||||
imageMd5?: string,
|
||||
imageDatNameOrThumb?: string | boolean,
|
||||
sessionId?: string,
|
||||
options?: { force?: boolean }
|
||||
options?: { force?: boolean; preferThumbnail?: boolean }
|
||||
) => ipcRenderer.invoke('db:getImage', imageMd5, imageDatNameOrThumb, sessionId, options),
|
||||
getVideo: (hashes: string[]) => ipcRenderer.invoke('db:getVideo', hashes),
|
||||
getSticker: (cdnUrl?: string, md5?: string) => ipcRenderer.invoke('db:getSticker', cdnUrl, md5),
|
||||
startExport: (request: ExportRequest) => ipcRenderer.invoke('export:start', request),
|
||||
cancelExport: (jobId: string) => ipcRenderer.invoke('export:cancel', jobId),
|
||||
revealExport: (path: string) => ipcRenderer.invoke('export:reveal', path),
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, progress: ExportJobProgress): void =>
|
||||
callback(progress)
|
||||
ipcRenderer.on('export:progress', listener)
|
||||
return () => ipcRenderer.removeListener('export:progress', listener)
|
||||
},
|
||||
exportGroupReport: (request: GroupReportExportRequest) =>
|
||||
ipcRenderer.invoke('report:export', request),
|
||||
listGeneratedReports: () => ipcRenderer.invoke('report:listGenerated'),
|
||||
@@ -106,7 +124,8 @@ const api = {
|
||||
reopenWithRoot: (accountRoot: string) => ipcRenderer.invoke('db:reopenWithRoot', accountRoot),
|
||||
selectDbRoot: () => ipcRenderer.invoke('settings:selectDbRoot'),
|
||||
openAccountRoot: () => ipcRenderer.invoke('settings:openAccountRoot'),
|
||||
disconnectDb: () => ipcRenderer.invoke('db:disconnect'),
|
||||
disconnectDb: (options?: { closeNative?: boolean }) =>
|
||||
ipcRenderer.invoke('db:disconnect', options),
|
||||
apiStatus: () => ipcRenderer.invoke('api:getStatus'),
|
||||
apiStart: (host?: string, port?: number) => ipcRenderer.invoke('api:start', host, port),
|
||||
apiStop: () => ipcRenderer.invoke('api:stop'),
|
||||
@@ -120,7 +139,9 @@ const api = {
|
||||
// ============================================================
|
||||
// AI 图片理解基础设施(ImageInsightService)
|
||||
// ============================================================
|
||||
imageListCandidates: (query: ImageCandidateQuery): Promise<{
|
||||
imageListCandidates: (
|
||||
query: ImageCandidateQuery
|
||||
): Promise<{
|
||||
success: boolean
|
||||
candidates: ImageCandidate[]
|
||||
error?: string
|
||||
|
||||
@@ -7,7 +7,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:; media-src 'self' blob: data:;"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: wxe-media:; media-src 'self' blob: data: wxe-media:;"
|
||||
/>
|
||||
</head>
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { ChatHeader } from './chat/ChatHeader'
|
||||
import { ChatStatusBar } from './chat/ChatStatusBar'
|
||||
import { DataTrustBar } from './chat/DataTrustBar'
|
||||
import { EmptyConversationState } from './chat/EmptyConversationState'
|
||||
import { ExportRange } from './chat/ExportMenu'
|
||||
import { MessageList } from './chat/MessageList'
|
||||
|
||||
interface ChatWindowProps {
|
||||
@@ -12,51 +11,14 @@ interface ChatWindowProps {
|
||||
messages: Message[]
|
||||
isLoadingMessages?: boolean
|
||||
contentFilter?: string
|
||||
dateRange?: string
|
||||
onContentFilterChange?: (keyword: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onReloadAvatars?: () => Promise<void>
|
||||
onLoadOlderMessages?: () => Promise<void>
|
||||
onCreateGroupReport?: () => void
|
||||
isAiLoading?: boolean
|
||||
}
|
||||
|
||||
const MAX_RENDERED_MESSAGES = 600
|
||||
const DATE_RANGE_LABELS: Record<string, string> = {
|
||||
today: '今天',
|
||||
yesterday: '昨日',
|
||||
'7': '7 天',
|
||||
'30': '30 天',
|
||||
all: '全部'
|
||||
}
|
||||
|
||||
const formatClock = (date: Date): string =>
|
||||
`${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`
|
||||
|
||||
const formatRangeDate = (date: Date, now: Date): string => {
|
||||
const clock = formatClock(date)
|
||||
if (date.getFullYear() === now.getFullYear()) {
|
||||
return `${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
return `${date.getFullYear()} 年 ${date.getMonth() + 1} 月 ${date.getDate()} 日 ${clock}`
|
||||
}
|
||||
|
||||
const getChatHeaderRangeLabel = (range: string): string => {
|
||||
const now = new Date()
|
||||
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const endOfYesterday = new Date(startOfToday.getTime() - 60_000)
|
||||
|
||||
if (range === 'today') return `今天 00:00—现在`
|
||||
if (range === 'yesterday') return `昨天 00:00—${formatClock(endOfYesterday)}`
|
||||
if (range === '7') {
|
||||
const start = new Date(Date.now() - 7 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === '30') {
|
||||
const start = new Date(Date.now() - 30 * 86400000)
|
||||
return `${formatRangeDate(start, now)}—现在`
|
||||
}
|
||||
if (range === 'all') return '全部记录'
|
||||
return DATE_RANGE_LABELS[range] || '当前范围'
|
||||
jumpToTime?: number | null
|
||||
}
|
||||
|
||||
const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
@@ -64,12 +26,14 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
messages,
|
||||
isLoadingMessages,
|
||||
contentFilter,
|
||||
dateRange = 'today',
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onReloadAvatars,
|
||||
onLoadOlderMessages,
|
||||
onCreateGroupReport,
|
||||
isAiLoading = false
|
||||
isAiLoading = false,
|
||||
jumpToTime
|
||||
}) => {
|
||||
const isGroupChat = Boolean(
|
||||
contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom')
|
||||
@@ -86,6 +50,8 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
)
|
||||
const [showAvatar, setShowAvatar] = useState(true)
|
||||
const [isAtLatest, setIsAtLatest] = useState(true)
|
||||
const [isReloadingAvatars, setIsReloadingAvatars] = useState(false)
|
||||
const previousScrollTopRef = useRef(0)
|
||||
|
||||
const scrollToBottom = useCallback((): void => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'auto' })
|
||||
@@ -95,13 +61,37 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
const handleMessageListScroll = useCallback((event: React.UIEvent<HTMLDivElement>): void => {
|
||||
const target = event.currentTarget
|
||||
const distanceToBottom = target.scrollHeight - target.scrollTop - target.clientHeight
|
||||
setIsAtLatest(distanceToBottom <= 24)
|
||||
if (distanceToBottom <= 24) {
|
||||
setIsAtLatest(true)
|
||||
} else if (target.scrollTop < previousScrollTopRef.current - 1) {
|
||||
setIsAtLatest(false)
|
||||
}
|
||||
previousScrollTopRef.current = target.scrollTop
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
previousScrollTopRef.current = 0
|
||||
setIsAtLatest(true)
|
||||
}, [contact?.md5])
|
||||
|
||||
useEffect(() => {
|
||||
if (jumpToTime !== undefined && jumpToTime !== null) setIsAtLatest(false)
|
||||
}, [jumpToTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||
const frame = window.requestAnimationFrame(() => scrollToBottom())
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [messages, scrollToBottom])
|
||||
}, [isAtLatest, jumpToTime, messages, scrollToBottom])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAtLatest || (jumpToTime !== undefined && jumpToTime !== null)) return
|
||||
const content = messageListRef.current?.querySelector('.virtual-message-list')
|
||||
if (!content) return
|
||||
const observer = new ResizeObserver(() => scrollToBottom())
|
||||
observer.observe(content)
|
||||
return () => observer.disconnect()
|
||||
}, [contact?.md5, isAtLatest, jumpToTime, scrollToBottom])
|
||||
|
||||
const openImagePreview = (imageUrl: string): void => {
|
||||
setPreviewImage(imageUrl)
|
||||
@@ -154,6 +144,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
imageDragRef.current = null
|
||||
}
|
||||
|
||||
const handleReloadAvatars = async (): Promise<void> => {
|
||||
if (!onReloadAvatars || isReloadingAvatars) return
|
||||
setIsReloadingAvatars(true)
|
||||
try {
|
||||
await onReloadAvatars()
|
||||
} finally {
|
||||
setIsReloadingAvatars(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewImage) return
|
||||
|
||||
@@ -172,65 +172,6 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
}
|
||||
}, [previewImage])
|
||||
|
||||
const handleExport = (days: ExportRange): void => {
|
||||
if (!messages.length) return
|
||||
|
||||
let filtered = messages
|
||||
if (days !== 'all') {
|
||||
const now = new Date()
|
||||
const startOfDay = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime()
|
||||
|
||||
filtered = messages.filter((m) => {
|
||||
const parsed = new Date(m.datetime).getTime()
|
||||
if (isNaN(parsed)) return true
|
||||
|
||||
if (days === 0) {
|
||||
// 今天
|
||||
return parsed >= startOfDay
|
||||
} else if (days === 1) {
|
||||
// 昨天
|
||||
const startOfYesterday = startOfDay - 86400000
|
||||
return parsed >= startOfYesterday && parsed < startOfDay
|
||||
} 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
|
||||
})
|
||||
}
|
||||
|
||||
const headers = ['发送者', '类型', '时间', '内容']
|
||||
const csvContent = [
|
||||
headers.join(','),
|
||||
...filtered.map((m) => {
|
||||
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')
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.setAttribute('download', `${contact?.m_nsNickName || 'chat'}_export.csv`)
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
}
|
||||
|
||||
const filteredMessages = React.useMemo(() => {
|
||||
return messages.filter((msg) => {
|
||||
const filterTypes = (import.meta.env.VITE_FILTER_MSG_TYPES || '')
|
||||
@@ -242,51 +183,44 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
return typeMatch && contentMatch
|
||||
})
|
||||
}, [messages, contentFilter])
|
||||
const hiddenMessageCount = Math.max(0, filteredMessages.length - MAX_RENDERED_MESSAGES)
|
||||
const renderedMessages = React.useMemo(
|
||||
() => filteredMessages.slice(-MAX_RENDERED_MESSAGES),
|
||||
[filteredMessages]
|
||||
)
|
||||
|
||||
if (!contact) return <EmptyConversationState />
|
||||
|
||||
const dateRangeLabel = getChatHeaderRangeLabel(dateRange)
|
||||
|
||||
return (
|
||||
<div className="chat-window">
|
||||
<ChatHeader
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
dateRangeLabel={dateRangeLabel}
|
||||
loadedCount={messages.length}
|
||||
filteredCount={filteredMessages.length}
|
||||
contentFilter={contentFilter || ''}
|
||||
isAiLoading={isAiLoading}
|
||||
canExport={messages.length > 0}
|
||||
onContentFilterChange={onContentFilterChange || (() => undefined)}
|
||||
onRefresh={onRefresh}
|
||||
onRefreshData={onRefreshData}
|
||||
onExport={handleExport}
|
||||
onOpenAiSettings={onCreateGroupReport || (() => undefined)}
|
||||
/>
|
||||
<DataTrustBar messageCount={messages.length} />
|
||||
<MessageList
|
||||
contact={contact}
|
||||
messages={renderedMessages}
|
||||
hiddenMessageCount={hiddenMessageCount}
|
||||
messages={filteredMessages}
|
||||
hiddenMessageCount={0}
|
||||
isLoadingMessages={isLoadingMessages}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
listRef={messageListRef}
|
||||
bottomRef={messagesEndRef}
|
||||
onScroll={handleMessageListScroll}
|
||||
onReachTop={onLoadOlderMessages}
|
||||
onImageClick={openImagePreview}
|
||||
jumpToTime={jumpToTime}
|
||||
/>
|
||||
<ChatStatusBar
|
||||
count={renderedMessages.length}
|
||||
count={filteredMessages.length}
|
||||
showAvatar={showAvatar}
|
||||
isAtLatest={isAtLatest}
|
||||
isReloadingAvatars={isReloadingAvatars}
|
||||
onShowAvatarChange={setShowAvatar}
|
||||
onReloadAvatars={() => void handleReloadAvatars()}
|
||||
onJumpToLatest={scrollToBottom}
|
||||
/>
|
||||
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import React from 'react'
|
||||
|
||||
export type DatabaseConnectionMode = 'automatic' | 'manual'
|
||||
export type DatabaseConnectionStatusKind = 'normal' | 'success' | 'error'
|
||||
|
||||
interface DatabaseConnectionPageProps {
|
||||
platform: string
|
||||
mode: DatabaseConnectionMode
|
||||
dbKey: string
|
||||
dbRoot: string
|
||||
showDbKey: boolean
|
||||
isFetching: boolean
|
||||
status: string
|
||||
statusKind: DatabaseConnectionStatusKind
|
||||
showMacKeyFaq: boolean
|
||||
macKeyFaqUrl: string
|
||||
onModeChange: (mode: DatabaseConnectionMode) => void
|
||||
onDbKeyChange: (value: string) => void
|
||||
onDbRootChange: (value: string) => void
|
||||
onToggleDbKey: () => void
|
||||
onAutoGetKey: () => void
|
||||
onManualConnect: () => void
|
||||
onPasteKey: () => void
|
||||
onClearKey: () => void
|
||||
}
|
||||
|
||||
function LineIcon({
|
||||
name
|
||||
}: {
|
||||
name: 'shield' | 'lock' | 'cloud' | 'info' | 'database'
|
||||
}): React.ReactElement {
|
||||
const paths = {
|
||||
shield: <path d="M12 3 5 6v5c0 4.5 2.8 7.7 7 10 4.2-2.3 7-5.5 7-10V6l-7-3Z" />,
|
||||
lock: <path d="M7 10V8a5 5 0 0 1 10 0v2m-11 0h12v10H6V10Z" />,
|
||||
cloud: (
|
||||
<path d="m4 4 16 16M7.5 16H6a4 4 0 0 1-.5-8A6.5 6.5 0 0 1 17 6.8M18.5 10A4 4 0 0 1 18 18h-7" />
|
||||
),
|
||||
info: <path d="M12 8h.01M11 12h1v4h1M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18Z" />,
|
||||
database: (
|
||||
<path d="M5 6c0-1.7 3.1-3 7-3s7 1.3 7 3-3.1 3-7 3-7-1.3-7-3Zm0 0v6c0 1.7 3.1 3 7 3s7-1.3 7-3V6m-14 6v6c0 1.7 3.1 3 7 3s7-1.3 7-3v-6" />
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<g
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{paths[name]}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function EyeIcon({ visible }: { visible: boolean }): React.ReactElement {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
||||
<path d="M2.5 12s3.5-6 9.5-6 9.5 6 9.5 6-3.5 6-9.5 6-9.5-6-9.5-6Z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
{!visible && <path d="M4 4l16 16" />}
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function StoragePathHelp(): React.ReactElement {
|
||||
return (
|
||||
<span className="database-login-path-help">
|
||||
<span
|
||||
className="database-login-path-help-icon"
|
||||
tabIndex={0}
|
||||
aria-describedby="storage-path-help"
|
||||
>
|
||||
!
|
||||
</span>
|
||||
<span id="storage-path-help" className="database-login-path-tooltip" role="tooltip">
|
||||
打开微信设置,在缓存管理中复制存储路径,然后粘贴到这里。
|
||||
</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function DatabaseConnectionPage({
|
||||
platform,
|
||||
mode,
|
||||
dbKey,
|
||||
dbRoot,
|
||||
showDbKey,
|
||||
isFetching,
|
||||
status,
|
||||
statusKind,
|
||||
showMacKeyFaq,
|
||||
macKeyFaqUrl,
|
||||
onModeChange,
|
||||
onDbKeyChange,
|
||||
onDbRootChange,
|
||||
onToggleDbKey,
|
||||
onAutoGetKey,
|
||||
onManualConnect,
|
||||
onPasteKey,
|
||||
onClearKey
|
||||
}: DatabaseConnectionPageProps): React.ReactElement {
|
||||
const isMac = platform === 'darwin'
|
||||
const defaultPath = isMac
|
||||
? '~/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/'
|
||||
: 'C:\\Users\\...\\WeChat Files\\Msg'
|
||||
const keyIsValid = /^[0-9a-f]{64}$/i.test(dbKey.trim().replace(/^0x/i, ''))
|
||||
|
||||
return (
|
||||
<main className="database-login-page">
|
||||
<section className="database-login-brand" aria-label="WechatExplorer 产品说明">
|
||||
<div className="database-login-brand-content">
|
||||
<div className="database-login-logo" aria-hidden="true">
|
||||
<LineIcon name="database" />
|
||||
</div>
|
||||
<h1>WechatExplorer</h1>
|
||||
<p className="database-login-tagline">你的本地微信聊天档案</p>
|
||||
<p className="database-login-description">
|
||||
连接本机微信数据库,开始检索、整理和分析聊天记录。
|
||||
</p>
|
||||
<div className="database-login-promises">
|
||||
<div>
|
||||
<LineIcon name="shield" />
|
||||
<span>仅限本机</span>
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="lock" />
|
||||
<span>加密保存</span>
|
||||
</div>
|
||||
<div>
|
||||
<LineIcon name="cloud" />
|
||||
<span>不会上传</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="database-login-brand-footer">LOCAL-FIRST · PRIVATE · SECURE</div>
|
||||
</section>
|
||||
|
||||
<section className="database-login-workspace" aria-label="数据库连接">
|
||||
<div className="database-login-panel">
|
||||
<div className="database-login-tabs" role="tablist" aria-label="连接方式">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'automatic'}
|
||||
className={mode === 'automatic' ? 'active' : ''}
|
||||
onClick={() => onModeChange('automatic')}
|
||||
>
|
||||
自动获取
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={mode === 'manual'}
|
||||
className={mode === 'manual' ? 'active' : ''}
|
||||
onClick={() => onModeChange('manual')}
|
||||
>
|
||||
手动输入
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === 'automatic' ? (
|
||||
<div className="database-login-auto" role="tabpanel">
|
||||
<div className={`database-login-state-card ${statusKind}`}>
|
||||
<div className="database-login-state-heading">
|
||||
<span className="database-login-state-icon">
|
||||
<LineIcon name="info" />
|
||||
</span>
|
||||
<div>
|
||||
<strong>
|
||||
{statusKind === 'error' ? '未能获取数据库密钥' : '已准备检测微信数据库'}
|
||||
</strong>
|
||||
<p>
|
||||
{statusKind === 'error'
|
||||
? status
|
||||
: status || '请保持微信客户端正在运行,系统将尝试安全获取数据库密钥。'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="database-login-diagnostics">
|
||||
<div>
|
||||
<dt>微信客户端</dt>
|
||||
<dd>{isFetching ? '正在检测' : '等待检测'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
存储路径
|
||||
<StoragePathHelp />
|
||||
</dt>
|
||||
<dd>
|
||||
<span className="database-login-path-input-wrap">
|
||||
<input
|
||||
type="text"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
aria-label="微信数据存储路径"
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
<span className="database-login-path-value" role="status">
|
||||
{dbRoot || defaultPath}
|
||||
</span>
|
||||
</span>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>数据库状态</dt>
|
||||
<dd>{statusKind === 'error' ? '无法连接' : '准备连接'}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onAutoGetKey}
|
||||
disabled={isFetching}
|
||||
>
|
||||
{isFetching
|
||||
? '正在获取密钥…'
|
||||
: statusKind === 'error'
|
||||
? '重新检测'
|
||||
: '自动获取密钥'}
|
||||
</button>
|
||||
{showMacKeyFaq && (
|
||||
<a href={macKeyFaqUrl} target="_blank" rel="noreferrer">
|
||||
查看详情 · 连接帮助
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="database-login-manual" role="tabpanel">
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-key">数据库密钥</label>
|
||||
<div className="database-login-key-input">
|
||||
<input
|
||||
id="database-login-key"
|
||||
type={showDbKey ? 'text' : 'password'}
|
||||
value={dbKey}
|
||||
onChange={(event) => onDbKeyChange(event.target.value)}
|
||||
placeholder="输入或粘贴 64 位数据库密钥"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleDbKey}
|
||||
title={showDbKey ? '隐藏密钥' : '显示密钥'}
|
||||
>
|
||||
<EyeIcon visible={showDbKey} />
|
||||
</button>
|
||||
</div>
|
||||
<small>密钥通过系统安全存储加密保存在当前设备。</small>
|
||||
</div>
|
||||
{platform === 'win32' && (
|
||||
<div className="database-login-field">
|
||||
<label htmlFor="database-login-root">
|
||||
微信数据目录
|
||||
<StoragePathHelp />
|
||||
</label>
|
||||
<input
|
||||
id="database-login-root"
|
||||
value={dbRoot}
|
||||
onChange={(event) => onDbRootChange(event.target.value)}
|
||||
placeholder={defaultPath}
|
||||
title={dbRoot || defaultPath}
|
||||
spellCheck={false}
|
||||
onFocus={(event) => event.currentTarget.select()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{status && <div className={`database-login-message ${statusKind}`}>{status}</div>}
|
||||
<button
|
||||
type="button"
|
||||
className="database-login-primary"
|
||||
onClick={onManualConnect}
|
||||
disabled={!keyIsValid}
|
||||
>
|
||||
连接数据库
|
||||
</button>
|
||||
<button type="button" className="database-login-secondary" onClick={onPasteKey}>
|
||||
从剪贴板粘贴并安全保存
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="database-login-footer-actions">
|
||||
<button type="button" onClick={onClearKey}>
|
||||
清除已保存密钥
|
||||
</button>
|
||||
<span>WechatExplorer</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
import { getCachedLoadedImage, requestImage } from './image-loader'
|
||||
|
||||
interface ImageBubbleProps {
|
||||
imageMd5?: string
|
||||
imageDatName?: string
|
||||
sessionId?: string
|
||||
isThumb?: boolean
|
||||
fallbackUrl?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
@@ -13,39 +15,91 @@ export function ImageBubble({
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
isThumb = false,
|
||||
fallbackUrl,
|
||||
onImageClick
|
||||
}: ImageBubbleProps): JSX.Element {
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(null)
|
||||
const initialCachedImage = getCachedLoadedImage(imageMd5, imageDatName, {
|
||||
preferThumbnail: true
|
||||
})
|
||||
const [imageUrl, setImageUrl] = useState<string | null>(initialCachedImage?.data || null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [upgrading, setUpgrading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isThumbnail, setIsThumbnail] = useState(false)
|
||||
const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail))
|
||||
const [usingFallback, setUsingFallback] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountedRef = useRef(true)
|
||||
const backgroundUpgradeRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const upgradeOriginalInBackground = useCallback(() => {
|
||||
if (!isThumbnail || backgroundUpgradeRef.current || (!imageMd5 && !imageDatName)) return
|
||||
backgroundUpgradeRef.current = true
|
||||
void requestImage(imageMd5, imageDatName, sessionId, { force: true }, 1)
|
||||
.then((original) => {
|
||||
if (!mountedRef.current) return
|
||||
setImageUrl(original.data)
|
||||
setIsThumbnail(false)
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}, [imageDatName, imageMd5, isThumbnail, sessionId])
|
||||
|
||||
const loadImage = useCallback(async () => {
|
||||
if (imageUrl || loading) return
|
||||
if (!imageMd5 && !imageDatName) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
setError('缺少图片标识')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
const result = await requestImage(
|
||||
imageMd5,
|
||||
imageDatName,
|
||||
sessionId,
|
||||
{ preferThumbnail: true },
|
||||
0
|
||||
)
|
||||
setImageUrl(result.data)
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
if (result.isThumbnail) upgradeOriginalInBackground()
|
||||
} catch (error) {
|
||||
if (fallbackUrl) {
|
||||
setImageUrl(fallbackUrl)
|
||||
setUsingFallback(true)
|
||||
setError(null)
|
||||
} else {
|
||||
setError(result.error || '加载图片失败')
|
||||
setError(error instanceof Error ? error.message : '加载图片失败')
|
||||
}
|
||||
} catch {
|
||||
setError('加载图片失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
|
||||
}, [
|
||||
fallbackUrl,
|
||||
imageDatName,
|
||||
imageMd5,
|
||||
imageUrl,
|
||||
loading,
|
||||
sessionId,
|
||||
upgradeOriginalInBackground
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (initialCachedImage?.isThumbnail) upgradeOriginalInBackground()
|
||||
}, [initialCachedImage?.isThumbnail, upgradeOriginalInBackground])
|
||||
|
||||
useEffect(() => {
|
||||
if (imageUrl || loading || error) return
|
||||
@@ -88,12 +142,11 @@ export function ImageBubble({
|
||||
|
||||
setUpgrading(true)
|
||||
try {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId, {
|
||||
force: true
|
||||
})
|
||||
if (result.success && result.data?.startsWith('data:image/')) {
|
||||
const result = await requestImage(imageMd5, imageDatName, sessionId, { force: true }, 0)
|
||||
if (result.data.startsWith('data:image/')) {
|
||||
setImageUrl(result.data)
|
||||
setIsThumbnail(Boolean(result.isThumb))
|
||||
setUsingFallback(false)
|
||||
setIsThumbnail(result.isThumbnail)
|
||||
setError(null)
|
||||
onImageClick?.(result.data)
|
||||
return
|
||||
@@ -136,7 +189,11 @@ export function ImageBubble({
|
||||
|
||||
return (
|
||||
<div className="image-bubble image-loaded" onClick={handleClick}>
|
||||
<img src={imageUrl} alt="图片" className="image-content" />
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt="图片"
|
||||
className={`image-content ${usingFallback ? 'image-fallback' : ''}`}
|
||||
/>
|
||||
{(upgrading || isThumbnail) && (
|
||||
<div className="image-quality-badge">{upgrading ? '正在查找原图' : '缩略图'}</div>
|
||||
)}
|
||||
|
||||
@@ -2,14 +2,21 @@ import { ParsedContent } from '../../../shared/types'
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { JSX, MouseEvent } from 'react'
|
||||
import { renderWechatEmojiText } from '../utils/wechatEmojiText'
|
||||
import { ImageBubble } from './ImageBubble'
|
||||
|
||||
const stickerDataUrlCache = new Map<string, string>()
|
||||
|
||||
interface RichMessageBubbleProps {
|
||||
contentData: ParsedContent
|
||||
sessionId?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}
|
||||
|
||||
export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.Element {
|
||||
export function RichMessageBubble({
|
||||
contentData,
|
||||
sessionId,
|
||||
onImageClick
|
||||
}: RichMessageBubbleProps): JSX.Element {
|
||||
switch (contentData.type) {
|
||||
case 'location':
|
||||
return <LocationBubble data={contentData} />
|
||||
@@ -17,12 +24,22 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
||||
return <CardBubble data={contentData} />
|
||||
case 'share':
|
||||
return <ShareBubble data={contentData} />
|
||||
case 'miniProgram':
|
||||
return (
|
||||
<MiniProgramBubble
|
||||
data={contentData}
|
||||
sessionId={sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
)
|
||||
case 'redPacket':
|
||||
return <RedPacketBubble data={contentData} />
|
||||
case 'voip':
|
||||
return <VoipBubble data={contentData} />
|
||||
case 'sticker':
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
return <QuoteBubble data={contentData} sessionId={sessionId} onImageClick={onImageClick} />
|
||||
case 'system':
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
@@ -110,6 +127,83 @@ function ShareBubble({ data }: { data: Extract<ParsedContent, { type: 'share' }>
|
||||
)
|
||||
}
|
||||
|
||||
function MiniProgramBubble({
|
||||
data,
|
||||
sessionId,
|
||||
onImageClick
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'miniProgram' }>
|
||||
sessionId?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<div className="mini-program-message">
|
||||
<div className="mini-program-title">{data.title}</div>
|
||||
{data.description && <div className="mini-program-description">{data.description}</div>}
|
||||
{data.thumbDataUrl ? (
|
||||
<div className="mini-program-preview">
|
||||
<img
|
||||
className="mini-program-inline-image"
|
||||
src={data.thumbDataUrl}
|
||||
alt={data.title}
|
||||
onClick={() => onImageClick?.(data.thumbDataUrl || '')}
|
||||
/>
|
||||
</div>
|
||||
) : data.thumbMd5 ? (
|
||||
<div className="mini-program-preview">
|
||||
<ImageBubble
|
||||
imageMd5={data.thumbMd5}
|
||||
imageDatName={data.thumbDatName}
|
||||
sessionId={sessionId}
|
||||
isThumb
|
||||
fallbackUrl={data.iconUrl}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
</div>
|
||||
) : data.iconUrl ? (
|
||||
<img
|
||||
className="mini-program-icon"
|
||||
src={data.iconUrl}
|
||||
alt=""
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : null}
|
||||
<div className="mini-program-footer">
|
||||
<span aria-hidden>⌁</span>
|
||||
<span>{data.appName || '小程序'}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function RedPacketBubble({
|
||||
data
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'redPacket' }>
|
||||
}): JSX.Element {
|
||||
const handleClick = (): void => {
|
||||
if (data.url) window.open(data.url, '_blank')
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`red-packet-message ${data.url ? 'is-clickable' : ''}`}
|
||||
onClick={data.url ? handleClick : undefined}
|
||||
>
|
||||
<div className="red-packet-main">
|
||||
<span className="red-packet-icon" aria-hidden>
|
||||
¥
|
||||
</span>
|
||||
<span className="red-packet-copy">
|
||||
<strong>{data.title}</strong>
|
||||
<small>{data.description || '恭喜发财,大吉大利'}</small>
|
||||
</span>
|
||||
</div>
|
||||
<div className="red-packet-footer">微信红包</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function VoipBubble({ data }: { data: Extract<ParsedContent, { type: 'voip' }> }): JSX.Element {
|
||||
const { status, roomType, duration } = data
|
||||
const isVideo = roomType === 1
|
||||
@@ -201,7 +295,15 @@ function StickerBubble({
|
||||
)
|
||||
}
|
||||
|
||||
function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }> }): JSX.Element {
|
||||
function QuoteBubble({
|
||||
data,
|
||||
sessionId,
|
||||
onImageClick
|
||||
}: {
|
||||
data: Extract<ParsedContent, { type: 'quote' }>
|
||||
sessionId?: string
|
||||
onImageClick?: (imageUrl: string) => void
|
||||
}): JSX.Element {
|
||||
const quotedText = data.quotedContent || data.content || '[引用消息]'
|
||||
const replyText = data.content || data.title || ''
|
||||
const quotedSender = data.quotedSender || data.sender || ''
|
||||
@@ -210,7 +312,17 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
|
||||
<div className="quote-message">
|
||||
<div className="quoted-message">
|
||||
{quotedSender && <span className="quoted-sender">{quotedSender}</span>}
|
||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||
{data.quotedImageMd5 || data.quotedImageDatName ? (
|
||||
<ImageBubble
|
||||
imageMd5={data.quotedImageMd5}
|
||||
imageDatName={data.quotedImageDatName}
|
||||
sessionId={sessionId}
|
||||
isThumb
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : (
|
||||
<span className="quoted-text">{renderWechatEmojiText(quotedText, 18)}</span>
|
||||
)}
|
||||
</div>
|
||||
{replyText && <div className="quote-reply">{renderWechatEmojiText(replyText)}</div>}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
interface VideoBubbleProps {
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
}
|
||||
|
||||
export function VideoBubble({
|
||||
md5,
|
||||
newMd5,
|
||||
rawMd5,
|
||||
duration
|
||||
}: VideoBubbleProps): React.ReactElement {
|
||||
const hashes = useMemo(
|
||||
() => [rawMd5, newMd5, md5].filter((value): value is string => Boolean(value)),
|
||||
[md5, newMd5, rawMd5]
|
||||
)
|
||||
const [media, setMedia] = useState<{ url?: string; poster?: string; error?: string }>({})
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
window.api
|
||||
.getVideo(hashes)
|
||||
.then((result) => {
|
||||
if (!cancelled) setMedia(result.success ? result : { error: result.error })
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setMedia({ error: error instanceof Error ? error.message : String(error) })
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [hashes])
|
||||
|
||||
if (!media.url) {
|
||||
return <div className="video-placeholder">{media.error || '视频加载中…'}</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="video-message">
|
||||
<video
|
||||
className="video-content"
|
||||
src={media.url}
|
||||
poster={media.poster}
|
||||
controls
|
||||
preload="metadata"
|
||||
/>
|
||||
{duration ? <span className="video-duration">{duration} 秒</span> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,38 +1,31 @@
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { ConversationContentSearch } from './ConversationContentSearch'
|
||||
import { ExportMenu, ExportRange } from './ExportMenu'
|
||||
import { AiIcon, MoreIcon, RefreshIcon, SearchIcon } from './icons'
|
||||
|
||||
interface ChatHeaderProps {
|
||||
contact: Contact
|
||||
isGroupChat: boolean
|
||||
dateRangeLabel: string
|
||||
loadedCount: number
|
||||
filteredCount: number
|
||||
contentFilter: string
|
||||
isAiLoading: boolean
|
||||
canExport: boolean
|
||||
onContentFilterChange: (value: string) => void
|
||||
onRefresh?: () => void
|
||||
onRefreshData?: () => void
|
||||
onExport: (range: ExportRange) => void
|
||||
onOpenAiSettings: () => void
|
||||
}
|
||||
|
||||
export function ChatHeader({
|
||||
contact,
|
||||
isGroupChat,
|
||||
dateRangeLabel,
|
||||
loadedCount,
|
||||
filteredCount,
|
||||
contentFilter,
|
||||
isAiLoading,
|
||||
canExport,
|
||||
onContentFilterChange,
|
||||
onRefresh,
|
||||
onRefreshData,
|
||||
onExport,
|
||||
onOpenAiSettings
|
||||
}: ChatHeaderProps): React.ReactElement {
|
||||
const [searchOpen, setSearchOpen] = useState(Boolean(contentFilter))
|
||||
@@ -70,7 +63,6 @@ export function ChatHeader({
|
||||
<h2>{displayName}</h2>
|
||||
<div className="chat-title-meta">
|
||||
<span>{typeLabel}</span>
|
||||
<span>{dateRangeLabel}</span>
|
||||
<span>{visibleCount} 条消息</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,7 +88,6 @@ export function ChatHeader({
|
||||
<button type="button" className="chat-icon-button" onClick={onRefresh} title="刷新聊天记录">
|
||||
<RefreshIcon />
|
||||
</button>
|
||||
<ExportMenu disabled={!canExport} onExport={onExport} />
|
||||
<div className="chat-menu" ref={moreRef}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React from 'react'
|
||||
import { RefreshIcon } from './icons'
|
||||
|
||||
interface ChatStatusBarProps {
|
||||
count: number
|
||||
showAvatar: boolean
|
||||
isAtLatest: boolean
|
||||
isReloadingAvatars: boolean
|
||||
onShowAvatarChange: (show: boolean) => void
|
||||
onReloadAvatars: () => void
|
||||
onJumpToLatest: () => void
|
||||
}
|
||||
|
||||
@@ -12,7 +15,9 @@ export function ChatStatusBar({
|
||||
count,
|
||||
showAvatar,
|
||||
isAtLatest,
|
||||
isReloadingAvatars,
|
||||
onShowAvatarChange,
|
||||
onReloadAvatars,
|
||||
onJumpToLatest
|
||||
}: ChatStatusBarProps): React.ReactElement {
|
||||
const jumpDisabled = isAtLatest || count === 0
|
||||
@@ -29,6 +34,22 @@ export function ChatStatusBar({
|
||||
/>
|
||||
<span>显示头像</span>
|
||||
</label>
|
||||
<span
|
||||
className="chat-avatar-note"
|
||||
title="头像或名称缺失时,请先在微信中进入该聊天,再尝试重新加载"
|
||||
>
|
||||
头像或名称缺失时,请先在微信中进入该聊天
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="chat-avatar-reload"
|
||||
onClick={onReloadAvatars}
|
||||
disabled={isReloadingAvatars}
|
||||
title="尝试重新加载当前聊天的头像和名称"
|
||||
>
|
||||
<RefreshIcon className={isReloadingAvatars ? 'is-spinning' : ''} />
|
||||
<span>{isReloadingAvatars ? '加载中' : '重新加载头像'}</span>
|
||||
</button>
|
||||
<span className="chat-status-separator" aria-hidden />
|
||||
<button type="button" onClick={onJumpToLatest} disabled={jumpDisabled}>
|
||||
{jumpDisabled ? '已是最新消息' : '跳转到最新消息'}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../../shared/types'
|
||||
import { ImageBubble } from '../ImageBubble'
|
||||
import { RichMessageBubble } from '../RichMessageBubble'
|
||||
import { VoicePlayer } from '../VoicePlayer'
|
||||
import { VideoBubble } from '../VideoBubble'
|
||||
import { renderWechatEmojiText } from '../../utils/wechatEmojiText'
|
||||
import { formatMessageTime } from './messageGrouping'
|
||||
|
||||
@@ -13,9 +14,25 @@ interface MessageBubbleProps {
|
||||
isMine: boolean
|
||||
showAvatarSpace: boolean
|
||||
onImageClick: (imageUrl: string) => void
|
||||
isJumpTarget?: boolean
|
||||
}
|
||||
|
||||
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
|
||||
const RICH_MESSAGE_TYPES = [
|
||||
'名片',
|
||||
'位置',
|
||||
'分享消息',
|
||||
'小程序',
|
||||
'微信红包',
|
||||
'公众号链接',
|
||||
'文件',
|
||||
'文件发送中',
|
||||
'视频号',
|
||||
'转账',
|
||||
'引用消息',
|
||||
'通话',
|
||||
'表情包',
|
||||
'系统消息'
|
||||
]
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
@@ -23,19 +40,23 @@ export function MessageBubble({
|
||||
isGroupChat,
|
||||
isMine,
|
||||
showAvatarSpace,
|
||||
onImageClick
|
||||
onImageClick,
|
||||
isJumpTarget
|
||||
}: MessageBubbleProps): React.ReactElement {
|
||||
const isVoice = message.type === '语音'
|
||||
const isImage = message.type === '图片'
|
||||
const isVideo = message.type === '视频'
|
||||
const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type)
|
||||
const hoverTime = formatMessageTime(message)
|
||||
|
||||
return (
|
||||
<div className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'}`}>
|
||||
<div
|
||||
className={`message-bubble-wrap ${showAvatarSpace ? '' : 'is-followup'} ${isJumpTarget ? 'archive-jump-message' : ''}`}
|
||||
>
|
||||
<div
|
||||
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
|
||||
isImage ? 'image-message-bubble' : ''
|
||||
}`}
|
||||
} ${isVideo ? 'video-message-bubble' : ''}`}
|
||||
>
|
||||
{isVoice && message.sessionId ? (
|
||||
<VoicePlayer
|
||||
@@ -50,12 +71,28 @@ export function MessageBubble({
|
||||
sessionId={message.sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : isVideo && message.contentData && message.contentData.type === 'video' ? (
|
||||
<VideoBubble
|
||||
md5={message.contentData.md5}
|
||||
newMd5={message.contentData.newMd5}
|
||||
rawMd5={message.contentData.rawMd5}
|
||||
duration={message.contentData.duration}
|
||||
/>
|
||||
) : isRichMedia && message.contentData ? (
|
||||
<RichMessageBubble contentData={message.contentData} />
|
||||
<RichMessageBubble
|
||||
contentData={message.contentData}
|
||||
sessionId={message.sessionId}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="message-text">{renderWechatEmojiText(message.content)}</div>
|
||||
)}
|
||||
</div>
|
||||
{message.recalled && (
|
||||
<span className="message-recalled-status" title={message.recalledBy || undefined}>
|
||||
消息已撤回
|
||||
</span>
|
||||
)}
|
||||
<span className="message-hover-time">{hoverTime}</span>
|
||||
{!isGroupChat && !isMine && contact.m_nsNickName && (
|
||||
<span className="message-accessible-sender">{contact.m_nsNickName}</span>
|
||||
|
||||
@@ -9,6 +9,7 @@ interface MessageGroupProps {
|
||||
isGroupChat: boolean
|
||||
showAvatar: boolean
|
||||
onImageClick: (imageUrl: string) => void
|
||||
jumpTargetMessageId?: string
|
||||
}
|
||||
|
||||
export function MessageGroup({
|
||||
@@ -16,7 +17,8 @@ export function MessageGroup({
|
||||
contact,
|
||||
isGroupChat,
|
||||
showAvatar,
|
||||
onImageClick
|
||||
onImageClick,
|
||||
jumpTargetMessageId
|
||||
}: MessageGroupProps): React.ReactElement {
|
||||
const firstMessage = group.messages[0]
|
||||
|
||||
@@ -57,9 +59,7 @@ export function MessageGroup({
|
||||
)}
|
||||
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
|
||||
<div className="message-stack">
|
||||
{!isMine && isGroupChat && shouldShowAvatar && (
|
||||
<div className="message-sender-name">{displayName}</div>
|
||||
)}
|
||||
{!isMine && isGroupChat && <div className="message-sender-name">{displayName}</div>}
|
||||
{group.messages.map((message, index) => (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
@@ -69,6 +69,7 @@ export function MessageGroup({
|
||||
isMine={isMine}
|
||||
showAvatarSpace={index === 0}
|
||||
onImageClick={onImageClick}
|
||||
isJumpTarget={message.id === jumpTargetMessageId}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Contact, Message } from '../../../../shared/types'
|
||||
import { MessageGroup } from './MessageGroup'
|
||||
import { buildMessageGroups } from './messageGrouping'
|
||||
@@ -13,7 +14,9 @@ interface MessageListProps {
|
||||
listRef: React.RefObject<HTMLDivElement | null>
|
||||
bottomRef: React.RefObject<HTMLDivElement | null>
|
||||
onScroll: (event: React.UIEvent<HTMLDivElement>) => void
|
||||
onReachTop?: () => Promise<void>
|
||||
onImageClick: (imageUrl: string) => void
|
||||
jumpToTime?: number | null
|
||||
}
|
||||
|
||||
export function MessageList({
|
||||
@@ -26,12 +29,95 @@ export function MessageList({
|
||||
listRef,
|
||||
bottomRef,
|
||||
onScroll,
|
||||
onImageClick
|
||||
onReachTop,
|
||||
onImageClick,
|
||||
jumpToTime
|
||||
}: MessageListProps): React.ReactElement {
|
||||
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
|
||||
const groupsRef = React.useRef(groups)
|
||||
const loadingOlderRef = React.useRef(false)
|
||||
groupsRef.current = groups
|
||||
const virtualizer = useVirtualizer({
|
||||
count: groups.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: () => 96,
|
||||
getItemKey: (index) => groups[index]?.id || index,
|
||||
overscan: 8
|
||||
})
|
||||
const virtualItems = virtualizer.getVirtualItems()
|
||||
const jumpTarget = React.useMemo(() => {
|
||||
if (jumpToTime === undefined || jumpToTime === null) return null
|
||||
const groupIndex = groups.findIndex((group) =>
|
||||
group.messages.some((message) => (message.createTime || 0) >= jumpToTime)
|
||||
)
|
||||
if (groupIndex < 0) return null
|
||||
const message = groups[groupIndex].messages.find((item) => (item.createTime || 0) >= jumpToTime)
|
||||
return { groupIndex, messageId: message?.id }
|
||||
}, [groups, jumpToTime])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!jumpTarget) return
|
||||
const frame = window.requestAnimationFrame(() => {
|
||||
virtualizer.scrollToIndex(jumpTarget.groupIndex, { align: 'center' })
|
||||
})
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [jumpTarget, virtualizer])
|
||||
|
||||
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
|
||||
onScroll(event)
|
||||
const scrollElement = event.currentTarget
|
||||
if (
|
||||
(jumpToTime !== undefined && jumpToTime !== null) ||
|
||||
scrollElement.scrollTop >= 48 ||
|
||||
loadingOlderRef.current ||
|
||||
isLoadingMessages ||
|
||||
!onReachTop
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
loadingOlderRef.current = true
|
||||
const previousGroupCount = groups.length
|
||||
const previousScrollTop = scrollElement.scrollTop
|
||||
const previousScrollHeight = scrollElement.scrollHeight
|
||||
const anchorMessageId = groups[0]?.messages[0]?.id
|
||||
void (async () => {
|
||||
try {
|
||||
await onReachTop()
|
||||
for (let frame = 0; frame < 8; frame += 1) {
|
||||
if (groupsRef.current.length > previousGroupCount) break
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
}
|
||||
if (!anchorMessageId) return
|
||||
const anchorIndex = groupsRef.current.findIndex((group) =>
|
||||
group.messages.some((message) => message.id === anchorMessageId)
|
||||
)
|
||||
if (anchorIndex <= 0) return
|
||||
virtualizer.measure()
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
const addedHeight = scrollElement.scrollHeight - previousScrollHeight
|
||||
if (addedHeight > 0) {
|
||||
scrollElement.scrollTop = previousScrollTop + addedHeight
|
||||
} else {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
|
||||
// Variable-height groups can finish measuring one frame later. Preserve
|
||||
// the same message anchor after that final measurement as well.
|
||||
await new Promise<void>((resolve) => window.requestAnimationFrame(() => resolve()))
|
||||
if (scrollElement.scrollTop < 48) {
|
||||
virtualizer.scrollToIndex(anchorIndex, { align: 'start' })
|
||||
}
|
||||
} finally {
|
||||
window.setTimeout(() => {
|
||||
loadingOlderRef.current = false
|
||||
}, 250)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={onScroll}>
|
||||
<div className="message-list wechat-message-list" ref={listRef} onScroll={handleScroll}>
|
||||
{isLoadingMessages && <div className="message-loading-pill">正在加载聊天记录...</div>}
|
||||
{hiddenMessageCount > 0 && (
|
||||
<div className="wechat-system-message-row">
|
||||
@@ -40,16 +126,30 @@ export function MessageList({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<MessageGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
/>
|
||||
))}
|
||||
<div className="virtual-message-list" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualItems.map((virtualItem) => {
|
||||
const group = groups[virtualItem.index]
|
||||
if (!group) return null
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={virtualItem.index}
|
||||
className={`virtual-message-group ${jumpTarget?.groupIndex === virtualItem.index ? 'archive-jump-target-group' : ''}`}
|
||||
style={{ transform: `translateY(${virtualItem.start}px)` }}
|
||||
>
|
||||
<MessageGroup
|
||||
group={group}
|
||||
contact={contact}
|
||||
isGroupChat={isGroupChat}
|
||||
showAvatar={showAvatar}
|
||||
onImageClick={onImageClick}
|
||||
jumpTargetMessageId={jumpTarget?.messageId}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { ConversationItem } from './ConversationItem'
|
||||
|
||||
@@ -29,6 +30,31 @@ export function ConversationSection({
|
||||
onToggle,
|
||||
onSelectContact
|
||||
}: ConversationSectionProps): React.ReactElement {
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
const [scrollMargin, setScrollMargin] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
const updateMargin = (): void => {
|
||||
const list = listRef.current
|
||||
const scrollElement = document.querySelector<HTMLDivElement>('.conversation-list')
|
||||
if (!list || !scrollElement) return
|
||||
setScrollMargin(list.getBoundingClientRect().top - scrollElement.getBoundingClientRect().top)
|
||||
}
|
||||
updateMargin()
|
||||
window.addEventListener('resize', updateMargin)
|
||||
return () => window.removeEventListener('resize', updateMargin)
|
||||
}, [contacts.length, expanded])
|
||||
const virtualizer = useVirtualizer({
|
||||
count: expanded ? contacts.length : 0,
|
||||
getScrollElement: () => document.querySelector<HTMLDivElement>('.conversation-list'),
|
||||
scrollMargin,
|
||||
estimateSize: () => 58,
|
||||
overscan: 8
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (expanded) virtualizer.measure()
|
||||
}, [contacts.length, expanded, virtualizer])
|
||||
|
||||
return (
|
||||
<section className="conversation-section">
|
||||
<button type="button" className="conversation-section-header" onClick={onToggle}>
|
||||
@@ -40,16 +66,29 @@ export function ConversationSection({
|
||||
</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="conversation-section-list">
|
||||
<div ref={listRef} className="conversation-section-list">
|
||||
{contacts.length > 0 ? (
|
||||
contacts.map((contact) => (
|
||||
<ConversationItem
|
||||
key={contact.md5}
|
||||
contact={contact}
|
||||
active={selectedContact?.md5 === contact.md5}
|
||||
onSelect={onSelectContact}
|
||||
/>
|
||||
))
|
||||
<div
|
||||
className="conversation-section-virtual-content"
|
||||
style={{ height: `${virtualizer.getTotalSize()}px` }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const contact = contacts[virtualItem.index]
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="conversation-section-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start - scrollMargin}px)` }}
|
||||
>
|
||||
<ConversationItem
|
||||
contact={contact}
|
||||
active={selectedContact?.md5 === contact.md5}
|
||||
onSelect={onSelectContact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="conversation-section-empty">{emptyText}</div>
|
||||
)}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useState } from 'react'
|
||||
import React, { useMemo, useRef, useState } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { Contact } from '../../../../shared/types'
|
||||
import { AccountSummary } from '../account/AccountSummary'
|
||||
import { ConversationSection } from './ConversationSection'
|
||||
import { ConversationItem } from './ConversationItem'
|
||||
import { ConversationSidebarHeader } from './ConversationSidebarHeader'
|
||||
|
||||
interface SelfInfo {
|
||||
@@ -18,30 +19,55 @@ export interface ConversationSidebarProps {
|
||||
onSearch: (keyword: string) => void
|
||||
onContentFilter: (keyword: string) => void
|
||||
width: number
|
||||
dateRange: string
|
||||
onDateRangeChange: (range: string) => void
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onOpenSettings: () => void
|
||||
}
|
||||
|
||||
type SectionName = 'groups' | 'contacts'
|
||||
type ConversationRow =
|
||||
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||
| { kind: 'contact'; id: string; contact: Contact }
|
||||
|
||||
export function ConversationSidebar({
|
||||
contacts,
|
||||
selectedContact,
|
||||
onSelectContact,
|
||||
onSearch,
|
||||
width,
|
||||
dateRange,
|
||||
onDateRangeChange,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onOpenSettings
|
||||
}: ConversationSidebarProps): React.ReactElement {
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [expandedSection, setExpandedSection] = useState<'groups' | 'contacts'>('groups')
|
||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||
groups: true,
|
||||
contacts: false
|
||||
})
|
||||
const listRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
const groups = contacts.filter((contact) => contact.type === 'group')
|
||||
const users = contacts.filter((contact) => contact.type === 'user')
|
||||
const rows = useMemo<ConversationRow[]>(
|
||||
() => [
|
||||
{ kind: 'header', id: 'groups-header', title: '群聊', count: groups.length, section: 'groups' },
|
||||
...(expandedSections.groups
|
||||
? groups.map((contact) => ({ kind: 'contact' as const, id: `group-${contact.md5}`, contact }))
|
||||
: []),
|
||||
{ kind: 'header', id: 'contacts-header', title: '联系人', count: users.length, section: 'contacts' },
|
||||
...(expandedSections.contacts
|
||||
? users.map((contact) => ({ kind: 'contact' as const, id: `user-${contact.md5}`, contact }))
|
||||
: [])
|
||||
],
|
||||
[expandedSections.contacts, expandedSections.groups, groups, users]
|
||||
)
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => listRef.current,
|
||||
estimateSize: (index) => (rows[index]?.kind === 'header' ? 38 : 58),
|
||||
getItemKey: (index) => rows[index]?.id || index,
|
||||
overscan: 10
|
||||
})
|
||||
|
||||
const handleSearchChange = (term: string): void => {
|
||||
setSearchTerm(term)
|
||||
@@ -53,29 +79,49 @@ export function ConversationSidebar({
|
||||
<ConversationSidebarHeader
|
||||
totalCount={contacts.length}
|
||||
searchValue={searchTerm}
|
||||
dateRange={dateRange}
|
||||
onSearchChange={handleSearchChange}
|
||||
onDateRangeChange={onDateRangeChange}
|
||||
/>
|
||||
<div className="conversation-list" aria-label="会话列表">
|
||||
<ConversationSection
|
||||
title="群聊"
|
||||
contacts={groups}
|
||||
expanded={expandedSection === 'groups'}
|
||||
selectedContact={selectedContact}
|
||||
emptyText="暂无群聊"
|
||||
onToggle={() => setExpandedSection('groups')}
|
||||
onSelectContact={onSelectContact}
|
||||
/>
|
||||
<ConversationSection
|
||||
title="联系人"
|
||||
contacts={users}
|
||||
expanded={expandedSection === 'contacts'}
|
||||
selectedContact={selectedContact}
|
||||
emptyText="暂无联系人"
|
||||
onToggle={() => setExpandedSection('contacts')}
|
||||
onSelectContact={onSelectContact}
|
||||
/>
|
||||
<div ref={listRef} className="conversation-list" aria-label="会话列表">
|
||||
<div className="conversation-virtual-content" style={{ height: `${virtualizer.getTotalSize()}px` }}>
|
||||
{virtualizer.getVirtualItems().map((virtualItem) => {
|
||||
const row = rows[virtualItem.index]
|
||||
if (!row) return null
|
||||
if (row.kind === 'header') {
|
||||
const expanded = expandedSections[row.section]
|
||||
return (
|
||||
<button
|
||||
key={virtualItem.key}
|
||||
type="button"
|
||||
className="conversation-section-header conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
onClick={() =>
|
||||
setExpandedSections((current) => ({ ...current, [row.section]: !current[row.section] }))
|
||||
}
|
||||
>
|
||||
<span className="conversation-section-chevron" aria-hidden="true">
|
||||
<svg viewBox="0 0 16 16" focusable="false">
|
||||
<path d={expanded ? 'M4 6l4 4 4-4' : 'M6 4l4 4-4 4'} />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="conversation-section-title">{row.title} ({row.count})</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div
|
||||
key={virtualItem.key}
|
||||
className="conversation-virtual-row"
|
||||
style={{ transform: `translateY(${virtualItem.start}px)`, height: `${virtualItem.size}px` }}
|
||||
>
|
||||
<ConversationItem
|
||||
contact={row.contact}
|
||||
active={selectedContact?.md5 === row.contact.md5}
|
||||
onSelect={onSelectContact}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div className="conversation-sidebar-account">
|
||||
<AccountSummary selfInfo={selfInfo} dbReady={dbReady} onClick={onOpenSettings} />
|
||||
|
||||
@@ -1,21 +1,16 @@
|
||||
import React from 'react'
|
||||
import { ConversationSearch } from './ConversationSearch'
|
||||
import { DateRangeSelector } from './DateRangeSelector'
|
||||
|
||||
interface ConversationSidebarHeaderProps {
|
||||
totalCount: number
|
||||
searchValue: string
|
||||
dateRange: string
|
||||
onSearchChange: (value: string) => void
|
||||
onDateRangeChange: (range: string) => void
|
||||
}
|
||||
|
||||
export function ConversationSidebarHeader({
|
||||
totalCount,
|
||||
searchValue,
|
||||
dateRange,
|
||||
onSearchChange,
|
||||
onDateRangeChange
|
||||
onSearchChange
|
||||
}: ConversationSidebarHeaderProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-sidebar-header">
|
||||
@@ -24,7 +19,6 @@ export function ConversationSidebarHeader({
|
||||
<span>{totalCount} 个会话</span>
|
||||
</div>
|
||||
<ConversationSearch value={searchValue} onChange={onSearchChange} />
|
||||
<DateRangeSelector value={dateRange} onChange={onDateRangeChange} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react'
|
||||
|
||||
interface DateRangeSelectorProps {
|
||||
value: string
|
||||
onChange: (range: string) => void
|
||||
}
|
||||
|
||||
const DATE_RANGE_OPTIONS = [
|
||||
{ key: 'today', label: '今天' },
|
||||
{ key: 'yesterday', label: '昨日' },
|
||||
{ key: '7', label: '7 天' },
|
||||
{ key: '30', label: '30 天' },
|
||||
{ key: 'all', label: '全部' }
|
||||
]
|
||||
|
||||
export function DateRangeSelector({ value, onChange }: DateRangeSelectorProps): React.ReactElement {
|
||||
return (
|
||||
<div className="conversation-date-range" aria-label="时间范围">
|
||||
{DATE_RANGE_OPTIONS.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
className={`conversation-date-range-button ${value === item.key ? 'active' : ''}`}
|
||||
onClick={() => onChange(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
import React, { useMemo, useState } from 'react'
|
||||
import type { Contact, Message } from '../../../../shared/types'
|
||||
import type {
|
||||
ExportJobProgress,
|
||||
ExportMessageKind,
|
||||
ExportNameMode
|
||||
} from '../../../../shared/export'
|
||||
import type { ExportRequest, ExportResult, ExportTaskRecord } from '../../../../shared/export'
|
||||
|
||||
type ExportRange = 'today' | 'threeDays' | 'sevenDays' | 'custom'
|
||||
type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||
type ExportStatus = 'idle' | 'running' | 'completed'
|
||||
|
||||
interface GroupMemberName {
|
||||
wxid: string
|
||||
nickname: string
|
||||
groupNickname: string
|
||||
wechatNickname: string
|
||||
remark: string
|
||||
avatar: string
|
||||
}
|
||||
|
||||
interface SelfInfo {
|
||||
wxid: string
|
||||
nickname: string
|
||||
avatar?: string
|
||||
accountRoot: string
|
||||
}
|
||||
|
||||
interface ExportWorkspaceProps {
|
||||
contacts: Contact[]
|
||||
selectedContact: Contact | null
|
||||
previewMessages: Message[]
|
||||
selfInfo: SelfInfo | null
|
||||
dbReady: boolean
|
||||
onSelectContact: (contact: Contact) => void
|
||||
onOpenSettings: () => void
|
||||
exportTasks: ExportTaskRecord[]
|
||||
onStartExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
onCancelExport: (jobId: string) => Promise<void>
|
||||
}
|
||||
|
||||
const messageKinds = [
|
||||
['text', '文字'],
|
||||
['image', '图片'],
|
||||
['video', '视频'],
|
||||
['voice', '语音'],
|
||||
['sticker', '表情包'],
|
||||
['share', '链接与分享'],
|
||||
['location', '位置'],
|
||||
['system', '系统消息']
|
||||
] as const
|
||||
|
||||
const formatLabels: Record<ExportFormat, { label: string; hint?: string }> = {
|
||||
html: { label: 'HTML', hint: '推荐' },
|
||||
csv: { label: 'CSV' },
|
||||
json: { label: 'JSON' },
|
||||
markdown: { label: 'Markdown' }
|
||||
}
|
||||
const formatOrder: ExportFormat[] = ['csv', 'html', 'json', 'markdown']
|
||||
|
||||
function displayName(contact: Contact | null): string {
|
||||
return contact?.m_nsNickName || contact?.m_nsUsrName || '未选择会话'
|
||||
}
|
||||
|
||||
function formatPreviewTime(message: Message): string {
|
||||
if (!message.createTime) return message.datetime || ''
|
||||
return new Date(message.createTime * 1000).toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
export function ExportWorkspace({
|
||||
contacts,
|
||||
selectedContact,
|
||||
previewMessages,
|
||||
selfInfo,
|
||||
dbReady,
|
||||
onSelectContact,
|
||||
onOpenSettings,
|
||||
exportTasks,
|
||||
onStartExport,
|
||||
onCancelExport
|
||||
}: ExportWorkspaceProps): React.ReactElement {
|
||||
const [contactFilter, setContactFilter] = useState('')
|
||||
const [contactType, setContactType] = useState<'all' | 'group' | 'user'>('all')
|
||||
const [range, setRange] = useState<ExportRange>('today')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [endDate, setEndDate] = useState('')
|
||||
const [selectedKinds, setSelectedKinds] = useState<Set<string>>(() => new Set(['text']))
|
||||
const [nameMode, setNameMode] = useState<ExportNameMode>('remark')
|
||||
const [groupMembers, setGroupMembers] = useState<GroupMemberName[]>([])
|
||||
const [includeMedia, setIncludeMedia] = useState(true)
|
||||
const [includeAvatars, setIncludeAvatars] = useState(true)
|
||||
const [preferOriginal, setPreferOriginal] = useState(true)
|
||||
const [fallbackThumbnail, setFallbackThumbnail] = useState(true)
|
||||
const [keepMissing, setKeepMissing] = useState(false)
|
||||
const [format, setFormat] = useState<ExportFormat>('csv')
|
||||
const [zip, setZip] = useState(false)
|
||||
const [fileName, setFileName] = useState('')
|
||||
const [status, setStatus] = useState<ExportStatus>('idle')
|
||||
const [jobId, setJobId] = useState('')
|
||||
const [progress, setProgress] = useState<ExportJobProgress | null>(null)
|
||||
const [taskCenterOpen, setTaskCenterOpen] = useState(false)
|
||||
|
||||
const filteredContacts = useMemo(() => {
|
||||
const keyword = contactFilter.trim().toLowerCase()
|
||||
return contacts.filter((contact) => {
|
||||
if (contactType !== 'all' && contact.type !== contactType) return false
|
||||
if (!keyword) return true
|
||||
return [contact.m_nsNickName, contact.m_nsUsrName].some((value) =>
|
||||
value.toLowerCase().includes(keyword)
|
||||
)
|
||||
})
|
||||
}, [contactFilter, contactType, contacts])
|
||||
|
||||
const activeContact = selectedContact || filteredContacts[0] || contacts[0] || null
|
||||
const currentTask = exportTasks.find((task) => task.contactId === activeContact?.md5)
|
||||
const taskCount = exportTasks.filter((task) => task.status === 'running').length
|
||||
const activeName = displayName(activeContact)
|
||||
const preview = previewMessages.slice(-20)
|
||||
const previewMediaCount = preview.filter((message) =>
|
||||
['image', 'video', 'voice', 'sticker'].includes(message.contentData?.type || '')
|
||||
).length
|
||||
const previewBytes = preview.reduce(
|
||||
(total, message) => total + (message.content?.length || 0) * 2 + (message.img ? 1024 : 0),
|
||||
0
|
||||
)
|
||||
const outputName = fileName.trim() || `${activeName}_聊天档案`
|
||||
const nameOptions: { value: ExportNameMode; label: string }[] =
|
||||
activeContact?.type === 'group'
|
||||
? [
|
||||
{ value: 'groupNickname', label: '群昵称' },
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
: [
|
||||
{ value: 'remark', label: '备注' },
|
||||
{ value: 'wechatNickname', label: '微信名' }
|
||||
]
|
||||
|
||||
const nameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.type === 'group') {
|
||||
for (const member of groupMembers) {
|
||||
const value =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
map[member.wxid] = value
|
||||
}
|
||||
} else if (activeContact) {
|
||||
map[activeContact.m_nsUsrName] =
|
||||
nameMode === 'remark'
|
||||
? activeContact.remark || activeContact.m_nsNickName || activeContact.m_nsUsrName
|
||||
: activeContact.wechatNickname || activeContact.m_nsUsrName
|
||||
}
|
||||
if (selfInfo?.wxid) map[selfInfo.wxid] = selfInfo.nickname || selfInfo.wxid
|
||||
return map
|
||||
}, [activeContact, groupMembers, nameMode, selfInfo])
|
||||
|
||||
const avatarUrls = useMemo(() => {
|
||||
const map: Record<string, string> = {}
|
||||
if (activeContact?.m_nsUsrName && activeContact.avatar) {
|
||||
map[activeContact.m_nsUsrName] = activeContact.avatar
|
||||
}
|
||||
for (const member of groupMembers) {
|
||||
if (member.avatar) map[member.wxid] = member.avatar
|
||||
}
|
||||
if (selfInfo?.wxid && selfInfo.avatar) map[selfInfo.wxid] = selfInfo.avatar
|
||||
return map
|
||||
}, [activeContact, groupMembers, selfInfo])
|
||||
|
||||
const previewName = (message: Message): string =>
|
||||
(message.senderId && nameMap[message.senderId]) ||
|
||||
message.name ||
|
||||
(message.isSender ? selfInfo?.nickname : undefined) ||
|
||||
(message.isSender ? '我' : '联系人')
|
||||
const previewAvatar = (message: Message): string | undefined =>
|
||||
(message.senderId && avatarUrls[message.senderId]) ||
|
||||
message.img ||
|
||||
(message.isSender ? selfInfo?.avatar : undefined)
|
||||
const previewItems = preview.map((message) => ({
|
||||
...message,
|
||||
name: previewName(message),
|
||||
img: previewAvatar(message)
|
||||
}))
|
||||
|
||||
React.useEffect(() => {
|
||||
setNameMode(activeContact?.type === 'group' ? 'groupNickname' : 'remark')
|
||||
setGroupMembers([])
|
||||
if (!activeContact || activeContact.type !== 'group') return
|
||||
const timer = window.setTimeout(() => {
|
||||
void window.api.getGroupSnapshot(activeContact.md5).then((snapshot) => {
|
||||
setGroupMembers((snapshot?.members || []) as GroupMemberName[])
|
||||
})
|
||||
}, 300)
|
||||
return () => window.clearTimeout(timer)
|
||||
}, [activeContact])
|
||||
|
||||
const toggleKind = (value: string): void => {
|
||||
setSelectedKinds((current) => {
|
||||
const next = new Set(current)
|
||||
if (next.has(value)) next.delete(value)
|
||||
else next.add(value)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleStart = async (): Promise<void> => {
|
||||
if (!activeContact || status === 'running') return
|
||||
const nextJobId = `export-${Date.now()}`
|
||||
setJobId(nextJobId)
|
||||
setProgress(null)
|
||||
setStatus('running')
|
||||
let exportNameMap = nameMap
|
||||
let exportAvatarUrls = avatarUrls
|
||||
if (activeContact.type === 'group') {
|
||||
const snapshot = await window.api.getGroupSnapshot(activeContact.md5)
|
||||
const members = (snapshot?.members || []) as GroupMemberName[]
|
||||
setGroupMembers(members)
|
||||
exportNameMap = { ...nameMap }
|
||||
exportAvatarUrls = { ...avatarUrls }
|
||||
for (const member of members) {
|
||||
exportNameMap[member.wxid] =
|
||||
nameMode === 'groupNickname'
|
||||
? member.groupNickname || member.nickname || member.wxid
|
||||
: nameMode === 'remark'
|
||||
? member.remark || member.wechatNickname || member.wxid
|
||||
: member.wechatNickname || member.wxid
|
||||
if (member.avatar) exportAvatarUrls[member.wxid] = member.avatar
|
||||
}
|
||||
}
|
||||
const now = new Date()
|
||||
const endOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 1)
|
||||
const days = range === 'today' ? 1 : range === 'threeDays' ? 3 : range === 'sevenDays' ? 7 : 0
|
||||
const startOfRange = days
|
||||
? new Date(now.getFullYear(), now.getMonth(), now.getDate() - days + 1)
|
||||
: null
|
||||
const request = {
|
||||
jobId: nextJobId,
|
||||
userMd5: activeContact.md5,
|
||||
name: activeName,
|
||||
format,
|
||||
outputName,
|
||||
startTime: startOfRange
|
||||
? Math.floor(startOfRange.getTime() / 1000)
|
||||
: range === 'custom' && startDate
|
||||
? Math.floor(new Date(startDate).getTime() / 1000)
|
||||
: undefined,
|
||||
endTime: startOfRange
|
||||
? Math.floor(endOfToday.getTime() / 1000)
|
||||
: range === 'custom' && endDate
|
||||
? Math.floor(new Date(endDate).getTime() / 1000)
|
||||
: undefined,
|
||||
kinds: Array.from(selectedKinds) as ExportMessageKind[],
|
||||
includeMedia,
|
||||
includeAvatars,
|
||||
avatarUrls: exportAvatarUrls,
|
||||
nameMode,
|
||||
nameMap: exportNameMap,
|
||||
zip
|
||||
}
|
||||
const result = await onStartExport(request)
|
||||
if (result.success) {
|
||||
setProgress((current) => ({
|
||||
...(current || { jobId: nextJobId, processed: result.messageCount || 0 }),
|
||||
phase: 'completed',
|
||||
processed: result.messageCount ?? current?.processed ?? 0,
|
||||
total: result.messageCount ?? current?.total,
|
||||
percent: 100,
|
||||
outputPath: result.outputPath
|
||||
}))
|
||||
setStatus('completed')
|
||||
} else if (result.error !== '已取消') {
|
||||
setStatus('idle')
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(
|
||||
() =>
|
||||
window.api.onExportProgress((next) => {
|
||||
if (next.jobId !== jobId) return
|
||||
setProgress(next)
|
||||
if (next.phase === 'completed') setStatus('completed')
|
||||
if (next.phase === 'cancelled' || next.phase === 'failed') setStatus('idle')
|
||||
}),
|
||||
[jobId]
|
||||
)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentTask) return
|
||||
setJobId(currentTask.jobId)
|
||||
setProgress(currentTask.progress)
|
||||
setStatus(
|
||||
currentTask.status === 'running'
|
||||
? 'running'
|
||||
: currentTask.status === 'completed'
|
||||
? 'completed'
|
||||
: 'idle'
|
||||
)
|
||||
}, [currentTask])
|
||||
|
||||
const targetPath =
|
||||
format === 'html'
|
||||
? zip
|
||||
? `文稿/WechatExplorer/导出/${outputName}.zip`
|
||||
: `文稿/WechatExplorer/导出/${outputName}/`
|
||||
: `文稿/WechatExplorer/导出/${outputName}.${format === 'markdown' ? 'md' : format}`
|
||||
|
||||
return (
|
||||
<div className="export-workspace">
|
||||
<aside className="export-contact-panel">
|
||||
<div className="export-panel-header">
|
||||
<div className="export-panel-title-row">
|
||||
<h2>选择聊天</h2>
|
||||
<span className="export-count-badge">共 {contacts.length.toLocaleString()} 个</span>
|
||||
</div>
|
||||
<label className="export-search-field">
|
||||
<span aria-hidden>⌕</span>
|
||||
<input
|
||||
value={contactFilter}
|
||||
onChange={(event) => setContactFilter(event.target.value)}
|
||||
placeholder="搜索群聊、联系人或 wxid"
|
||||
aria-label="搜索聊天"
|
||||
/>
|
||||
</label>
|
||||
<div className="export-filter-tabs" role="tablist" aria-label="聊天类型">
|
||||
{(
|
||||
[
|
||||
['all', '全部'],
|
||||
['group', '群聊'],
|
||||
['user', '联系人']
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={contactType === value ? 'active' : ''}
|
||||
onClick={() => setContactType(value)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="export-contact-list">
|
||||
{filteredContacts.map((contact) => {
|
||||
const name = displayName(contact)
|
||||
return (
|
||||
<button
|
||||
key={contact.md5}
|
||||
type="button"
|
||||
className={`export-contact-item ${activeContact?.md5 === contact.md5 ? 'active' : ''}`}
|
||||
onClick={() => onSelectContact(contact)}
|
||||
>
|
||||
<span className="export-contact-avatar">
|
||||
{contact.avatar ? <img src={contact.avatar} alt="" /> : name.slice(0, 1)}
|
||||
</span>
|
||||
<span className="export-contact-copy">
|
||||
<strong>{name}</strong>
|
||||
<small>{contact.type === 'group' ? '群聊' : '联系人'}</small>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
<button type="button" className="export-account-summary" onClick={onOpenSettings}>
|
||||
<span className="export-account-avatar">
|
||||
{selfInfo?.avatar ? (
|
||||
<img src={selfInfo.avatar} alt="" />
|
||||
) : (
|
||||
(selfInfo?.nickname || '我').slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<strong>{selfInfo?.nickname || '当前账号'}</strong>
|
||||
<small className={dbReady ? 'ready' : ''}>
|
||||
{dbReady ? '数据库已连接' : '数据库未连接'}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
</aside>
|
||||
|
||||
<main className="export-config-panel">
|
||||
<div className="export-config-scroll">
|
||||
<button type="button" className="export-task-center-button" onClick={() => setTaskCenterOpen((open) => !open)}>
|
||||
任务中心{taskCount > 0 ? ` (${taskCount})` : ''}
|
||||
</button>
|
||||
{taskCenterOpen && (
|
||||
<section className="export-task-center">
|
||||
<div className="export-section-heading"><h3>导出任务</h3><span>{exportTasks.length} 条记录</span></div>
|
||||
{exportTasks.length === 0 ? <p>暂无导出记录</p> : exportTasks.map((task) => (
|
||||
<div className="export-task-row" key={task.jobId}>
|
||||
<span><strong>{task.contactName}</strong><small>{task.format.toUpperCase()} · {task.progress.phase}</small></span>
|
||||
<span className="export-task-progress"><i style={{ width: `${task.progress.percent ?? 0}%` }} /><b>{task.progress.percent ?? 0}%</b></span>
|
||||
{task.status === 'running' && <button type="button" onClick={() => void onCancelExport(task.jobId)}>取消</button>}
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
)}
|
||||
<header className="export-config-header">
|
||||
<span className="export-chat-avatar">
|
||||
{activeContact?.avatar ? (
|
||||
<img src={activeContact.avatar} alt="" />
|
||||
) : (
|
||||
activeName.slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span>
|
||||
<h1>导出设置</h1>
|
||||
<p>
|
||||
{activeName}
|
||||
{activeContact?.type === 'group' ? ' · 群聊' : ''}
|
||||
</p>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<section className="export-section export-format-top">
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.map((value) => (
|
||||
<button key={value} type="button" className={format === value ? 'active' : ''} onClick={() => setFormat(value)}>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="export-helper-text">CSV 默认最快;HTML 会包含图片、引用和其他媒体,导出时间可能较长。</p>
|
||||
{format === 'html' && (
|
||||
<div className="export-html-options">
|
||||
<label>
|
||||
<input type="radio" name="html-package-top" checked={!zip} onChange={() => setZip(false)} /> HTML 资源包
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="html-package-top" checked={zip} onChange={() => setZip(true)} /> HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<div className="export-section-heading">
|
||||
<h3>时间范围</h3>
|
||||
<span>{status === 'completed' ? '已完成导出' : '消息数量将在开始导出后统计'}</span>
|
||||
</div>
|
||||
<div className="export-range-toggle">
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'today' ? 'active' : ''}
|
||||
onClick={() => setRange('today')}
|
||||
>
|
||||
今天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'threeDays' ? 'active' : ''}
|
||||
onClick={() => setRange('threeDays')}
|
||||
>
|
||||
最近 3 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'sevenDays' ? 'active' : ''}
|
||||
onClick={() => setRange('sevenDays')}
|
||||
>
|
||||
最近 7 天
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={range === 'custom' ? 'active' : ''}
|
||||
onClick={() => setRange('custom')}
|
||||
>
|
||||
自定义时间
|
||||
</button>
|
||||
</div>
|
||||
{range === 'custom' && (
|
||||
<div className="export-date-fields">
|
||||
<label>
|
||||
开始时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={startDate}
|
||||
onChange={(event) => setStartDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
结束时间
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={endDate}
|
||||
onChange={(event) => setEndDate(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>消息内容</h3>
|
||||
<div className="export-kind-grid">
|
||||
{messageKinds.map(([value, label]) => (
|
||||
<label key={value} className={`export-check-row ${value === 'video' ? 'unsupported' : ''}`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value !== 'video' && selectedKinds.has(value)}
|
||||
disabled={value === 'video'}
|
||||
onChange={() => toggleKind(value)}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
{value === 'video' && (
|
||||
<span className="export-unsupported-hint" title="当前版本暂不支持视频导出" aria-label="当前版本暂不支持视频导出">
|
||||
!
|
||||
</span>
|
||||
)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>消息显示名称</h3>
|
||||
<div className="export-name-mode-grid" role="radiogroup" aria-label="消息显示名称">
|
||||
{nameOptions.map((option) => (
|
||||
<label key={option.value} className="export-name-mode-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="export-name-mode"
|
||||
checked={nameMode === option.value}
|
||||
onChange={() => setNameMode(option.value)}
|
||||
/>
|
||||
<span>{option.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>资源处理</h3>
|
||||
<label className="export-media-master">
|
||||
<span>包含图片、视频、语音及动态表情</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeMedia}
|
||||
disabled={format !== 'html'}
|
||||
onChange={(event) => setIncludeMedia(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className={`export-media-options ${includeMedia && format === 'html' ? '' : 'disabled'}`}>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preferOriginal}
|
||||
disabled={!includeMedia || format !== 'html'}
|
||||
onChange={(event) => setPreferOriginal(event.target.checked)}
|
||||
/>
|
||||
<span>优先导出原图</span>
|
||||
</label>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={fallbackThumbnail}
|
||||
disabled={!includeMedia || format !== 'html'}
|
||||
onChange={(event) => setFallbackThumbnail(event.target.checked)}
|
||||
/>
|
||||
<span>原图缺失时使用缩略图</span>
|
||||
</label>
|
||||
<label className="export-check-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={keepMissing}
|
||||
disabled={!includeMedia || format !== 'html'}
|
||||
onChange={(event) => setKeepMissing(event.target.checked)}
|
||||
/>
|
||||
<span>媒体缺失时保留占位说明</span>
|
||||
</label>
|
||||
</div>
|
||||
<p className="export-helper-text">资源文件仅在 HTML 导出中生效,CSV、JSON 和 Markdown 只保留文本内容。</p>
|
||||
<div className="export-resource-statuses">
|
||||
<span>图片解密:已就绪</span>
|
||||
<span>视频资源:可用</span>
|
||||
<span>语音资源:可用</span>
|
||||
<span>表情资源:按需解析</span>
|
||||
</div>
|
||||
<p className="export-helper-text">媒体资源会延长导出时间,缺失资源不会中断任务。</p>
|
||||
<label className="export-media-master">
|
||||
<span>在聊天气泡旁显示头像</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={includeAvatars}
|
||||
onChange={(event) => setIncludeAvatars(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="export-section">
|
||||
<h3>导出格式</h3>
|
||||
<div className="export-format-grid">
|
||||
{formatOrder.map((value) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
className={format === value ? 'active' : ''}
|
||||
onClick={() => setFormat(value)}
|
||||
>
|
||||
<strong>{formatLabels[value].label}</strong>
|
||||
{formatLabels[value].hint && <small>{formatLabels[value].hint}</small>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{format === 'html' && (
|
||||
<div className="export-html-options">
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={!zip}
|
||||
onChange={() => setZip(false)}
|
||||
/>{' '}
|
||||
HTML 资源包(推荐)
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
name="html-package"
|
||||
checked={zip}
|
||||
onChange={() => setZip(true)}
|
||||
/>{' '}
|
||||
HTML 资源包并压缩为 ZIP
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="export-section export-save-section">
|
||||
<h3>保存设置</h3>
|
||||
<label>
|
||||
文件名称
|
||||
<input
|
||||
value={fileName}
|
||||
onChange={(event) => setFileName(event.target.value)}
|
||||
placeholder={`${activeName}_聊天档案`}
|
||||
/>
|
||||
</label>
|
||||
<div className="export-target-path">
|
||||
<span>保存位置</span>
|
||||
<strong>{targetPath}</strong>
|
||||
<button type="button">选择位置</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<footer className="export-action-bar">
|
||||
<span className={`export-ready-dot ${status === 'completed' ? 'completed' : ''}`} />
|
||||
<span>
|
||||
{status === 'running'
|
||||
? '正在后台导出'
|
||||
: status === 'completed'
|
||||
? '导出完成'
|
||||
: '准备就绪'}
|
||||
</span>
|
||||
<span className="export-target-summary">路径:{targetPath}</span>
|
||||
<button type="button" className="export-reset-button" onClick={() => setStatus('idle')}>
|
||||
恢复默认
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-primary-button"
|
||||
disabled={!activeContact || status === 'running'}
|
||||
onClick={handleStart}
|
||||
>
|
||||
{status === 'running' ? '正在导出' : status === 'completed' ? '再次导出' : '开始导出'}
|
||||
</button>
|
||||
</footer>
|
||||
</main>
|
||||
|
||||
<aside className={`export-preview-panel ${status !== 'idle' ? `status-${status}` : ''}`}>
|
||||
{status === 'idle' && (
|
||||
<>
|
||||
<div className="export-preview-heading">
|
||||
<strong>导出预览</strong>
|
||||
<span>仅预览最近 20 条</span>
|
||||
</div>
|
||||
<div className="export-message-preview">
|
||||
<div className="export-preview-date">最近消息</div>
|
||||
{(previewItems.length
|
||||
? previewItems
|
||||
: [
|
||||
{
|
||||
id: 'empty',
|
||||
from: 'user',
|
||||
content: '导出预览将在这里显示',
|
||||
type: '文字',
|
||||
datetime: '',
|
||||
isSender: false
|
||||
}
|
||||
]
|
||||
).map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`export-preview-message ${message.isSender ? 'mine' : ''} ${
|
||||
message.contentData?.type === 'system' && message.contentData.pat ? 'system' : ''
|
||||
}`}
|
||||
>
|
||||
<span className="export-preview-avatar">
|
||||
{message.img || (message.isSender && selfInfo?.avatar) ? (
|
||||
<img src={message.img || selfInfo?.avatar} alt="" />
|
||||
) : (
|
||||
(message.isSender ? '我' : message.name || '友').slice(0, 1)
|
||||
)}
|
||||
</span>
|
||||
<span className="export-preview-bubble">
|
||||
<small>
|
||||
{message.name || (message.isSender ? '我' : '联系人')} ·{' '}
|
||||
{formatPreviewTime(message)}
|
||||
</small>
|
||||
{message.content || `[${message.type}]`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="export-preview-stats export-preview-real-stats">
|
||||
<span>
|
||||
预览消息<strong>{previewItems.length}</strong>
|
||||
</span>
|
||||
<span>
|
||||
媒体预览<strong>{previewMediaCount}</strong>
|
||||
</span>
|
||||
<span>
|
||||
预估文本大小<strong>{previewBytes < 1024 ? `${previewBytes} B` : `${(previewBytes / 1024).toFixed(1)} KB`}</strong>
|
||||
</span>
|
||||
</div>
|
||||
<div className="export-preview-stats">
|
||||
<span>
|
||||
消息总数<strong>待统计</strong>
|
||||
</span>
|
||||
<span>
|
||||
媒体文件<strong>待统计</strong>
|
||||
</span>
|
||||
<span>
|
||||
预计大小<strong>待统计</strong>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{status === 'running' && (
|
||||
<div className="export-job-state">
|
||||
<h2>正在导出</h2>
|
||||
<p>导出任务在后台运行,不影响档案浏览。</p>
|
||||
<ol>
|
||||
<li className="done">准备导出</li>
|
||||
<li className="current">
|
||||
{progress?.phase === 'writing' ? '生成档案' : '分批读取聊天记录'}
|
||||
</li>
|
||||
<li>解析消息内容</li>
|
||||
<li>处理媒体资源</li>
|
||||
<li>生成档案</li>
|
||||
</ol>
|
||||
<div className="export-progress-bar" aria-label="导出进度">
|
||||
<span style={{ width: `${progress?.percent ?? 0}%` }} />
|
||||
</div>
|
||||
<strong>
|
||||
{progress?.phase === 'writing'
|
||||
? `正在写入 ${progress.processed.toLocaleString()} 条消息... ${progress.percent ?? 0}%`
|
||||
: `正在读取消息... ${progress?.percent ?? 0}%`}
|
||||
</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="export-cancel-button"
|
||||
onClick={() => {
|
||||
void window.api.cancelExport(jobId)
|
||||
setStatus('idle')
|
||||
}}
|
||||
>
|
||||
取消导出
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{status === 'completed' && (
|
||||
<div className="export-job-state completed">
|
||||
<div className="export-success-icon">✓</div>
|
||||
<h2>导出完成</h2>
|
||||
<p>聊天档案已成功保存。</p>
|
||||
<div className="export-complete-summary">
|
||||
<span>
|
||||
导出消息<strong>{progress?.processed.toLocaleString() || '已完成'}</strong>
|
||||
</span>
|
||||
<span>
|
||||
媒体资源<strong>按设置处理</strong>
|
||||
</span>
|
||||
<span>
|
||||
输出位置<strong>已保存</strong>
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="export-primary-button"
|
||||
onClick={() =>
|
||||
progress?.outputPath && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
打开档案
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="export-open-folder-button"
|
||||
onClick={() =>
|
||||
progress?.outputPath && void window.api.revealExport(progress.outputPath)
|
||||
}
|
||||
>
|
||||
在文件夹中显示
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
export type LoadedImage = {
|
||||
data: string
|
||||
isThumbnail: boolean
|
||||
}
|
||||
|
||||
export type ImageLoadOptions = {
|
||||
force?: boolean
|
||||
preferThumbnail?: boolean
|
||||
}
|
||||
|
||||
type QueueItem = {
|
||||
priority: number
|
||||
run: () => Promise<LoadedImage>
|
||||
resolve: (value: LoadedImage) => void
|
||||
reject: (error: Error) => void
|
||||
}
|
||||
|
||||
const MAX_CONCURRENT_IMAGE_LOADS = 3
|
||||
const MAX_IMAGE_CACHE_BYTES = 48 * 1024 * 1024
|
||||
const imageCache = new Map<string, LoadedImage>()
|
||||
const imageCacheSizes = new Map<string, number>()
|
||||
const imageRequests = new Map<string, Promise<LoadedImage>>()
|
||||
const imageQueue: QueueItem[] = []
|
||||
let activeImageLoads = 0
|
||||
let imageCacheBytes = 0
|
||||
|
||||
function imageIdentityKeys(imageMd5?: string, imageDatName?: string): string[] {
|
||||
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
|
||||
Boolean
|
||||
)
|
||||
}
|
||||
|
||||
function cacheMode(options: ImageLoadOptions): string {
|
||||
if (options.force) return 'original'
|
||||
if (options.preferThumbnail) return 'thumbnail'
|
||||
return 'auto'
|
||||
}
|
||||
|
||||
function cacheKeys(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): string[] {
|
||||
return imageIdentityKeys(imageMd5, imageDatName).map(
|
||||
(identity) => `${identity}:${cacheMode(options)}`
|
||||
)
|
||||
}
|
||||
|
||||
function getCachedImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions
|
||||
): LoadedImage | undefined {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
for (const key of keys) {
|
||||
const cached = imageCache.get(key)
|
||||
if (!cached) continue
|
||||
imageCache.delete(key)
|
||||
imageCache.set(key, cached)
|
||||
return cached
|
||||
}
|
||||
|
||||
if (!options.force && options.preferThumbnail) {
|
||||
for (const identity of imageIdentityKeys(imageMd5, imageDatName)) {
|
||||
const fallbackKey = `${identity}:auto`
|
||||
const cached = imageCache.get(fallbackKey)
|
||||
if (cached) return cached
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function cacheImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
options: ImageLoadOptions,
|
||||
image: LoadedImage
|
||||
): void {
|
||||
const keys = cacheKeys(imageMd5, imageDatName, options)
|
||||
const size = image.data.length * 2
|
||||
for (const key of keys) {
|
||||
const previousSize = imageCacheSizes.get(key) || 0
|
||||
imageCacheBytes -= previousSize
|
||||
imageCache.delete(key)
|
||||
imageCacheSizes.delete(key)
|
||||
imageCache.set(key, image)
|
||||
imageCacheSizes.set(key, size)
|
||||
imageCacheBytes += size
|
||||
}
|
||||
|
||||
while (imageCacheBytes > MAX_IMAGE_CACHE_BYTES && imageCache.size > 1) {
|
||||
const oldestKey = imageCache.keys().next().value
|
||||
if (!oldestKey) break
|
||||
imageCache.delete(oldestKey)
|
||||
imageCacheBytes -= imageCacheSizes.get(oldestKey) || 0
|
||||
imageCacheSizes.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
function pumpImageQueue(): void {
|
||||
while (activeImageLoads < MAX_CONCURRENT_IMAGE_LOADS && imageQueue.length > 0) {
|
||||
imageQueue.sort((left, right) => left.priority - right.priority)
|
||||
const item = imageQueue.shift()
|
||||
if (!item) return
|
||||
activeImageLoads += 1
|
||||
void item
|
||||
.run()
|
||||
.then(item.resolve, item.reject)
|
||||
.finally(() => {
|
||||
activeImageLoads -= 1
|
||||
pumpImageQueue()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export function getCachedLoadedImage(
|
||||
imageMd5?: string,
|
||||
imageDatName?: string,
|
||||
options: ImageLoadOptions = {}
|
||||
): LoadedImage | undefined {
|
||||
return getCachedImage(imageMd5, imageDatName, options)
|
||||
}
|
||||
|
||||
export function requestImage(
|
||||
imageMd5: string | undefined,
|
||||
imageDatName: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
options: ImageLoadOptions = {},
|
||||
priority = 0
|
||||
): Promise<LoadedImage> {
|
||||
const cached = getCachedImage(imageMd5, imageDatName, options)
|
||||
if (cached) return Promise.resolve(cached)
|
||||
|
||||
const identity = imageIdentityKeys(imageMd5, imageDatName)[0]
|
||||
if (!identity) return Promise.reject(new Error('缺少图片标识'))
|
||||
const requestKey = `${identity}:${cacheMode(options)}`
|
||||
const existingRequest = imageRequests.get(requestKey)
|
||||
if (existingRequest) return existingRequest
|
||||
|
||||
const request = new Promise<LoadedImage>((resolve, reject) => {
|
||||
imageQueue.push({
|
||||
priority,
|
||||
resolve,
|
||||
reject,
|
||||
run: async () => {
|
||||
const result = await window.api.getImage(imageMd5, imageDatName, sessionId, options)
|
||||
if (!result.success || !result.data?.startsWith('data:image/')) {
|
||||
throw new Error(result.error || '加载图片失败')
|
||||
}
|
||||
const loadedImage = {
|
||||
data: result.data,
|
||||
isThumbnail: Boolean(result.isThumb)
|
||||
}
|
||||
cacheImage(imageMd5, imageDatName, options, loadedImage)
|
||||
return loadedImage
|
||||
}
|
||||
})
|
||||
pumpImageQueue()
|
||||
})
|
||||
imageRequests.set(requestKey, request)
|
||||
void request.then(
|
||||
() => imageRequests.delete(requestKey),
|
||||
() => imageRequests.delete(requestKey)
|
||||
)
|
||||
return request
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { ReportGenerationPhase } from '../../hooks/useGroupReportGeneration'
|
||||
|
||||
interface ReportTaskStatusPanelProps {
|
||||
@@ -27,6 +27,14 @@ export function ReportTaskStatusPanel({
|
||||
}: ReportTaskStatusPanelProps): React.ReactElement {
|
||||
const activeIndex = phaseIndex(phase)
|
||||
const completedAll = phase === 'success'
|
||||
const [logPath, setLogPath] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
void window.api
|
||||
.getAppLogPath()
|
||||
.then(setLogPath)
|
||||
.catch(() => undefined)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<aside className="report-task-panel">
|
||||
@@ -70,6 +78,10 @@ export function ReportTaskStatusPanel({
|
||||
<button type="button" onClick={onRetry}>
|
||||
重试
|
||||
</button>
|
||||
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||
打开诊断日志
|
||||
</button>
|
||||
{logPath && <small className="report-task-log-path">{logPath}</small>}
|
||||
</div>
|
||||
)}
|
||||
{phase === 'success' && (
|
||||
|
||||
@@ -37,10 +37,7 @@ export function ReportViewer({
|
||||
return () => window.cancelAnimationFrame(frame)
|
||||
}, [report?.id])
|
||||
|
||||
const title = useMemo(
|
||||
() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'),
|
||||
[report]
|
||||
)
|
||||
const title = useMemo(() => (report ? `${report.contactName} 群聊日报` : 'AI 日报'), [report])
|
||||
|
||||
const fitWidth = (): void => {
|
||||
const viewport = viewportRef.current
|
||||
@@ -108,10 +105,16 @@ export function ReportViewer({
|
||||
}}
|
||||
onLoad={(event) => {
|
||||
const image = event.currentTarget
|
||||
setNaturalSize({
|
||||
const nextSize = {
|
||||
width: image.naturalWidth,
|
||||
height: image.naturalHeight
|
||||
})
|
||||
}
|
||||
setNaturalSize(nextSize)
|
||||
const viewport = viewportRef.current
|
||||
if (viewport && nextSize.width > viewport.clientWidth - 48) {
|
||||
const fittedZoom = Math.max(0.25, (viewport.clientWidth - 48) / nextSize.width)
|
||||
setZoom(Number(fittedZoom.toFixed(2)))
|
||||
}
|
||||
}}
|
||||
onError={() => {
|
||||
setImageError('日报图片加载失败')
|
||||
|
||||
@@ -195,8 +195,9 @@ export function AgentHubWorkspace(): React.ReactElement {
|
||||
<h2>微信数据助手</h2>
|
||||
<p>机器人通过本机 Agent Hub 调用 WechatExplorer,不向公网暴露数据库。</p>
|
||||
<div className="agent-hub-example">
|
||||
<span>可以这样问</span>
|
||||
<span>支持自然语言,可以这样问</span>
|
||||
<strong>“最近 5 条消息是谁?”</strong>
|
||||
<strong>“帮我看看最近跟xx聊了些什么”</strong>
|
||||
<strong>“生成产品交流群今天的群聊总结图片”</strong>
|
||||
</div>
|
||||
<ul>
|
||||
@@ -212,6 +213,10 @@ export function AgentHubWorkspace(): React.ReactElement {
|
||||
<i />
|
||||
消息重复保护
|
||||
</li>
|
||||
<li>
|
||||
<i />
|
||||
使用已配置 AI 理解自然语言
|
||||
</li>
|
||||
<li>
|
||||
<i className={status.dataApi === 'online' ? '' : 'offline'} />
|
||||
本地数据 API:{status.dataApi === 'online' ? '已连接' : '未连接'}
|
||||
|
||||
@@ -6,6 +6,8 @@ import { AccountDatabasePage } from './pages/AccountDatabasePage'
|
||||
import { DatabaseKeyPage } from './pages/DatabaseKeyPage'
|
||||
import { ImageDecryptionPage } from './pages/ImageDecryptionPage'
|
||||
import { AIModelPage } from './pages/AIModelPage'
|
||||
import { RecallProtectionPage } from './pages/RecallProtectionPage'
|
||||
import { AdvancedPage } from './pages/AdvancedPage'
|
||||
import type { Contact } from '../../../../shared/types'
|
||||
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
|
||||
|
||||
@@ -49,14 +51,17 @@ export function SettingsWorkspace({
|
||||
dbReady={dbReady}
|
||||
onOpenSettings={onOpenSettings}
|
||||
/>
|
||||
{selectedCategory === 'account-database' ? (
|
||||
<div
|
||||
className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}
|
||||
>
|
||||
<AccountDatabasePage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
selfInfo={selfInfo}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : selectedCategory === 'database-key' ? (
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
|
||||
<DatabaseKeyPage
|
||||
dbKey={dbKey}
|
||||
dbReady={dbReady}
|
||||
@@ -69,12 +74,32 @@ export function SettingsWorkspace({
|
||||
onReturnToLogin={onReturnToLogin}
|
||||
onNotice={onNotice}
|
||||
/>
|
||||
) : selectedCategory === 'image-key' ? (
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'image-key' ? 'active' : ''}`}>
|
||||
<ImageDecryptionPage selfInfo={selfInfo} onNotice={onNotice} />
|
||||
) : selectedCategory === 'ai-model' ? (
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'ai-model' ? 'active' : ''}`}>
|
||||
<AIModelPage onRuntimeChange={onAIRuntimeChange} onNotice={onNotice} />
|
||||
) : (
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
</div>
|
||||
<div
|
||||
className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}
|
||||
>
|
||||
<RecallProtectionPage onNotice={onNotice} />
|
||||
</div>
|
||||
<div className={`settings-page-panel ${selectedCategory === 'advanced' ? 'active' : ''}`}>
|
||||
<AdvancedPage onNotice={onNotice} />
|
||||
</div>
|
||||
{![
|
||||
'account-database',
|
||||
'database-key',
|
||||
'image-key',
|
||||
'ai-model',
|
||||
'recall-protection',
|
||||
'advanced'
|
||||
].includes(selectedCategory) && (
|
||||
<div className="settings-page-panel active">
|
||||
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -191,7 +191,9 @@ export function useDatabaseKeyController({
|
||||
])
|
||||
|
||||
const returnToLogin = useCallback(async (): Promise<void> => {
|
||||
const result = await window.api.disconnectDb()
|
||||
// macOS WCDB 的 close 会关闭进程级原生运行时,返回登录时只退出 UI 连接态。
|
||||
// 用户再次点击连接后由 db:init 复用已验证的本机连接。
|
||||
const result = await window.api.disconnectDb({ closeNative: false })
|
||||
if (!result.success) {
|
||||
onNotice(result.error || '断开数据库连接失败')
|
||||
return
|
||||
|
||||
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
|
||||
{state.status.platform === 'darwin'
|
||||
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
|
||||
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
|
||||
<br />
|
||||
<small className="image-auto-scope-hint">
|
||||
仅支持 WeChat 4.0,V3 及以下无法解析
|
||||
</small>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -7,19 +7,10 @@ export function ImageKeyConfiguration({
|
||||
}: {
|
||||
state: ImageDecryptionState
|
||||
disabled: boolean
|
||||
onEdit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
onEdit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<section className="settings-card image-key-editor">
|
||||
<label>
|
||||
<span>图片资源目录</span>
|
||||
<input
|
||||
value={state.resourceRoot}
|
||||
disabled={disabled}
|
||||
title={state.resourceRoot}
|
||||
onChange={(event) => onEdit('resourceRoot', event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="image-key-grid">
|
||||
<label>
|
||||
<span>XOR Key</span>
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import type { ImageDecryptionState } from './types'
|
||||
|
||||
type StepState = 'pending' | 'ok' | 'fail' | 'skipped'
|
||||
|
||||
function stepClass(step: StepState): string {
|
||||
switch (step) {
|
||||
case 'ok':
|
||||
return 'image-step image-step-ok'
|
||||
case 'fail':
|
||||
return 'image-step image-step-fail'
|
||||
case 'skipped':
|
||||
return 'image-step image-step-skipped'
|
||||
default:
|
||||
return 'image-step image-step-pending'
|
||||
}
|
||||
}
|
||||
|
||||
function stepIcon(step: StepState): string {
|
||||
switch (step) {
|
||||
case 'ok':
|
||||
return '✓'
|
||||
case 'fail':
|
||||
return '×'
|
||||
case 'skipped':
|
||||
return '·'
|
||||
default:
|
||||
return '○'
|
||||
}
|
||||
}
|
||||
|
||||
function pickSteps(result: {
|
||||
fileFound: boolean
|
||||
decrypted: boolean
|
||||
readable: boolean
|
||||
success: boolean
|
||||
}): { find: StepState; decrypt: StepState; read: StepState } {
|
||||
// 三步严格联动:找到失败 → 解密/读取 skipped;解密失败 → 读取 skipped;
|
||||
// 解密成功但不可读 → 读取 fail。
|
||||
if (!result.fileFound) {
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.success && !result.decrypted && !result.readable) {
|
||||
// 后端把 fileFound=false 的情况也用 success:false 表达;
|
||||
// 此时第一步直接 fail,后两步 skip。
|
||||
return { find: 'fail', decrypt: 'skipped', read: 'skipped' }
|
||||
}
|
||||
if (!result.decrypted) {
|
||||
return { find: 'ok', decrypt: 'fail', read: 'skipped' }
|
||||
}
|
||||
if (!result.readable) {
|
||||
return { find: 'ok', decrypt: 'ok', read: 'fail' }
|
||||
}
|
||||
return { find: 'ok', decrypt: 'ok', read: 'ok' }
|
||||
}
|
||||
|
||||
export function ImageTestSection({
|
||||
state,
|
||||
disabled,
|
||||
@@ -16,6 +69,14 @@ export function ImageTestSection({
|
||||
onSave: () => void
|
||||
}): React.ReactElement {
|
||||
const result = state.testResult
|
||||
const steps = result
|
||||
? pickSteps({
|
||||
fileFound: result.fileFound,
|
||||
decrypted: result.decrypted,
|
||||
readable: result.readable,
|
||||
success: result.success
|
||||
})
|
||||
: null
|
||||
return (
|
||||
<section className="settings-card image-test-section">
|
||||
<div>
|
||||
@@ -48,16 +109,27 @@ export function ImageTestSection({
|
||||
确认保存
|
||||
</button>
|
||||
</div>
|
||||
{result ? (
|
||||
<div className={`image-test-result ${result.success ? 'success' : 'error'}`}>
|
||||
<span>{result.fileFound ? '✓' : '×'} 找到图片文件</span>
|
||||
<span>{result.decrypted ? '✓' : '×'} 解密成功</span>
|
||||
<span>{result.readable ? '✓' : '×'} 图片可以读取</span>
|
||||
{!result.success ? <p>{result.error}</p> : null}
|
||||
</div>
|
||||
{steps ? (
|
||||
<ol className="image-step-list">
|
||||
<li className={stepClass(steps.find)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.find)}</span>
|
||||
<span>找到图片文件</span>
|
||||
</li>
|
||||
<li className={stepClass(steps.decrypt)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.decrypt)}</span>
|
||||
<span>解密成功</span>
|
||||
</li>
|
||||
<li className={stepClass(steps.read)}>
|
||||
<span className="image-step-icon">{stepIcon(steps.read)}</span>
|
||||
<span>图片可以读取</span>
|
||||
</li>
|
||||
</ol>
|
||||
) : state.error ? (
|
||||
<p className="image-inline-error">{state.error}</p>
|
||||
) : null}
|
||||
{result && !result.success && result.error ? (
|
||||
<p className="image-inline-error">{result.error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -27,7 +27,11 @@ export function imageDecryptionReducer(
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: action.contacts,
|
||||
resourceRoot: action.config.resourceRoot,
|
||||
// 下方"图片资源目录"跟随状态面板的默认目录同步;
|
||||
// 一旦用户手动编辑过(dirty),就不再被刷新覆盖。
|
||||
resourceRoot: state.dirty && state.resourceRoot
|
||||
? state.resourceRoot
|
||||
: action.status.resourceRoot || action.config.resourceRoot,
|
||||
xorKey: action.config.xorKey || '0x40',
|
||||
aesKey: action.config.aesKey || '',
|
||||
error: action.config.success ? undefined : action.config.error,
|
||||
@@ -126,7 +130,7 @@ export function imageDecryptionReducer(
|
||||
config: action.config,
|
||||
status: action.status,
|
||||
contacts: state.contacts,
|
||||
resourceRoot: action.config.resourceRoot
|
||||
resourceRoot: action.status.resourceRoot || action.config.resourceRoot
|
||||
}
|
||||
default:
|
||||
return state
|
||||
|
||||
@@ -53,7 +53,7 @@ export type ImageDecryptionAction =
|
||||
contacts: Contact[]
|
||||
}
|
||||
| { type: 'LOAD_ERROR'; error: string }
|
||||
| { type: 'EDIT'; field: 'resourceRoot' | 'xorKey' | 'aesKey'; value: string }
|
||||
| { type: 'EDIT'; field: 'xorKey' | 'aesKey'; value: string }
|
||||
| { type: 'SELECT_CHAT'; userMd5: string }
|
||||
| { type: 'TEST_START' }
|
||||
| { type: 'TEST_DONE'; result: ImageDecryptionTestResult }
|
||||
@@ -80,7 +80,7 @@ export interface ImageDecryptionController {
|
||||
pageStatus: 'configured' | 'unconfigured' | 'partial'
|
||||
busy: boolean
|
||||
canSave: boolean
|
||||
edit: (field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string) => void
|
||||
edit: (field: 'xorKey' | 'aesKey', value: string) => void
|
||||
selectChat: (userMd5: string) => void
|
||||
test: () => Promise<void>
|
||||
save: () => Promise<void>
|
||||
|
||||
@@ -33,7 +33,7 @@ export function useImageDecryptionController({
|
||||
})
|
||||
}, [refresh])
|
||||
|
||||
const edit = useCallback((field: 'resourceRoot' | 'xorKey' | 'aesKey', value: string): void => {
|
||||
const edit = useCallback((field: 'xorKey' | 'aesKey', value: string): void => {
|
||||
dispatch({ type: 'EDIT', field, value })
|
||||
}, [])
|
||||
|
||||
@@ -84,12 +84,17 @@ export function useImageDecryptionController({
|
||||
dispatch({ type: 'AUTO_START' })
|
||||
const result = await window.api.autoGetImageKey({ save: false })
|
||||
if (!result.success || !result.aesKey || !result.verified) {
|
||||
dispatch({
|
||||
type: 'AUTO_ERROR',
|
||||
error: result.success
|
||||
? '获取到候选密钥,但未通过图片验证'
|
||||
: sanitizeImageError(result.error)
|
||||
})
|
||||
// 自动获取链路:原文透传后端错误信息,不要走 sanitizeImageError。
|
||||
// sanitizeImageError 是给"测试图片解析"设计的字典,会把
|
||||
// "未找到 V2 模板文件 / 微信进程未运行 / 60 秒未扫描到密钥" 等
|
||||
// 完全合法的扫描阶段错误强制翻成"无法解析媒体文件"。
|
||||
const rawError = (result.error || '').toString().trim()
|
||||
const errorMessage = !result.success
|
||||
? rawError || '自动获取图片密钥失败'
|
||||
: !result.aesKey
|
||||
? '自动获取未返回 AES 密钥'
|
||||
: '获取到候选密钥,但未通过图片验证'
|
||||
dispatch({ type: 'AUTO_ERROR', error: errorMessage })
|
||||
return
|
||||
}
|
||||
dispatch({
|
||||
|
||||
@@ -11,11 +11,17 @@ export function formatImageConfigTime(value?: number): string {
|
||||
|
||||
export function sanitizeImageError(error?: string): string {
|
||||
const value = String(error || '').toLowerCase()
|
||||
if (value.includes('no_image_message') || value.includes('300')) {
|
||||
return '当前会话最近 300 条消息内没有图片,请换一个含图片的聊天再测试'
|
||||
}
|
||||
if (value.includes('unsupported') || value.includes('dat version')) {
|
||||
return '仅支持 WeChat 4.0 图片协议,V3 及以下无法解析'
|
||||
}
|
||||
if (value.includes('key') || value.includes('密钥')) return '图片密钥未配置或与当前账号不匹配'
|
||||
if (value.includes('不存在') || value.includes('not found')) return '图片文件不存在'
|
||||
if (value.includes('账号')) return '当前账号不匹配'
|
||||
if (value.includes('目录')) return '图片资源目录不可用'
|
||||
return error ? '无法解析媒体文件' : '图片解析测试未通过'
|
||||
return error ? '无法解析媒体文件(仅支持 WeChat 4.0)' : '图片解析测试未通过'
|
||||
}
|
||||
|
||||
export function normalizeAutoXorKey(value?: number, formatted?: string): string {
|
||||
|
||||
@@ -18,7 +18,6 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
label: '智能能力',
|
||||
items: [
|
||||
{ id: 'ai-model', label: 'AI 模型' },
|
||||
{ id: 'local-api', label: '本地 API' }
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -31,6 +30,7 @@ export const SETTINGS_NAVIGATION: SettingsNavigationGroup[] = [
|
||||
{
|
||||
label: '应用',
|
||||
items: [
|
||||
{ id: 'recall-protection', label: '防撤回' },
|
||||
{ id: 'appearance', label: '外观与行为' },
|
||||
{ id: 'advanced', label: '高级' },
|
||||
{ id: 'about', label: '关于' }
|
||||
|
||||
@@ -3,6 +3,7 @@ export type SettingsCategoryId =
|
||||
| 'database-key'
|
||||
| 'image-key'
|
||||
| 'ai-model'
|
||||
| 'recall-protection'
|
||||
| 'local-api'
|
||||
| 'storage-export'
|
||||
| 'cache-cleanup'
|
||||
|
||||
@@ -31,7 +31,8 @@ export function AccountDatabasePage({
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSettings().then((result) => {
|
||||
if (active) setAutoLogin(result.settings.autoLogin)
|
||||
if (!active) return
|
||||
setAutoLogin(result.settings.autoLogin)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
@@ -46,6 +47,7 @@ export function AccountDatabasePage({
|
||||
setAutoLogin(result.settings.autoLogin)
|
||||
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function AdvancedPage({
|
||||
onNotice
|
||||
}: {
|
||||
onNotice: (message: string) => void
|
||||
}): React.ReactElement {
|
||||
const [debugEnabled, setDebugEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSettings().then((result) => {
|
||||
if (active) setDebugEnabled(result.settings.debugEnabled)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeDebugEnabled = async (checked: boolean): Promise<void> => {
|
||||
const result = await window.api.setSettings({ debugEnabled: checked })
|
||||
setDebugEnabled(result.settings.debugEnabled)
|
||||
onNotice(checked ? '已开启调试日志' : '已关闭调试日志')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>高级</h1>
|
||||
<p>用于开发人员排查本地数据库和检索问题</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content">
|
||||
<h2 className="settings-section-heading">诊断</h2>
|
||||
<section className="settings-card settings-debug-card">
|
||||
<label>
|
||||
<span>
|
||||
<b>显示诊断日志</b>
|
||||
<small>开启后,检索页显示诊断日志入口并记录匹配统计,不记录聊天正文。</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={debugEnabled}
|
||||
onChange={(event) => void changeDebugEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-debug-actions">
|
||||
<button type="button" onClick={() => void window.api.revealAppLog()}>
|
||||
打开诊断日志
|
||||
</button>
|
||||
<small>关闭调试日志后,仍会保留错误和崩溃日志。</small>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
export function RecallProtectionPage({ onNotice }: { onNotice: (message: string) => void }): React.ReactElement {
|
||||
const [enabled, setEnabled] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void window.api.getSettings().then((result) => {
|
||||
if (active) setEnabled(result.settings.recallProtectionEnabled)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const changeEnabled = async (checked: boolean): Promise<void> => {
|
||||
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
|
||||
setEnabled(result.settings.recallProtectionEnabled)
|
||||
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="settings-page">
|
||||
<header className="settings-page-header">
|
||||
<div>
|
||||
<h1>防撤回</h1>
|
||||
<p>尽量保留已撤回的聊天内容,并在聊天记录中明确标记。</p>
|
||||
</div>
|
||||
</header>
|
||||
<div className="settings-page-scroll">
|
||||
<div className="settings-page-content">
|
||||
<h2 className="settings-section-heading">消息处理</h2>
|
||||
<section className="settings-card settings-recall-card">
|
||||
<div className="settings-recall-grid">
|
||||
<label className="settings-recall-option">
|
||||
<span>
|
||||
<b>开启防撤回</b>
|
||||
<small>尽量保留已撤回的聊天内容,方便后续查看。</small>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
onChange={(event) => void changeEnabled(event.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<aside className="settings-recall-notice">
|
||||
<strong>性能提示</strong>
|
||||
<span>开启后会为消息表增加监听,数据量较大或磁盘较慢时可能让加载变慢。</span>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { Contact, Message } from '../../../shared/types'
|
||||
import {
|
||||
buildGroupReportInput,
|
||||
getSummaryDateRange,
|
||||
GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT,
|
||||
GROUP_REPORT_SYSTEM_PROMPT,
|
||||
isInternalName,
|
||||
parseGroupDailyReport,
|
||||
@@ -108,6 +109,31 @@ const withTimeout = async <T>(
|
||||
const errorMessage = (error: unknown): string =>
|
||||
error instanceof Error ? error.message : String(error)
|
||||
|
||||
const writeReportLog = (
|
||||
level: 'info' | 'warn' | 'error',
|
||||
message: string,
|
||||
details?: Record<string, unknown>
|
||||
): void => {
|
||||
void window.api
|
||||
.writeAppLog({ level, scope: 'group-report', message, details })
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
const jsonErrorContext = (raw: string, error: unknown): Record<string, unknown> => {
|
||||
const message = errorMessage(error)
|
||||
const position = Number(/\bposition\s+(\d+)/i.exec(message)?.[1])
|
||||
const safePosition = Number.isFinite(position) ? Math.max(0, Math.min(raw.length, position)) : 0
|
||||
return {
|
||||
error: message,
|
||||
outputLength: raw.length,
|
||||
position: Number.isFinite(position) ? position : undefined,
|
||||
context:
|
||||
raw.length && Number.isFinite(position)
|
||||
? raw.slice(Math.max(0, safePosition - 300), Math.min(raw.length, safePosition + 300))
|
||||
: raw.slice(0, 600)
|
||||
}
|
||||
}
|
||||
|
||||
const isGroupContact = (contact: Contact | null): boolean =>
|
||||
Boolean(contact?.type === 'group' || contact?.m_nsUsrName?.endsWith('@chatroom'))
|
||||
|
||||
@@ -136,6 +162,16 @@ const estimateTokenUsage = (
|
||||
}
|
||||
}
|
||||
|
||||
const mergeTokenUsage = (
|
||||
first: NonNullable<ReportGenerationMetadata['tokenUsage']>,
|
||||
second: NonNullable<ReportGenerationMetadata['tokenUsage']>
|
||||
): NonNullable<ReportGenerationMetadata['tokenUsage']> => ({
|
||||
input: (first.input || 0) + (second.input || 0),
|
||||
output: (first.output || 0) + (second.output || 0),
|
||||
total: (first.total || 0) + (second.total || 0),
|
||||
estimated: Boolean(first.estimated || second.estimated)
|
||||
})
|
||||
|
||||
const applyGroupMemberNames = async (
|
||||
contact: Contact,
|
||||
messages: Message[],
|
||||
@@ -175,13 +211,18 @@ const applyGroupMemberNames = async (
|
||||
const member = memberMap.get(senderId)
|
||||
if (!member) return message
|
||||
const preferredNames: Record<ReportMemberNamePreference, string[]> = {
|
||||
groupNickname: [member.groupNickname, member.wechatNickname, member.remark, member.nickname],
|
||||
wechatNickname: [member.wechatNickname, member.groupNickname, member.remark, member.nickname],
|
||||
// Keep the three modes semantically distinct. `member.nickname` may
|
||||
// already be a contact remark, so it must not leak into the first two.
|
||||
groupNickname: [member.groupNickname, member.wechatNickname],
|
||||
wechatNickname: [member.wechatNickname, member.groupNickname],
|
||||
remark: [member.remark, member.groupNickname, member.wechatNickname, member.nickname]
|
||||
}
|
||||
const name = preferredNames[preference].find((value) => value && !isInternalName(value))
|
||||
if (!name) return message
|
||||
return { ...message, name, img: message.img || member.avatar }
|
||||
return {
|
||||
...message,
|
||||
name: name || (preference === 'remark' ? message.name : ''),
|
||||
img: message.img || member.avatar
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -222,11 +263,12 @@ export function useGroupReportGeneration({
|
||||
const [generatedImage, setGeneratedImage] = useState<string | null>(null)
|
||||
const [reportPaths, setReportPaths] = useState<ReportPaths | null>(null)
|
||||
const [templateId, setTemplateId] = useState<ReportTemplateId>('v1')
|
||||
const [memberNamePreference, setMemberNamePreferenceState] =
|
||||
useState<ReportMemberNamePreference>(() => {
|
||||
const [memberNamePreference, setMemberNamePreferenceState] = useState<ReportMemberNamePreference>(
|
||||
() => {
|
||||
const saved = localStorage.getItem('group_report_member_name_preference')
|
||||
return saved === 'wechatNickname' || saved === 'remark' ? saved : 'groupNickname'
|
||||
})
|
||||
}
|
||||
)
|
||||
const [reportTimeoutSeconds, setReportTimeoutSecondsState] = useState<number>(() => {
|
||||
const saved = Number(localStorage.getItem('group_report_timeout_seconds'))
|
||||
return Number.isFinite(saved) && saved >= 30 ? saved : 300
|
||||
@@ -352,6 +394,7 @@ export function useGroupReportGeneration({
|
||||
}
|
||||
|
||||
const startGenerateTime = Date.now()
|
||||
let failedAt = '初始化'
|
||||
const logs: ReportGenerationLog[] = []
|
||||
const pushLog = (log: ReportGenerationLog): void => {
|
||||
logs.push(log)
|
||||
@@ -382,15 +425,29 @@ export function useGroupReportGeneration({
|
||||
modelName: modelConfig.model,
|
||||
generationLogs: []
|
||||
})
|
||||
writeReportLog('info', '开始生成群聊日报', {
|
||||
groupName: sourceContact.m_nsNickName || sourceContact.m_nsUsrName,
|
||||
dateRange: summaryDateRange,
|
||||
selectedMessageTypes: summaryMessageTypes,
|
||||
providerName: modelConfig.providerName,
|
||||
model: modelConfig.model,
|
||||
templateId
|
||||
})
|
||||
|
||||
try {
|
||||
failedAt = '读取聊天记录'
|
||||
const sourceMessages = await trackStep('读取聊天记录', () => loadRangeMessages(true))
|
||||
|
||||
const selectedTypes = selectedMessageTypeSet(summaryMessageTypes)
|
||||
const filteredMessages = sourceMessages.filter((message) => selectedTypes.has(message.type))
|
||||
if (!filteredMessages.length) throw new Error('当前范围没有可总结消息')
|
||||
writeReportLog('info', '聊天记录读取完成', {
|
||||
sourceMessageCount: sourceMessages.length,
|
||||
filteredMessageCount: filteredMessages.length
|
||||
})
|
||||
|
||||
setPhase('preparingInput')
|
||||
failedAt = '整理日报输入'
|
||||
const input = await trackStep('整理输入', async () => {
|
||||
const namedReportMessages = await applyGroupMemberNames(
|
||||
sourceContact,
|
||||
@@ -401,6 +458,7 @@ export function useGroupReportGeneration({
|
||||
})
|
||||
|
||||
setPhase('requestingModel')
|
||||
failedAt = '调用模型生成内容'
|
||||
const aiMessages = [
|
||||
{ role: 'system', content: GROUP_REPORT_SYSTEM_PROMPT },
|
||||
{ role: 'user', content: input.prompt }
|
||||
@@ -417,22 +475,82 @@ export function useGroupReportGeneration({
|
||||
)
|
||||
)
|
||||
if (!result.success || !result.data) throw new Error(result.error || 'AI 请求失败')
|
||||
writeReportLog('info', '模型响应完成', {
|
||||
outputLength: result.data.length,
|
||||
usage: result.usage
|
||||
})
|
||||
|
||||
const tokenUsage =
|
||||
let tokenUsage =
|
||||
result.usage && result.usage.total
|
||||
? result.usage
|
||||
: estimateTokenUsage(aiMessages, result.data)
|
||||
|
||||
const report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
let report: ReturnType<typeof parseGroupDailyReport>
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
result.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
} catch (parseError) {
|
||||
writeReportLog('warn', '本地修复日报 JSON 失败,尝试由模型纠正', {
|
||||
...jsonErrorContext(result.data, parseError),
|
||||
retry: 1
|
||||
})
|
||||
const repairMessages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT
|
||||
},
|
||||
{ role: 'user', content: result.data }
|
||||
]
|
||||
const repairResult = await trackStep('AI 修复 JSON', () =>
|
||||
withTimeout(
|
||||
window.api.aiChat(repairMessages, {
|
||||
providerId: modelConfig.providerId,
|
||||
modelId: modelConfig.model,
|
||||
timeoutMs: reportTimeoutSeconds * 1000
|
||||
}),
|
||||
'AI 修复日报 JSON',
|
||||
reportTimeoutSeconds * 1000 + REPORT_MODEL_TIMEOUT_BUFFER_MS
|
||||
)
|
||||
)
|
||||
if (!repairResult.success || !repairResult.data) {
|
||||
throw new Error(repairResult.error || 'AI 修复日报 JSON 失败', { cause: parseError })
|
||||
}
|
||||
const repairUsage =
|
||||
repairResult.usage && repairResult.usage.total
|
||||
? repairResult.usage
|
||||
: estimateTokenUsage(repairMessages, repairResult.data)
|
||||
tokenUsage = mergeTokenUsage(tokenUsage, repairUsage)
|
||||
try {
|
||||
report = parseGroupDailyReport(
|
||||
repairResult.data,
|
||||
input.topSpeakers,
|
||||
input.activeTimeline,
|
||||
input.voiceLeaderboard || [],
|
||||
input.metadata,
|
||||
input.media
|
||||
)
|
||||
writeReportLog('info', '模型已纠正日报 JSON', {
|
||||
retry: 1,
|
||||
outputLength: repairResult.data.length
|
||||
})
|
||||
} catch (retryParseError) {
|
||||
writeReportLog(
|
||||
'error',
|
||||
'日报 JSON 重试后仍解析失败',
|
||||
jsonErrorContext(repairResult.data, retryParseError)
|
||||
)
|
||||
throw retryParseError
|
||||
}
|
||||
}
|
||||
|
||||
setPhase('exportingReport')
|
||||
failedAt = '导出 HTML 与 PNG'
|
||||
const exported = await withTimeout(
|
||||
window.api.exportGroupReport({ report, metadata: input.metadata, templateId }),
|
||||
'日报图片导出'
|
||||
@@ -468,8 +586,19 @@ export function useGroupReportGeneration({
|
||||
generationLogs: [...logs]
|
||||
})
|
||||
setPhase('success')
|
||||
writeReportLog('info', '群聊日报生成成功', {
|
||||
durationMs: Date.now() - startGenerateTime,
|
||||
htmlPath: exported.htmlPath,
|
||||
pngPath: exported.pngPath
|
||||
})
|
||||
} catch (generateError) {
|
||||
setError(errorMessage(generateError))
|
||||
const message = errorMessage(generateError)
|
||||
writeReportLog('error', '群聊日报生成失败', {
|
||||
error: message,
|
||||
failedAt,
|
||||
durationMs: Date.now() - startGenerateTime
|
||||
})
|
||||
setError(message)
|
||||
setPhase('error')
|
||||
}
|
||||
}, [
|
||||
@@ -479,6 +608,7 @@ export function useGroupReportGeneration({
|
||||
modelConfig,
|
||||
reportTimeoutSeconds,
|
||||
sourceContact,
|
||||
summaryDateRange,
|
||||
summaryMessageTypes,
|
||||
templateId
|
||||
])
|
||||
|
||||
@@ -3,6 +3,40 @@ import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/tokens.css'
|
||||
import './assets/main.css'
|
||||
import './styles/search.css'
|
||||
import './styles/archive.css'
|
||||
import './styles/settings-advanced.css'
|
||||
|
||||
window.addEventListener('error', (event) => {
|
||||
void window.api
|
||||
.writeAppLog({
|
||||
level: 'error',
|
||||
scope: 'renderer',
|
||||
message: event.message || 'Renderer 未捕获错误',
|
||||
details: {
|
||||
filename: event.filename,
|
||||
line: event.lineno,
|
||||
column: event.colno,
|
||||
stack: event.error instanceof Error ? event.error.stack : undefined
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const reason = event.reason
|
||||
void window.api
|
||||
.writeAppLog({
|
||||
level: 'error',
|
||||
scope: 'renderer',
|
||||
message: reason instanceof Error ? reason.message : 'Renderer Promise 未处理拒绝',
|
||||
details: {
|
||||
stack: reason instanceof Error ? reason.stack : undefined,
|
||||
reason: reason instanceof Error ? undefined : String(reason)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
})
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
.archive-jump-target-group {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.archive-jump-message {
|
||||
z-index: 1;
|
||||
animation: archive-jump-flash 0.72s ease-in-out 2;
|
||||
}
|
||||
|
||||
@keyframes archive-jump-flash {
|
||||
0%,
|
||||
100% {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
22%,
|
||||
58% {
|
||||
filter: drop-shadow(0 0 0.45rem rgba(38, 128, 103, 0.68));
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.archive-jump-message {
|
||||
outline: 3px solid rgba(38, 128, 103, 0.58);
|
||||
outline-offset: 5px;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
.settings-debug-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-card > label > span {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.settings-debug-card b {
|
||||
color: var(--wxex-text-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.settings-debug-card small {
|
||||
color: var(--wxex-text-secondary);
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-debug-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--wxex-border);
|
||||
}
|
||||
|
||||
.settings-debug-actions button {
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--wxex-border);
|
||||
border-radius: var(--wxex-radius-sm);
|
||||
background: var(--wxex-bg-elevated);
|
||||
color: var(--wxex-brand);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
ReportVoiceLeaderboardItem
|
||||
} from '../../../shared/group-report'
|
||||
import { Contact, Message } from '../../../shared/types'
|
||||
import { jsonrepair } from 'jsonrepair'
|
||||
import { buildGroupReportFacts } from './group-report-facts'
|
||||
|
||||
export const GROUP_REPORT_SYSTEM_PROMPT = `你是微信群聊日报编辑。请仅根据用户提供的聊天记录生成结构化中文日报。
|
||||
@@ -73,6 +74,9 @@ JSON 结构必须为:
|
||||
- 完整版:可以保留更多候选项,但仍要去重和排序。
|
||||
- 如果某个字段不确定,请返回 null 或空数组,不要猜。`
|
||||
|
||||
export const GROUP_REPORT_JSON_REPAIR_SYSTEM_PROMPT =
|
||||
'你是 JSON 格式修复器。只修复输入中的 JSON 语法,不改写、删减或新增任何业务内容。只输出一个可被 JSON.parse 解析的 JSON 对象,不要输出 Markdown 或解释。'
|
||||
|
||||
export interface GroupReportInput {
|
||||
prompt: string
|
||||
metadata: GroupReportMetadata
|
||||
@@ -259,7 +263,19 @@ const extractJson = (raw: string): unknown => {
|
||||
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))
|
||||
const json = cleaned.slice(start, end + 1)
|
||||
try {
|
||||
return JSON.parse(json)
|
||||
} catch (strictError) {
|
||||
try {
|
||||
return JSON.parse(jsonrepair(json))
|
||||
} catch (repairError) {
|
||||
throw new Error(
|
||||
`日报 JSON 自动修复失败:${repairError instanceof Error ? repairError.message : String(repairError)}`,
|
||||
{ cause: strictError }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createSignature = (...parts: Array<string | null | undefined>): string =>
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export type AppLogLevel = 'info' | 'warn' | 'error'
|
||||
|
||||
export interface AppLogEntry {
|
||||
level: AppLogLevel
|
||||
scope: string
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Message } from './types'
|
||||
|
||||
export type ExportFormat = 'html' | 'csv' | 'json' | 'markdown'
|
||||
export type ExportMessageKind =
|
||||
| 'text'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'voice'
|
||||
| 'sticker'
|
||||
| 'share'
|
||||
| 'location'
|
||||
| 'system'
|
||||
|
||||
export type ExportNameMode = 'groupNickname' | 'remark' | 'wechatNickname'
|
||||
|
||||
export interface ExportRequest {
|
||||
jobId: string
|
||||
userMd5: string
|
||||
name: string
|
||||
format: ExportFormat
|
||||
outputName: string
|
||||
startTime?: number
|
||||
endTime?: number
|
||||
kinds: ExportMessageKind[]
|
||||
includeMedia: boolean
|
||||
includeAvatars?: boolean
|
||||
avatarUrls?: Record<string, string>
|
||||
nameMode?: ExportNameMode
|
||||
nameMap?: Record<string, string>
|
||||
zip?: boolean
|
||||
}
|
||||
|
||||
export interface ExportJobProgress {
|
||||
jobId: string
|
||||
phase: 'reading' | 'writing' | 'completed' | 'cancelled' | 'failed'
|
||||
processed: number
|
||||
total?: number
|
||||
percent?: number
|
||||
outputPath?: string
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface ExportTaskRecord {
|
||||
jobId: string
|
||||
contactId: string
|
||||
contactName: string
|
||||
format: ExportFormat
|
||||
status: 'running' | 'completed' | 'cancelled' | 'failed'
|
||||
progress: ExportJobProgress
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export interface ExportResult {
|
||||
success: boolean
|
||||
outputPath?: string
|
||||
messageCount?: number
|
||||
error?: string
|
||||
}
|
||||
export type ExportRendererApi = {
|
||||
startExport: (request: ExportRequest) => Promise<ExportResult>
|
||||
cancelExport: (jobId: string) => Promise<{ success: boolean }>
|
||||
revealExport: (path: string) => Promise<{ success: boolean; error?: string }>
|
||||
onExportProgress: (callback: (progress: ExportJobProgress) => void) => () => void
|
||||
}
|
||||
|
||||
export type ExportMessage = Message
|
||||
@@ -4,6 +4,8 @@ export interface Contact {
|
||||
md5: string
|
||||
type: 'user' | 'group'
|
||||
avatar?: string
|
||||
wechatNickname?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
@@ -20,8 +22,17 @@ export interface Message {
|
||||
voiceDataUrl?: string
|
||||
voiceDuration?: number
|
||||
localId?: number
|
||||
serverId?: string
|
||||
createTime?: number
|
||||
sessionId?: string
|
||||
recalled?: boolean
|
||||
recalledBy?: string
|
||||
recoveredFromRecallJournal?: boolean
|
||||
exportMediaUrl?: string
|
||||
exportMediaType?: 'image' | 'video' | 'sticker'
|
||||
exportShowAvatar?: boolean
|
||||
exportMediaError?: string
|
||||
exportAvatarUrl?: string
|
||||
}
|
||||
|
||||
type TextContent = { type: 'text'; content: string }
|
||||
@@ -42,6 +53,22 @@ type ShareContent = {
|
||||
appname?: string
|
||||
typeVal?: string
|
||||
}
|
||||
type MiniProgramContent = {
|
||||
type: 'miniProgram'
|
||||
title: string
|
||||
description?: string
|
||||
appName?: string
|
||||
iconUrl?: string
|
||||
thumbMd5?: string
|
||||
thumbDatName?: string
|
||||
thumbDataUrl?: string
|
||||
}
|
||||
type RedPacketContent = {
|
||||
type: 'redPacket'
|
||||
title: string
|
||||
description?: string
|
||||
url?: string
|
||||
}
|
||||
type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number }
|
||||
type ImageContent = {
|
||||
type: 'image'
|
||||
@@ -50,6 +77,15 @@ type ImageContent = {
|
||||
aeskey?: string
|
||||
encrypVer?: number
|
||||
}
|
||||
type VideoContent = {
|
||||
type: 'video'
|
||||
md5?: string
|
||||
newMd5?: string
|
||||
rawMd5?: string
|
||||
duration?: number
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
type StickerContent = {
|
||||
type: 'sticker'
|
||||
md5?: string
|
||||
@@ -66,8 +102,23 @@ type QuoteContent = {
|
||||
quotedContent?: string
|
||||
quotedSender?: string
|
||||
quotedType?: string
|
||||
quotedImageMd5?: string
|
||||
quotedImageDatName?: string
|
||||
}
|
||||
type SystemContent = {
|
||||
type: 'system'
|
||||
content: string
|
||||
raw?: string
|
||||
pat?: boolean
|
||||
recall?: {
|
||||
targetId?: string
|
||||
targetIds?: string[]
|
||||
replacement: string
|
||||
actor?: string
|
||||
sessionId?: string
|
||||
recallTime?: number
|
||||
}
|
||||
}
|
||||
type SystemContent = { type: 'system'; content: string; raw?: string }
|
||||
type UnknownContent = { type: 'unknown'; raw: string }
|
||||
|
||||
export type ParsedContent =
|
||||
@@ -76,8 +127,11 @@ export type ParsedContent =
|
||||
| LocationContent
|
||||
| CardContent
|
||||
| ShareContent
|
||||
| MiniProgramContent
|
||||
| RedPacketContent
|
||||
| VoipContent
|
||||
| ImageContent
|
||||
| VideoContent
|
||||
| StickerContent
|
||||
| QuoteContent
|
||||
| SystemContent
|
||||
|
||||