Compare commits

..
Author SHA1 Message Date
Wxw-Gu 33aaf4d558 feat: 修改缓存加载逻辑 2026-07-28 09:56:50 +08:00
电摇小子 5ccabb3898 feat: 完善微信小程序与红包消息解析
- 新增小程序和红包结构化消息及专用卡片
- 修复嵌套类型、拍一拍和表情包误判
- 补充常见 AppMsg 类型分类
2026-07-28 03:20:11 +08:00
电摇小子 7db845ac7e feat: 优化聊天记录缓存、虚拟分页与群聊媒体展示
- 使用持久化缓存加速启动并异步读取 WCDB 消息
- 修复空群缓存、头像和群成员名称丢失问题
- 修复引用图片缩略图、虚拟卸载缓存和图片预览
- 修正引用消息发送者显示为群 ID 的问题
2026-07-28 02:45:20 +08:00
电摇小子 9348c5ce4a feat: 修复导出任务状态并优化聊天记录导出体验 2026-07-27 22:06:08 +08:00
Wxw-Gu f626d89a1a feat: 提交 2026-07-27 20:34:57 +08:00
Wxw-Gu 95e517eca5 feat: 表情包优化 2026-07-27 17:55:11 +08:00
Wxw-Gu e93e4554d7 feat: 导出功能 2026-07-27 17:43:36 +08:00
Wxw-Gu 9ae0c6cc47 chore: 删除无用代码 2026-07-27 10:13:13 +08:00
Wxw-Gu 59e609b08a Merge branch 'develop' 2026-07-27 09:42:25 +08:00
Wxw-Gu 9de7dcde3b chore: 提升版本号 2026-07-27 09:41:49 +08:00
电摇小子 cb3c2855c0 fix(paths): 同步 imageKeyRoot 与 dbRoot,仅识别 xwechat_files
之前的状态面板(chat.getCurrentAccountRoot())和自动获取图片密钥(settings.imageKeyRoot)
走两个完全不同的来源,二者漂移导致状态显示 D 盘但实际扫描到 C 盘。

index.ts:
- key:autoGetImageKey 优先级调整为 chat.getCurrentAccountRoot() → self.accountRoot →
  settings.imageKeyRoot → settings.dbRoot,让运行时识别的目录永远最优先。
- db:init:保存 dbRoot 时同步更新 imageKeyRoot,避免 settings 缓存漂移。
- db:reopenWithRoot:用户手动切换根目录时同步更新 imageKeyRoot。

settings-store.ts:
- loadSettings:dbRoot 修正时同步 imageKeyRoot;若 imageKeyRoot 指向旧路径或不存在,
  回退到 dbRoot。
- 新增 redirectLegacyWeChatFilesToXwechat():当 settings 里的 dbRoot/imageKeyRoot 仍指向
  V3 时代的 "Documents\WeChat Files" 路径时,自动重定向到同目录下的 xwechat_files(如果存在)。
  老用户升级时无需手动迁移。

wcdb4-client / windows-db-root-discovery / key-service-win:
- 候选目录列表只保留 xwechat_files 形态(Documents\xwechat_files、
  AppData\Roaming\Tencent\xwechat_files、macOS 的 Library/Containers/.../xwechat_files),
  移除 V3 时代的 "WeChat Files" 候选。
- DB_ROOT_NAMES 只包含 xwechat_files,自动扫描也只认 V4 路径。

V3 数据(V1 头 dat / WeChat Files 目录结构)如果用户机器上还存在,仅作为残留,
不会被 WechatExplorer 当成有效数据源。
2026-07-26 20:46:51 +08:00
电摇小子 dca88e5db3 fix(image-decrypt): 仅支持 WeChat 4.0,移除 V3 兜底 + 模板诊断
WeChat 4.0 dat 文件头为 07 08 56 32 08 07。V3 / 老版本(V1 头)不在支持范围。

image-decrypt-service:
- 移除 defaultV1AesKey ('cfcd208495d565ef') 字段。
- 移除 if (version === 1) 的 V1 默认 key 分支。
- getDatVersion 只识别 V2 头;其他返回 0 走 unsupported。
- 注释说明 decryptDatV4 方法名里的 V4 是历史命名,跟协议版本无关。

key-service-win._findTemplateData / autoGetImageKeyByMemoryScan:
- 统计扫描诊断信息:搜索到的 _t.dat 数量、V2 头数量、非 V2 数量、扫描根目录。
- 把模糊的「未找到 V2 模板文件」拆成三种具体提示:
  1) 目录下完全没有 _t.dat → 让用户先在微信里点开图片大图(缩略图未生成)。
  2) 有 _t.dat 但都不是 V2 头 → 让用户查看更多图片。
  3) 有 V2 但没有有效长度 → 让用户查看更多图片。
- 每条都附带实际扫描根目录,便于远程排查时一眼看出路径是否正确。
2026-07-26 20:46:34 +08:00
电摇小子 1a9f27488b refactor(image-decrypt): 设置面板 UI 重构 + 测试步骤联动
页面拆分两个目录语义:
- 上方「图片解密状态」的「图片资源目录」始终等于 chat 实时识别的默认目录(chat.getCurrentAccountRoot()),
  不再被用户输入覆盖。
- 下方「图片密钥管理」移除「图片资源目录」输入框,只保留 XOR / AES 两个 Key。
  该字段之前会被保存但实际不影响图片解密(数据库查询走默认根目录),保留只会让用户困惑。

UI 改动:
- ImageKeyConfiguration:移除 resourceRoot 输入框,edit() 签名收窄为 xorKey | aesKey。
- ImageTestSection:测试结果由 3 列横排改为步骤条样式(pending / ok / fail / skipped),
  任意一步 ✗ 后续步骤自动标记「跳过」并灰掉,前端语义上变成「要么都成功要么都失败」。
- AutoDetectImageKeySection:加一行小字提示「仅支持 WeChat 4.0,V3 及以下无法解析」。

后端改动:
- testImageDecryption:解密失败时把 fileFound/decrypted/readable 统一置 false,
  拆出「解密成功但不可读」中间态,让 UI 步骤联动准确反映。
- inspectImageDecryptionStatus:accountRoot 兜底改为 getCurrentAccountRoot() || config.resourceRoot,
  让状态面板始终显示当前识别到的默认目录。
- sanitizeImageError:把 'no_image_message' / '300' / 'unsupported' / 'dat version' 关键字翻译成具体提示。
- useImageDecryptionController.autoDetect:错误信息原文透传,不再走 sanitizeImageError(后者会
  把扫描阶段的「未找到 V2 模板文件」/「60 秒未找到 AES 密钥」等归类成「无法解析媒体文件」,掩盖真因)。
- image-key-config-service.save():不再写回 imageKeyRoot,下方输入框只用于校验,不再落盘。
2026-07-26 20:46:20 +08:00
电摇小子 3b64e18b5e chore(security): 移除 AI 内置 Key fallback + 修正默认模型名
发布版本不再自动从环境变量读取 VITE_DEEPSEEK_API_KEY 创建默认 DeepSeek provider。
ensureEnvironmentMigration() 改为 no-op(保留方法作为占位)。
用户首次启动必须在「设置 → AI 模型」手动配置 API Key。

migrateLegacy() 保留:用于把用户自己之前存在 localStorage 的旧配置迁移到加密存储,
与内置 Key 是两回事。

附带修正 .env.example 默认模型名:deepseek-v4-flash 是已弃用/不存在的标识符,
DeepSeek 官方未发布此模型,统一改为 deepseek-chat。
2026-07-26 20:45:53 +08:00
Wxw-Gu 554dccdb21 Merge branch 'develop' 2026-07-24 16:36:11 +08:00
Wxw-Gu c674dcbc4a docs: 更新文档 2026-07-24 16:19:27 +08:00
Wxw-Gu 0f65b96da1 docs: 修改二维码 2026-07-24 14:46:17 +08:00
Wxw-Gu 8488e81bb5 feat: 支持微信视频消息解析与播放
支持视频 XML 解析、本地文件映射与拖动播放。

修复源码乱码注释并统一 UTF-8 编码。
2026-07-24 14:16:34 +08:00
Wxw-Gu 430a36333b docs: 修改Readme 2026-07-24 11:28:10 +08:00
Wxw-Gu 474250c6c7 feat: 添加微信消息防撤回功能 2026-07-24 11:27:44 +08:00
62 changed files with 6170 additions and 633 deletions
+4 -1
View File
@@ -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=
+3
View File
@@ -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
+20 -44
View File
@@ -9,12 +9,13 @@ macOS / Windows 微信聊天记录查看,AI 一键生成群聊总结。
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
> 当前版本:`v2.1.2`。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,6 +158,16 @@ 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)
+121
View File
@@ -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 后,先检查页面中的“存储路径”是否正确。
![确认微信数据目录](./images/initial-setup.png)
Windows 当前不会扫描二级目录。如果没有正确识别微信数据,请进入“设置”,手动选择微信数据所在目录。
![Windows 自定义数据目录](./images/windows-data-path.png)
### 第二步:让微信停留在登录页面
如果微信已经登录,请先退出登录;然后重新打开微信,让它停留在未登录页面,暂时不要点击登录。
![微信未登录页面](./images/wechat-login-window.png)
### 第三步:开始获取密钥
返回 WechatExplorer,点击“自动获取密钥”。
- **Windows**:看到“Hook 注入成功”后,返回微信完成登录。
- **macOS**:系统会弹出授权提示,请输入当前 macOS 用户密码并完成授权,然后返回微信完成登录。
![macOS 授权页面](./images/macos-authorization.png)
> 点击“自动获取密钥”前,微信必须停留在登录页面。WechatExplorer 提示可以登录后,再回到微信完成登录。
### 第四步:完成连接
如果系统环境和微信版本符合要求,WechatExplorer 会自动填写数据库密钥并连接数据库。连接成功后即可查看、搜索和导出聊天记录,也可以配置 AI 服务生成群聊总结。
![密钥获取完成](./images/setup-complete.png)
## 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)。
Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "wechatexplorer",
"version": "2.1.4",
"version": "2.1.5",
"description": "macOS / Windows 微信聊天记录查看与 AI 群聊总结助手",
"keywords": [
"wechat",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 158 KiB

+41
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[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>`
}
+315
View File
@@ -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)
}
+11 -15
View File
@@ -10,8 +10,6 @@ const imageDecryptLog = (...args: unknown[]): void => {
}
export class ImageDecryptService {
private readonly defaultV1AesKey = 'cfcd208495d565ef'
private xorKey: number = 0
private aesKey: string = ''
private wcdb4Client: Wcdb4Client | null = null
@@ -80,9 +78,11 @@ export class ImageDecryptService {
findImageFile(
md5?: string,
imageDatName?: string,
options?: { allowThumbnail?: boolean }
options?: { allowThumbnail?: boolean; accountDir?: string }
): string | null {
const accountDir = this.getAccountDir()
// 测试场景下可显式指定根目录;不传则维持原 getAccountDir() 行为
const accountDir =
options?.accountDir && existsSync(options.accountDir) ? options.accountDir : this.getAccountDir()
if (!accountDir) return null
const allowThumbnail = options?.allowThumbnail !== false
@@ -273,12 +273,9 @@ export class ImageDecryptService {
)
let decrypted: Buffer
if (version === 1) {
imageDecryptLog('[ImageDecrypt] using V1 (default AES key)')
const key = Buffer.from(this.defaultV1AesKey, 'ascii')
decrypted = this.decryptDatV4(datPath, key)
} else if (version === 2) {
imageDecryptLog('[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) {
imageDecryptLog('[ImageDecrypt] no AES key configured')
return null
@@ -286,7 +283,8 @@ export class ImageDecryptService {
const key = Buffer.from(this.aesKey, 'ascii').slice(0, 16)
decrypted = this.decryptDatV4(datPath, key)
} else {
imageDecryptLog('[ImageDecrypt] unsupported dat version:', version)
// 仅支持 WeChat 4.0:版本不匹配直接返回 null,不做 V3/老版本兜底。
imageDecryptLog('[ImageDecrypt] unsupported dat version (WeChat 4.0 only):', version)
return null
}
@@ -356,7 +354,8 @@ export class ImageDecryptService {
}
/**
* 检测 DAT 文件版本
* 检测 DAT 文件版本(仅识别 WeChat 4.0 头 V2)。
* 老 V1 头(V3 及以下)直接返回 0,由调用方走"不支持"分支。
*/
private getDatVersion(inputPath: string): number {
const bytes = readFileSync(inputPath)
@@ -365,9 +364,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
}
+275 -41
View File
@@ -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,18 +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
@@ -80,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'
@@ -107,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,
@@ -130,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 {
@@ -139,9 +285,20 @@ 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',
@@ -169,24 +326,20 @@ app.whenReady().then(async () => {
})
})
// 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)
})
@@ -202,6 +355,7 @@ app.whenReady().then(async () => {
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()
@@ -216,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) {
@@ -297,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 })
@@ -364,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: [] }
}
)
@@ -391,8 +579,8 @@ 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)
}
@@ -400,7 +588,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))
@@ -443,6 +637,24 @@ 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()
})
@@ -623,6 +835,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', () => ({
@@ -637,13 +858,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(),
@@ -665,6 +895,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 }
})
@@ -750,9 +985,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)
@@ -766,15 +999,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') {
@@ -784,6 +1017,7 @@ app.on('window-all-closed', () => {
app.on('before-quit', async () => {
agentHubService.stop()
flushBootstrapCacheWritesSync()
chat.setChatDb(null)
await apiServer.stop().catch(() => undefined)
if (tray) {
+51 -2
View File
@@ -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,6 +935,9 @@ 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) {
@@ -942,7 +946,38 @@ export class KeyService {
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 }
}
+234 -6
View File
@@ -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]
-13
View File
@@ -41,7 +41,6 @@ export class AIProviderService {
constructor(private readonly keyStore = new AIProviderKeyStore()) {}
list(): AIProviderListResult {
this.ensureEnvironmentMigration()
try {
const data = this.readMetadata()
return {
@@ -338,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
+113 -4
View File
@@ -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,12 @@ 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>()
@@ -73,7 +87,9 @@ function readCacheFile(accountRoot?: string): BootstrapCacheFile | null {
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
@@ -122,7 +138,8 @@ function loadOrCreate(accountRoot?: string): BootstrapCacheFile | null {
accountRoot: normalizedRoot,
updatedAt: Date.now(),
contacts: [],
messages: {}
messages: {},
groupSnapshots: {}
}
memoryCache.set(getCacheFile(normalizedRoot), created)
return created
@@ -132,6 +149,33 @@ function messageBucketKey(userMd5: string, startTime?: number, endTime?: number)
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
@@ -260,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,
+150 -9
View File
@@ -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,10 +138,15 @@ 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
})
}
// 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
@@ -151,6 +168,7 @@ export function listContacts(filter?: string): FormattedContact[] {
})
}
}
}
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|&lt;appmsg\b|&lt;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: readable,
code: readable ? undefined : 'DECRYPT_FAILED',
error: readable ? undefined : '图片解密结果不可读取',
success: false,
code: 'DECRYPT_FAILED',
error: '图片解密结果不可读取',
fileFound: true,
decrypted: false,
readable: false,
isThumbnail: service.isThumbnailFile(filePath)
}
}
return {
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
+472
View File
@@ -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)
}
}
+32 -6
View File
@@ -2,7 +2,22 @@ import { app } from 'electron'
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from '../windows-db-root-discovery'
/**
* 把 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
@@ -13,6 +28,7 @@ export interface AppSettings {
imageXorKey: string
imageAesKey: string
imageKeyFallbackDisabled: boolean
recallProtectionEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
}
@@ -25,21 +41,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')
]
candidates.push(...discoverWindowsDbRoots())
return unique(candidates)
}
@@ -89,6 +104,7 @@ const DEFAULT_SETTINGS: AppSettings = {
imageXorKey: '',
imageAesKey: '',
imageKeyFallbackDisabled: false,
recallProtectionEnabled: false,
autoLogin: ['1', 'true', 'yes', 'on'].includes(
String(import.meta.env.VITE_AUTO_LOGIN || '')
.trim()
@@ -123,9 +139,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_filesV4)。
if (cache.imageKeyRoot) {
cache.imageKeyRoot = redirectLegacyWeChatFilesToXwechat(cache.imageKeyRoot)
}
if (cache.dbRoot) {
cache.dbRoot = redirectLegacyWeChatFilesToXwechat(cache.dbRoot)
}
return cache
}
} catch (error) {
+21 -13
View File
@@ -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}`)
+104
View File
@@ -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
}
}
+619 -34
View File
@@ -1,7 +1,6 @@
import fs from 'fs-extra'
import path from 'path'
import os from 'os'
import { discoverWindowsDbRoots } from './windows-db-root-discovery'
import crypto from 'crypto'
import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
@@ -11,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
@@ -30,6 +32,11 @@ export interface Wcdb4MessageQueryOptions {
limit?: number
}
type Wcdb4MessageStore = {
tableName: string
dbPath: string
}
export interface Wcdb4GroupMember {
m_nsUsrName: string
nickname: string
@@ -45,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
@@ -56,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
@@ -129,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]
@@ -158,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
@@ -217,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
@@ -297,17 +389,17 @@ export class Wcdb4Client {
private static getDefaultRootCandidates(): string[] {
const home = os.homedir()
if (process.platform === 'win32') {
// 仅支持 WeChat 4.0:只认 xwechat_filesV3 时代的 "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')
]
candidates.push(...discoverWindowsDbRoots())
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[] {
@@ -331,7 +423,9 @@ export class Wcdb4Client {
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
}
@@ -411,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
@@ -588,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 =
@@ -616,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)
@@ -648,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 : []
@@ -678,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) =>
@@ -694,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 []
@@ -739,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)
@@ -856,8 +1160,7 @@ export class Wcdb4Client {
const visibleMessages = limit && sorted.length > limit ? sorted.slice(-limit) : sorted
return visibleMessages
.map((message) => {
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
@@ -915,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',
@@ -933,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,
@@ -966,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
@@ -994,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,
@@ -1073,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}`)
@@ -1164,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)'
@@ -1286,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)'
@@ -1448,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
@@ -1474,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 {
@@ -1520,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',
@@ -1541,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),
@@ -1584,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
@@ -1618,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
+43 -18
View File
@@ -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)))
}
})
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
+2 -1
View File
@@ -1,7 +1,8 @@
import fs from 'fs-extra'
import path from 'path'
const DB_ROOT_NAMES = new Set(['xwechat_files', 'wechat files'])
// 仅支持 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',
+65 -1
View File
@@ -39,15 +39,43 @@ import type {
} 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
@@ -83,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: (
@@ -90,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,
@@ -151,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: (
@@ -191,6 +251,7 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -217,6 +278,7 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -231,6 +293,7 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
@@ -243,6 +306,7 @@ declare global {
apiHost: string
apiPort: number
imageKeyRoot: string
recallProtectionEnabled: boolean
autoLogin: boolean
autoLoginPreferenceSet: boolean
imageXorKey: string
+14
View File
@@ -17,6 +17,7 @@ import type {
} 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 = {
@@ -25,10 +26,13 @@ const api = {
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,
@@ -59,7 +63,17 @@ const api = {
sessionId?: string,
options?: { force?: 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'),
+1 -1
View File
@@ -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>
+411 -109
View File
@@ -1,4 +1,4 @@
import React, { useState } from 'react'
import React, { useState } from 'react'
import { Sidebar } from './components/Sidebar'
import ChatWindow from './components/ChatWindow'
import { AppShell } from './components/layout/AppShell'
@@ -20,6 +20,8 @@ import { AiModelConfig, useGroupReportGeneration } from './hooks/useGroupReportG
import { SummaryDateRange, SummaryMessageType } from './utils/group-report'
import { Contact, Message } from '../../shared/types'
import { DatabaseConnectionMode, DatabaseConnectionPage } from './components/DatabaseConnectionPage'
import { ExportWorkspace } from './components/export/ExportWorkspace'
import type { ExportJobProgress, ExportRequest, ExportTaskRecord } from '../../shared/export'
const SIDEBAR_MIN_WIDTH = 260
const SIDEBAR_MAX_WIDTH = 380
@@ -38,13 +40,80 @@ interface SelfInfo {
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
const MESSAGE_MONITOR_DEBOUNCE_MS = 8000
const VIEW_MESSAGE_LIMIT = 600
const INITIAL_MESSAGE_COUNT = 20
const MESSAGE_PAGE_SIZE = 100
const MESSAGE_PREFETCH_COUNT = INITIAL_MESSAGE_COUNT + MESSAGE_PAGE_SIZE
const EXPORT_PREVIEW_LIMIT = 20
const getMessageIdentity = (message: Message): string => {
if (message.localId) return `local:${message.localId}`
if (message.id) return `id:${message.id}`
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
}
const normalizeQuotedText = (value: string | undefined): string =>
String(value || '')
.replace(/\s+/g, ' ')
.trim()
const isInternalReferenceSender = (value: string | undefined): boolean => {
const sender = String(value || '').trim()
return (
!sender ||
sender.endsWith('@chatroom') ||
sender.startsWith('wxid_') ||
/^[a-z0-9_@.-]{12,}$/i.test(sender)
)
}
const enrichQuotedMessages = (messages: Message[], referenceMessages: Message[]): Message[] => {
const imageDatNameByMd5 = new Map<string, string>()
const messagesByContent = new Map<string, Message[]>()
for (const message of referenceMessages) {
if (
message.contentData?.type === 'image' &&
message.contentData.md5 &&
message.contentData.datName
) {
imageDatNameByMd5.set(message.contentData.md5, message.contentData.datName)
}
const content = normalizeQuotedText(message.content)
if (!content) continue
const candidates = messagesByContent.get(content) || []
candidates.push(message)
messagesByContent.set(content, candidates)
}
return messages.map((message) => {
if (message.contentData?.type !== 'quote') return message
const quote = message.contentData
let quotedImageDatName = quote.quotedImageDatName
if (!quotedImageDatName && quote.quotedImageMd5) {
quotedImageDatName = imageDatNameByMd5.get(quote.quotedImageMd5)
}
let quotedSender = quote.quotedSender
if (isInternalReferenceSender(quotedSender)) {
const candidates = messagesByContent.get(normalizeQuotedText(quote.quotedContent)) || []
const source = candidates
.filter((candidate) => (candidate.createTime || 0) <= (message.createTime || Infinity))
.sort((left, right) => (right.createTime || 0) - (left.createTime || 0))[0]
if (source?.name && !isInternalReferenceSender(source.name)) quotedSender = source.name
}
if (
quotedSender === quote.quotedSender &&
quotedImageDatName === quote.quotedImageDatName
) {
return message
}
return {
...message,
contentData: { ...quote, quotedSender, quotedImageDatName }
}
})
}
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
if (left === right) return true
if (left.length !== right.length) return false
@@ -76,7 +145,7 @@ type StartupProgress = {
}
const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string =>
member.nickname || member.wxid
member.groupNickname || member.nickname || member.remark || member.wechatNickname || member.wxid
const buildSyntheticGroupMessages = (
previous: GroupSnapshot | null,
@@ -132,6 +201,12 @@ const sortMessagesChronologically = (items: Message[]): Message[] =>
return getMessageIdentity(left).localeCompare(getMessageIdentity(right))
})
const mergeMessagePages = (older: Message[], current: Message[]): Message[] => {
const merged = new Map<string, Message>()
for (const message of [...older, ...current]) merged.set(getMessageIdentity(message), message)
return sortMessagesChronologically(Array.from(merged.values()))
}
function App(): React.ReactElement {
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isDatabaseConnected, setIsDatabaseConnected] = useState(false)
@@ -141,7 +216,6 @@ function App(): React.ReactElement {
const [messages, setMessages] = useState<Message[]>([])
const [isMessagesLoading, setIsMessagesLoading] = useState(false)
const [filteredContacts, setFilteredContacts] = useState<Contact[]>([])
const [dateRange, setDateRange] = useState('today') // 默认今天
const [contentFilter, setContentFilter] = useState('')
const [isFetchingDbKey, setIsFetchingDbKey] = useState(false)
const [dbKeyStatus, setDbKeyStatus] = useState('')
@@ -172,12 +246,24 @@ function App(): React.ReactElement {
})
const [selfInfo, setSelfInfo] = useState<SelfInfo | null>(null)
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
const [exportTasks, setExportTasks] = useState<ExportTaskRecord[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem('wxe_export_tasks') || '[]')
return Array.isArray(stored) ? (stored as ExportTaskRecord[]).slice(0, 20) : []
} catch {
return []
}
})
const [bootState, setBootState] = useState<'loading' | 'connecting' | 'login'>('loading')
const [autoConnectSource, setAutoConnectSource] = useState<'env' | 'saved' | null>(null)
const [startupProgress, setStartupProgress] = useState<StartupProgress | null>(null)
const currentGroupSnapshotRef = React.useRef<GroupSnapshot | null>(null)
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
const groupMemberMetaRef = React.useRef<Record<string, Map<string, GroupMemberMeta>>>({})
const messageHistoryRef = React.useRef<Message[]>([])
const messagesRef = React.useRef<Message[]>([])
const messagePrefetchRef = React.useRef<Promise<void> | null>(null)
messagesRef.current = messages
React.useEffect(() => {
if (!reportNotice) return
const timer = window.setTimeout(() => setReportNotice(''), 3200)
@@ -216,6 +302,80 @@ function App(): React.ReactElement {
})
const lastCapturedReportKeyRef = React.useRef('')
React.useEffect(() => {
const unsubscribe = window.api.onExportProgress((progress: ExportJobProgress) => {
setExportTasks((current) =>
current.map((task) => {
if (task.jobId !== progress.jobId) return task
const status =
progress.phase === 'completed'
? 'completed'
: progress.phase === 'cancelled'
? 'cancelled'
: progress.phase === 'failed'
? 'failed'
: 'running'
return { ...task, status, progress }
})
)
})
return unsubscribe
}, [])
React.useEffect(() => {
localStorage.setItem('wxe_export_tasks', JSON.stringify(exportTasks.slice(0, 20)))
}, [exportTasks])
const handleStartExport = async (request: ExportRequest): Promise<import('../../shared/export').ExportResult> => {
const task: ExportTaskRecord = {
jobId: request.jobId,
contactId: request.userMd5,
contactName: request.name,
format: request.format,
status: 'running',
progress: { jobId: request.jobId, phase: 'reading', processed: 0, percent: 0 },
createdAt: Date.now()
}
setExportTasks((current) => [task, ...current.filter((item) => item.jobId !== task.jobId)].slice(0, 20))
const result = await window.api.startExport(request)
setExportTasks((current) =>
current.map((item) =>
item.jobId === request.jobId
? result.success
? {
...item,
status: 'completed',
progress: {
...item.progress,
phase: 'completed',
processed: result.messageCount ?? item.progress.processed,
total: result.messageCount ?? item.progress.total,
percent: 100,
outputPath: result.outputPath
}
}
: {
...item,
status: 'failed',
progress: { ...item.progress, phase: 'failed', error: result.error }
}
: item
)
)
return result
}
const handleCancelExport = async (jobId: string): Promise<void> => {
await window.api.cancelExport(jobId)
setExportTasks((current) =>
current.map((task) =>
task.jobId === jobId
? { ...task, status: 'cancelled', progress: { ...task.progress, phase: 'cancelled' } }
: task
)
)
}
const loadGeneratedReports = React.useCallback(async (): Promise<void> => {
try {
const result = await window.api.listGeneratedReports()
@@ -316,7 +476,7 @@ function App(): React.ReactElement {
usernames.length ? 55 : 90
)
let loadedCount = 0
const chunkSize = 32
const chunkSize = 128
for (let index = 0; index < usernames.length; index += chunkSize) {
if (runId !== contactAvatarHydrationRunRef.current) return
const chunk = usernames.slice(index, index + chunkSize)
@@ -347,7 +507,7 @@ function App(): React.ReactElement {
} catch (error) {
console.warn('[Contacts] avatar hydrate failed:', error)
}
await new Promise((resolve) => window.setTimeout(resolve, 50))
await new Promise((resolve) => window.setTimeout(resolve, 20))
}
if (usernames.length) onProgress?.('头像加载完成', 90)
}
@@ -395,7 +555,32 @@ function App(): React.ReactElement {
}
if (!autoLoginEnabled) return
try {
const result = await window.api.initDb(key)
const startupCacheReady = await loadStartupCache()
const initPromise = window.api.initDb(key)
if (startupCacheReady) {
setIsAuthenticated(true)
setIsDatabaseConnected(false)
setBootState('login')
void initPromise.then(async (result) => {
const success = typeof result === 'boolean' ? result : result.success
if (!success) {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`后台连接失败${error ? `: ${error}` : ''}`)
setDbKeyStatusKind('error')
return
}
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
setIsDatabaseConnected(true)
setDbKeyStatus('已连接数据库')
// Cached contacts/self info are enough for startup. Native refresh is
// intentionally user-triggered so it cannot freeze the first session.
}).catch((error) => {
console.warn('[Startup] background database init failed:', error)
setDbKeyStatusKind('error')
})
return
}
const result = await initPromise
if (!active) return
const success = typeof result === 'boolean' ? result : result.success
if (success) {
@@ -406,9 +591,13 @@ function App(): React.ReactElement {
setIsDatabaseConnected(true)
setDbKeyStatus('已自动连接')
setDbKeyStatusKind('success')
await loadContacts()
await refreshSelfInfo(3)
const hasBootstrap = await loadBootstrapCache()
if (hasBootstrap) {
setIsAuthenticated(true)
} else {
await Promise.all([loadContacts(), refreshSelfInfo(3)])
setIsAuthenticated(true)
}
} else {
const error = typeof result === 'boolean' ? '' : result.error
setDbKeyStatus(`自动连接失败,请重新输入${error ? `: ${error}` : ''}`)
@@ -478,7 +667,7 @@ function App(): React.ReactElement {
detail: '正在读取本地缓存',
percent: 25
})
await loadBootstrapCache()
const hasBootstrap = await loadBootstrapCache()
// 持久化手动输入的密钥,供下次启动继续使用
void window.api.saveDbKey(keyToUse).catch(() => undefined)
void window.api.getSettings().then((current) => {
@@ -494,15 +683,28 @@ function App(): React.ReactElement {
})
// 账号识别依赖联系人数据就绪。返回登录后数据已被清空,如果先查账号,
// 会出现“数据库已连接,但账号未连接”的分离状态。手动连接与启动自动连接保持同一顺序。
await loadContacts({ waitForAvatars: false })
await refreshSelfInfo(3)
if (hasBootstrap) {
// Cached contacts are sufficient for the first paint. Refresh native data in the background.
setIsAuthenticated(true)
void Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
]).catch((error) => {
console.warn('[Startup] background refresh failed:', error)
})
} else {
await Promise.all([
loadContacts({ waitForAvatars: false }),
refreshSelfInfo(3)
])
setIsAuthenticated(true)
}
setStartupProgress({
title: '加载完成',
subtitle: '正在进入主页面',
detail: '联系人和头像已准备好',
percent: 100
})
setIsAuthenticated(true)
setIsDatabaseConnected(true)
setBootState('login')
window.setTimeout(() => {
@@ -555,10 +757,14 @@ function App(): React.ReactElement {
const applyGroupMemberMeta = React.useCallback(
(contact: Contact | null, baseMessages: Message[]): Message[] => {
if (!contact || contact.type !== 'group') return baseMessages
const enrichedMessages = enrichQuotedMessages(baseMessages, [
...messageHistoryRef.current,
...baseMessages
])
const memberMap = groupMemberMetaRef.current[contact.md5]
if (!memberMap || memberMap.size === 0) return baseMessages
if (!memberMap || memberMap.size === 0) return enrichedMessages
return baseMessages.map((message) => {
return enrichedMessages.map((message) => {
const senderId = String(message.senderId || message.name || '').trim()
if (!senderId) return message
const member = memberMap.get(senderId)
@@ -581,23 +787,57 @@ function App(): React.ReactElement {
[]
)
const storeGroupMemberMeta = React.useCallback(
(contact: Contact, snapshot: GroupSnapshot): void => {
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: formatGroupMemberName(member), avatar: member.avatar || '' }
])
)
},
[]
)
const loadGroupMemberMeta = React.useCallback(
async (contact: Contact | null): Promise<GroupSnapshot | null> => {
if (!contact || contact.type !== 'group') return null
const snapshot = await logGroupSnapshot(contact, 'load-member-meta')
if (!snapshot) return null
currentGroupSnapshotRef.current = snapshot
groupMemberMetaRef.current[contact.md5] = new Map(
snapshot.members.map((member) => [
member.wxid,
{ nickname: member.nickname || member.wxid, avatar: member.avatar || '' }
])
)
storeGroupMemberMeta(contact, snapshot)
return snapshot
},
[logGroupSnapshot]
[logGroupSnapshot, storeGroupMemberMeta]
)
const handleReloadCurrentAvatars = React.useCallback(async (): Promise<void> => {
const contact = selectedContact
if (!contact) return
try {
const avatars = await window.api.getContactAvatars([contact.m_nsUsrName])
const avatar = avatars[contact.m_nsUsrName]
if (avatar) {
const updateContact = (item: Contact): Contact =>
item.md5 === contact.md5 ? { ...item, avatar } : item
setContacts((current) => current.map(updateContact))
setFilteredContacts((current) => current.map(updateContact))
setSelectedContact((current) =>
current?.md5 === contact.md5 ? updateContact(current) : current
)
}
} catch (error) {
console.warn('[Contacts] current avatar reload failed:', error)
}
const snapshot = await loadGroupMemberMeta(contact)
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, current, snapshot.roomId))
)
}, [applyGroupMemberMeta, loadGroupMemberMeta, mergeSyntheticMessages, selectedContact])
const handleAutoGetDbKey = async (): Promise<void> => {
if (isFetchingDbKey) return
setIsFetchingDbKey(true)
@@ -670,65 +910,56 @@ function App(): React.ReactElement {
setStartupProgress(null)
}
const getDateRangeParams = (
range: string
): { startTime: number | undefined; endTime: number | undefined } => {
const now = new Date()
const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime() / 1000
let startTime: number | undefined
let endTime: number | undefined
switch (range) {
case 'today':
startTime = startOfToday
break
case 'yesterday':
startTime = startOfToday - 86400
endTime = startOfToday - 1 // 昨天结束
break
case '7':
startTime = Math.floor(Date.now() / 1000) - 7 * 86400
break
case '30':
startTime = Math.floor(Date.now() / 1000) - 30 * 86400
break
case 'all':
startTime = undefined
break
default:
startTime = startOfToday
}
return { startTime, endTime }
}
const handleSelectContact = async (contact: Contact): Promise<void> => {
const handleSelectContact = async (contact: Contact, forceLive = false): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
currentGroupSnapshotRef.current = null
setIsMessagesLoading(true)
const { startTime, endTime } = getDateRangeParams(dateRange)
const cachedMsgs = await window.api.getCachedMessages(contact.md5, startTime, endTime)
const cachedPage = await window.api.getCachedMessagePage(contact.md5)
const cachedMsgs = cachedPage.messages
if (selectedContactMd5Ref.current !== contact.md5) return
if (cachedMsgs.length) {
if (cachedPage.groupSnapshot) {
storeGroupMemberMeta(contact, cachedPage.groupSnapshot)
}
messageHistoryRef.current = cachedMsgs
setMessages(
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, cachedMsgs.slice(-VIEW_MESSAGE_LIMIT))
mergeSyntheticMessages(contact, cachedMsgs.slice(-INITIAL_MESSAGE_COUNT))
)
)
} else {
setMessages([])
const needsLivePage = forceLive || isDatabaseConnected
if (!needsLivePage) {
setIsMessagesLoading(false)
if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (!snapshot || selectedContactMd5Ref.current !== contact.md5) return
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, current, snapshot.roomId)
)
)
})
}, 0)
}
return
}
if (cachedMsgs.length >= INITIAL_MESSAGE_COUNT) setIsMessagesLoading(false)
await waitForPaint()
try {
const msgs = await window.api.getMessages(contact.md5, startTime, endTime, {
limit: VIEW_MESSAGE_LIMIT
const loadLivePage = async (): Promise<void> => {
const msgs = await window.api.getMessages(contact.md5, undefined, undefined, {
limit: MESSAGE_PREFETCH_COUNT
})
if (selectedContactMd5Ref.current !== contact.md5) return
const cachedMessages = applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, msgs))
setMessages(cachedMessages)
if (contact.type === 'group') {
messageHistoryRef.current = msgs
const visibleMessages = applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT))
)
setMessages(visibleMessages)
if (contact.type === 'group' && visibleMessages.length > 0) {
window.setTimeout(() => {
void loadGroupMemberMeta(contact).then((snapshot) => {
if (selectedContactMd5Ref.current !== contact.md5) return
@@ -742,42 +973,97 @@ function App(): React.ReactElement {
})
}, 120)
}
}
const prefetchPromise = loadLivePage()
messagePrefetchRef.current = prefetchPromise
try {
await prefetchPromise
} finally {
if (messagePrefetchRef.current === prefetchPromise) messagePrefetchRef.current = null
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
React.useEffect(() => {
if (!isDatabaseConnected || !selectedContact) return
void handleSelectContact(selectedContact)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isDatabaseConnected])
const loadStartupCache = async (): Promise<boolean> => {
try {
const cache = await window.api.getStartupCache()
if (!cache?.contacts.length) return false
setContacts(cache.contacts)
setFilteredContacts(cache.contacts)
if (cache.self) setSelfInfo(cache.self)
return true
} catch (error) {
console.warn('[StartupCache] load failed:', error)
return false
}
}
const handleLoadOlderMessages = async (): Promise<void> => {
const contact = selectedContact
if (!contact || messagesRef.current.length === 0) return
if (messagePrefetchRef.current) await messagePrefetchRef.current
if (selectedContactMd5Ref.current !== contact.md5) return
const currentMessages = messagesRef.current
const historyMessages = messageHistoryRef.current
const firstVisibleIndex = historyMessages.findIndex(
(message) => getMessageIdentity(message) === getMessageIdentity(currentMessages[0])
)
if (firstVisibleIndex > 0) {
const inMemoryOlder = historyMessages.slice(
Math.max(0, firstVisibleIndex - MESSAGE_PAGE_SIZE),
firstVisibleIndex
)
setMessages((current) =>
applyGroupMemberMeta(
contact,
mergeSyntheticMessages(contact, mergeMessagePages(inMemoryOlder, current))
)
)
return
}
const oldestTime = currentMessages[0]?.createTime
if (!oldestTime) return
setIsMessagesLoading(true)
try {
const cachedPage = await window.api.getCachedMessagePage(
contact.md5,
undefined,
oldestTime - 1
)
const olderMessages = cachedPage.hit
? cachedPage.messages
: await window.api.getMessages(contact.md5, undefined, oldestTime - 1, {
limit: MESSAGE_PAGE_SIZE
})
if (selectedContactMd5Ref.current !== contact.md5) return
messageHistoryRef.current = mergeMessagePages(olderMessages, historyMessages)
setMessages((current) =>
applyGroupMemberMeta(contact, mergeSyntheticMessages(contact, mergeMessagePages(olderMessages, current)))
)
} catch (error) {
console.warn('[Messages] older page load failed:', error)
} finally {
if (selectedContactMd5Ref.current === contact.md5) setIsMessagesLoading(false)
}
}
const handleDateRangeChange = (range: string): void => {
setDateRange(range)
if (selectedContact) {
const { startTime, endTime } = getDateRangeParams(range)
window.api
.getCachedMessages(selectedContact.md5, startTime, endTime)
.then((cachedMessages) => {
if (!cachedMessages.length) return
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, cachedMessages.slice(-VIEW_MESSAGE_LIMIT))
)
)
})
setIsMessagesLoading(true)
window.api
.getMessages(selectedContact.md5, startTime, endTime, { limit: VIEW_MESSAGE_LIMIT })
.then((nextMessages) => {
setMessages(
applyGroupMemberMeta(
selectedContact,
mergeSyntheticMessages(selectedContact, nextMessages)
)
)
setIsMessagesLoading(false)
})
.catch((error) => {
console.warn('[Messages] date range load failed:', error)
setIsMessagesLoading(false)
const loadExportPreview = async (contact: Contact): Promise<void> => {
setSelectedContact(contact)
selectedContactMd5Ref.current = contact.md5
try {
const previewMessages = await window.api.getMessages(contact.md5, undefined, undefined, {
limit: EXPORT_PREVIEW_LIMIT
})
if (selectedContactMd5Ref.current === contact.md5) setMessages(previewMessages)
} catch (error) {
console.warn('[Export] preview load failed:', error)
}
}
@@ -797,12 +1083,11 @@ function App(): React.ReactElement {
}
refreshInFlight = true
try {
const range = getDateRangeParams(dateRange)
const latestMessages = await window.api.getMessages(
contactMd5,
range.startTime,
range.endTime,
{ limit: VIEW_MESSAGE_LIMIT }
undefined,
undefined,
{ limit: INITIAL_MESSAGE_COUNT }
)
const nextMessages = applyGroupMemberMeta(
selectedContact,
@@ -827,7 +1112,10 @@ function App(): React.ReactElement {
}
}
const unsubscribe = window.api.onWcdbChange(() => {
const unsubscribe = window.api.onWcdbChange(({ json }) => {
const eventText = String(json || '').toLowerCase()
const targetIds = [contactMd5, selectedContact.m_nsUsrName].filter(Boolean).map((value) => value.toLowerCase())
if (!targetIds.some((targetId) => eventText.includes(targetId))) return
if (refreshTimer) window.clearTimeout(refreshTimer)
refreshTimer = window.setTimeout(() => {
refreshTimer = null
@@ -841,7 +1129,6 @@ function App(): React.ReactElement {
unsubscribe()
}
}, [
dateRange,
isAuthenticated,
isNativeMonitorActive,
selectedContact,
@@ -869,6 +1156,8 @@ function App(): React.ReactElement {
const handlePageChange = (page: AppPage): void => {
setActivePage(page)
if (page === 'archive' && selectedContact) void handleSelectContact(selectedContact)
if (page === 'export' && selectedContact) void loadExportPreview(selectedContact)
if (page === 'settings') setSettingsCategory('account-database')
if (page === 'report' && isGroupContact(selectedContact) && !reportSourceContact) {
setReportSourceContact(selectedContact)
@@ -1065,8 +1354,6 @@ function App(): React.ReactElement {
onSearch={handleSearchContacts}
onContentFilter={setContentFilter}
width={sidebarWidth}
dateRange={dateRange}
onDateRangeChange={handleDateRangeChange}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onOpenSettings={openSettings}
@@ -1078,10 +1365,11 @@ function App(): React.ReactElement {
messages={messages}
isLoadingMessages={isMessagesLoading}
contentFilter={contentFilter}
dateRange={dateRange}
onContentFilterChange={setContentFilter}
onRefresh={() => selectedContact && handleSelectContact(selectedContact)}
onRefresh={() => selectedContact && handleSelectContact(selectedContact, true)}
onRefreshData={loadContacts}
onReloadAvatars={handleReloadCurrentAvatars}
onLoadOlderMessages={handleLoadOlderMessages}
onCreateGroupReport={handleOpenReportWorkspace}
isAiLoading={reportGeneration.isGenerating}
/>
@@ -1201,8 +1489,22 @@ function App(): React.ReactElement {
/>
)
case 'search':
case 'export':
return renderPlaceholderPage(activePage)
case 'export':
return (
<ExportWorkspace
contacts={contacts}
selectedContact={selectedContact}
previewMessages={messages}
selfInfo={selfInfo}
dbReady={isDatabaseConnected}
onSelectContact={loadExportPreview}
onOpenSettings={openSettings}
exportTasks={exportTasks}
onStartExport={handleStartExport}
onCancelExport={handleCancelExport}
/>
)
}
}
File diff suppressed because it is too large Load Diff
+41 -107
View File
@@ -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,61 +11,25 @@ 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 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] || '当前范围'
}
const ChatWindow: React.FC<ChatWindowProps> = ({
contact,
messages,
isLoadingMessages,
contentFilter,
dateRange = 'today',
onContentFilterChange,
onRefresh,
onRefreshData,
onReloadAvatars,
onLoadOlderMessages,
onCreateGroupReport,
isAiLoading = false
}) => {
@@ -85,6 +48,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' })
@@ -94,13 +59,33 @@ 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 (!isAtLatest) return
const frame = window.requestAnimationFrame(() => scrollToBottom())
return () => window.cancelAnimationFrame(frame)
}, [messages, scrollToBottom])
}, [isAtLatest, messages, scrollToBottom])
useEffect(() => {
if (!isAtLatest) 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, scrollToBottom])
const openImagePreview = (imageUrl: string): void => {
setPreviewImage(imageUrl)
@@ -153,6 +138,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
@@ -171,65 +166,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 || '')
@@ -243,23 +179,18 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
}, [messages, contentFilter])
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} />
@@ -273,13 +204,16 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
listRef={messageListRef}
bottomRef={messagesEndRef}
onScroll={handleMessageListScroll}
onReachTop={onLoadOlderMessages}
onImageClick={openImagePreview}
/>
<ChatStatusBar
count={filteredMessages.length}
showAvatar={showAvatar}
isAtLatest={isAtLatest}
isReloadingAvatars={isReloadingAvatars}
onShowAvatarChange={setShowAvatar}
onReloadAvatars={() => void handleReloadAvatars()}
onJumpToLatest={scrollToBottom}
/>
+79 -4
View File
@@ -1,11 +1,51 @@
import { useState, useCallback, useEffect, useRef } from 'react'
import type { JSX, MouseEvent } from 'react'
type CachedImage = { data: string; isThumbnail: boolean }
const MAX_IMAGE_CACHE_ENTRIES = 80
const imageDataUrlCache = new Map<string, CachedImage>()
function imageCacheKeys(imageMd5?: string, imageDatName?: string): string[] {
return [imageMd5 ? `md5:${imageMd5}` : '', imageDatName ? `dat:${imageDatName}` : ''].filter(
Boolean
)
}
function getCachedImage(imageMd5?: string, imageDatName?: string): CachedImage | undefined {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
const cached = imageDataUrlCache.get(key)
if (cached) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
return cached
}
}
return undefined
}
function cacheImage(
imageMd5: string | undefined,
imageDatName: string | undefined,
cached: CachedImage
): void {
for (const key of imageCacheKeys(imageMd5, imageDatName)) {
imageDataUrlCache.delete(key)
imageDataUrlCache.set(key, cached)
}
while (imageDataUrlCache.size > MAX_IMAGE_CACHE_ENTRIES) {
const oldestKey = imageDataUrlCache.keys().next().value
if (!oldestKey) break
imageDataUrlCache.delete(oldestKey)
}
}
interface ImageBubbleProps {
imageMd5?: string
imageDatName?: string
sessionId?: string
isThumb?: boolean
fallbackUrl?: string
onImageClick?: (imageUrl: string) => void
}
@@ -14,18 +54,27 @@ export function ImageBubble({
imageDatName,
sessionId,
isThumb = false,
fallbackUrl,
onImageClick
}: ImageBubbleProps): JSX.Element {
const [imageUrl, setImageUrl] = useState<string | null>(null)
const initialCachedImage = getCachedImage(imageMd5, imageDatName)
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 loadImage = useCallback(async () => {
if (imageUrl || loading) return
if (!imageMd5 && !imageDatName) {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
return
}
setError('缺少图片标识')
return
}
@@ -34,18 +83,35 @@ export function ImageBubble({
try {
const result = await window.api.getImage(imageMd5, imageDatName || isThumb, sessionId)
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
} else {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError(result.error || '加载图片失败')
}
}
} catch {
if (fallbackUrl) {
setImageUrl(fallbackUrl)
setUsingFallback(true)
setError(null)
} else {
setError('加载图片失败')
}
} finally {
setLoading(false)
}
}, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
}, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading])
useEffect(() => {
if (imageUrl || loading || error) return
@@ -92,7 +158,12 @@ export function ImageBubble({
force: true
})
if (result.success && result.data?.startsWith('data:image/')) {
cacheImage(imageMd5, imageDatName, {
data: result.data,
isThumbnail: Boolean(result.isThumb)
})
setImageUrl(result.data)
setUsingFallback(false)
setIsThumbnail(Boolean(result.isThumb))
setError(null)
onImageClick?.(result.data)
@@ -136,7 +207,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>}
{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'
@@ -15,7 +16,22 @@ interface MessageBubbleProps {
onImageClick: (imageUrl: string) => void
}
const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息']
const RICH_MESSAGE_TYPES = [
'名片',
'位置',
'分享消息',
'小程序',
'微信红包',
'公众号链接',
'文件',
'文件发送中',
'视频号',
'转账',
'引用消息',
'通话',
'表情包',
'系统消息'
]
export function MessageBubble({
message,
@@ -27,6 +43,7 @@ export function MessageBubble({
}: 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)
@@ -35,7 +52,7 @@ export function MessageBubble({
<div
className={`message-bubble ${isVoice ? 'voice-bubble' : ''} ${
isImage ? 'image-message-bubble' : ''
}`}
} ${isVideo ? 'video-message-bubble' : ''}`}
>
{isVoice && message.sessionId ? (
<VoicePlayer
@@ -50,12 +67,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>
@@ -57,7 +57,7 @@ export function MessageGroup({
)}
{!isMine && !shouldShowAvatar && <div className="message-avatar-spacer" aria-hidden />}
<div className="message-stack">
{!isMine && isGroupChat && shouldShowAvatar && (
{!isMine && isGroupChat && (
<div className="message-sender-name">{displayName}</div>
)}
{group.messages.map((message, index) => (
@@ -14,6 +14,7 @@ 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
}
@@ -27,9 +28,13 @@ export function MessageList({
listRef,
bottomRef,
onScroll,
onReachTop,
onImageClick
}: MessageListProps): React.ReactElement {
const groups = React.useMemo(() => buildMessageGroups(messages), [messages])
const groupsRef = React.useRef(groups)
const loadingOlderRef = React.useRef(false)
groupsRef.current = groups
// TanStack Virtual intentionally exposes mutable measurement methods.
// eslint-disable-next-line react-hooks/incompatible-library
const virtualizer = useVirtualizer({
@@ -41,8 +46,60 @@ export function MessageList({
})
const virtualItems = virtualizer.getVirtualItems()
const handleScroll = (event: React.UIEvent<HTMLDivElement>): void => {
onScroll(event)
const scrollElement = event.currentTarget
if (
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">
@@ -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) => (
<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
key={contact.md5}
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,30 +79,50 @@ 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} />
</div>
@@ -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 CSVJSON 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>
)
}
@@ -6,6 +6,7 @@ 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 type { Contact } from '../../../../shared/types'
import type { AIRuntimeModelConfig } from '../../../../shared/ai-provider'
@@ -49,14 +50,10 @@ export function SettingsWorkspace({
dbReady={dbReady}
onOpenSettings={onOpenSettings}
/>
{selectedCategory === 'account-database' ? (
<AccountDatabasePage
dbKey={dbKey}
dbReady={dbReady}
selfInfo={selfInfo}
onNotice={onNotice}
/>
) : selectedCategory === 'database-key' ? (
<div className={`settings-page-panel ${selectedCategory === 'account-database' ? 'active' : ''}`}>
<AccountDatabasePage dbKey={dbKey} dbReady={dbReady} selfInfo={selfInfo} onNotice={onNotice} />
</div>
<div className={`settings-page-panel ${selectedCategory === 'database-key' ? 'active' : ''}`}>
<DatabaseKeyPage
dbKey={dbKey}
dbReady={dbReady}
@@ -69,12 +66,20 @@ 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} />
) : (
</div>
<div className={`settings-page-panel ${selectedCategory === 'recall-protection' ? 'active' : ''}`}>
<RecallProtectionPage onNotice={onNotice} />
</div>
{!['account-database', 'database-key', 'image-key', 'ai-model', 'recall-protection'].includes(selectedCategory) && (
<div className="settings-page-panel active">
<SettingsEmptyState label={SETTINGS_CATEGORY_LABELS[selectedCategory]} />
</div>
)}
</div>
)
@@ -45,6 +45,10 @@ export function AutoDetectImageKeySection({
{state.status.platform === 'darwin'
? '扫描本机微信缓存并通过图片模板验证候选密钥。'
: '扫描微信进程内存并通过本地图片模板验证候选密钥。'}
<br />
<small className="image-auto-scope-hint">
WeChat 4.0V3
</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'
@@ -27,11 +27,14 @@ export function AccountDatabasePage({
}): React.ReactElement {
const controller = useAccountDatabaseController({ dbKey, dbReady, selfInfo, onNotice })
const [autoLogin, setAutoLogin] = useState(false)
const [recallProtectionEnabled, setRecallProtectionEnabled] = useState(false)
useEffect(() => {
let active = true
void window.api.getSettings().then((result) => {
if (active) setAutoLogin(result.settings.autoLogin)
if (!active) return
setAutoLogin(result.settings.autoLogin)
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
})
return () => {
active = false
@@ -46,6 +49,12 @@ export function AccountDatabasePage({
setAutoLogin(result.settings.autoLogin)
onNotice(checked ? '已开启启动时自动连接' : '已关闭启动时自动连接')
}
const changeRecallProtection = async (checked: boolean): Promise<void> => {
const result = await window.api.setSettings({ recallProtectionEnabled: checked })
setRecallProtectionEnabled(result.settings.recallProtectionEnabled)
onNotice(checked ? '已开启防撤回' : '已关闭防撤回')
}
return (
<div className="settings-page">
<header className="settings-page-header">
@@ -93,6 +102,26 @@ export function AccountDatabasePage({
/>
</label>
</section>
<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={recallProtectionEnabled}
onChange={(event) => void changeRecallProtection(event.target.checked)}
/>
</label>
<aside className="settings-recall-notice">
<strong></strong>
<span></span>
</aside>
</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>
)
}
+66
View File
@@ -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
+55 -1
View File
@@ -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