cortico-world-wechat 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,22 @@
2
2
 
3
3
  本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
4
4
 
5
+ ## 0.1.3
6
+
7
+ - **修复:读取失败不会自愈**。此前一旦某次读取失败(并发写、微信正在写库、缓存半成品),
8
+ 扩展会每秒重试、每秒失败,直到重启 Coopanion —— 实测踩到过该状态。
9
+ 现在:缓存不可用即丢弃并全量重建;WAL 合并改为"临时文件 + 验证通过才原子替换",
10
+ 合并失败只降级为"少一次增量",不再污染缓存。
11
+ - **修复:多进程共用同一解密缓存会互相踩**(扩展 + 排查脚本同时跑 → 永久 ile is not a database)。
12
+ 缓存目录改为按进程隔离(cache/p<pid>)。
13
+ - **修复:用 mtime 判断"主库是否变化"导致每秒全量解密 20MB**。微信活着时 mtime 每秒都在变;
14
+ 改为按**文件大小**判断全量重建,内容增量靠 WAL 合并补齐。实测二次读取 5224 页库仅需 13ms。
15
+ - 依赖内容自省的 describe.json、 est/ 均未变。
16
+ ## 0.1.2
17
+
18
+ - 文档重写:安装给出三条路径(npm / 离线包 / 源码,按推荐顺序)、配置项逐条说明、
19
+ "没反应时按顺序查"的排查表、以及"消息正文会进入模型上下文"的明确提示。
20
+ - 无功能变化。
5
21
  ## 0.1.1
6
22
 
7
23
  - **打包内容调整**(无功能变化):
@@ -21,3 +37,5 @@
21
37
  - **取密钥**:微信的库密钥只存在于其进程内存中,由 `tools/fetch-keys.py` 按需取出
22
38
  (短命进程,取完即退;失效时自动重取)。这是本项目唯一用到 Python 的地方。
23
39
  - 实测:真机库解密 `PRAGMA integrity_check = ok`;测试 26 例(7 监听逻辑 + 6 文本格式化 + 13 World 行为)。
40
+
41
+
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # wechat-notifier · 微信消息桌宠提示器
2
+
3
+ 把 Windows 微信新消息播报给桌宠(Coopanion / 大肥鱼):她看到后用一句话汇报,并能用 TTS 念出来。
4
+
5
+ > **它是一个 Coopanion World 扩展。** 监听、解密、轮询、限流、推送**全部在扩展里**(Node),
6
+ > 随 Coopanion 一起启动 —— 没有独立常驻进程、没有 exe、没有开机自启任务。
7
+ >
8
+ > 唯一的例外是**取密钥**:微信的库密钥只存在于微信进程内存中,需要一个短命脚本把它取出来
9
+ > (几秒完成、取完即退,失效时由扩展自动重拉)。这是本项目唯一用到 Python 的地方,
10
+ > 取舍理由见下文「为什么取密钥保留 Python」。
11
+
12
+ | | |
13
+ |---|---|
14
+ | **npm 包** | [`cortico-world-wechat`](https://www.npmjs.com/package/cortico-world-wechat) |
15
+ | **源码仓** | https://gitee.com/ydcy_studio/wechat-notifier |
16
+ | **平台** | Windows x64 | 微信 4.x(需已登录) | Coopanion 0.2.0+ |
17
+
18
+ ---
19
+
20
+ ## 快速开始(三步)
21
+
22
+ ```powershell
23
+ # ① 装扩展:在 Coopanion 的扩展页搜 cortico-world-wechat 点安装
24
+ # (或命令行:进 Coopanion 的 data\extensions 目录执行下面这行)
25
+ pnpm add cortico-world-wechat
26
+
27
+ # ② 准备密钥(取密钥需要 Python;扩展启动时会自动拉起它,也可手动跑)
28
+ # 见下方「准备密钥」
29
+
30
+ # ③ 重启 Coopanion(扩展在启动时装载,不热更)
31
+ ```
32
+
33
+ 装好并重启后,随便发一条微信消息,桌宠应当冒泡汇报。
34
+
35
+ ---
36
+
37
+ ## 架构
38
+
39
+ ```text
40
+ 微信 4.x ──加密库──▶ Coopanion World 扩展(主体)
41
+ (已登录) ├─ 解密:纯 node:crypto(AES-256-CBC raw CBC)+ WAL 增量合并
42
+ ├─ 轮询:sort_seq 水位(落盘,重启不重复)
43
+ ├─ 规范化 + 限流(全局间隔 / 会话冷却 / 静音时段)
44
+ └─ host.pushEvent ──▶ 桌宠(气泡 / TTS)
45
+ ▲
46
+ └── keys.json ◀── tools/fetch-keys.py(短命,仅取密钥)
47
+ ```
48
+
49
+ | 部件 | 位置 | 说明 |
50
+ |---|---|---|
51
+ | **World 扩展(主体)** | `cortico-world-wechat/src/` | 监听、解密、轮询、推送全在这里 |
52
+ | 取密钥工具(例外) | `cortico-world-wechat/tools/fetch-keys.py` | 密钥缺失/失效时被自动拉起,几秒后退出 |
53
+ | 密钥与状态 | `%LOCALAPPDATA%\WeChatNotifier\`(`keys.json`、`watermarks.json`、`cache\`) | 含密钥,**切勿入库** |
54
+
55
+ ### 为什么取密钥保留 Python
56
+
57
+ 密钥只在微信进程内存里,"把它找出来"需要**版本相关的结构锚点**
58
+ (`com.Tencent.WCDB.Config.Cipher` 标记 + 多层指针 + XOR 解码)。上游 `wechatauto-replica`
59
+ 已把这套做得很稳,而我们在 Node 里试过的替代方案(宽扫内存候选 + 页 HMAC 验真)
60
+ **性能不可接受**(约 4000 万次 HMAC,实测 10 分钟跑不完)。
61
+
62
+ 所以取舍是:**取密钥复用已验证实现(短命、按需),其余全部在扩展里**。
63
+ 如果将来要彻底去掉 Python,正确路径是复刻上游那套"标记 + 指针链"(只扫 Weixin.dll,秒级),
64
+ 而不是宽扫 —— 这条路记录在 `设计文档.md` 里备用。
65
+
66
+ ---
67
+
68
+ ## 安装(三种方式,按推荐顺序)
69
+
70
+ ### 方式一:从 npm 安装(推荐)
71
+
72
+ Coopanion 的扩展页会用 `keywords:cortico-world` 搜 npm。搜 **cortico-world-wechat** 点安装即可;
73
+ 它内部执行 pnpm,依赖(含原生 sqlite 绑定)会自动装好。
74
+
75
+ 命令行等价做法:
76
+
77
+ ```powershell
78
+ cd 'D:\MyApp\Coopanion\data\extensions'
79
+ pnpm add cortico-world-wechat
80
+ ```
81
+
82
+ ### 方式二:离线发布包(不想装 npm / 内网)
83
+
84
+ 下载 `wechat-notifier-v<版本>-win-x64.zip`,解压后:
85
+
86
+ ```powershell
87
+ pwsh -File install.ps1 # 默认装到 D:\MyApp\Coopanion
88
+ pwsh -File install.ps1 -CoopanionRoot 'C:\Apps\Coopanion' # 自定义安装目录
89
+ pwsh -File install.ps1 -KeysOnly # 只看"怎么准备密钥"
90
+ ```
91
+
92
+ 它会:复制扩展到 `extensions/node_modules/`、复制**原生 sqlite 绑定**(离线包已内置,
93
+ 只保留 win32-x64)、把 `worlds.wechat-notifier` 写进配置(改前自动备份)。
94
+
95
+ ### 方式三:从源码(开发者)
96
+
97
+ ```powershell
98
+ $src = '<本仓库>\cortico-world-wechat'
99
+ $dst = 'D:\MyApp\Coopanion\data\extensions\node_modules\cortico-world-wechat'
100
+ robocopy $src $dst /E /XD node_modules
101
+ # 依赖装到 Coopanion 的 extensions 下(不要装进扩展目录,避免嵌套)
102
+ cd 'D:\MyApp\Coopanion\data\extensions'
103
+ pnpm add better-sqlite3-multiple-ciphers
104
+ ```
105
+
106
+ ---
107
+
108
+ ## 配置
109
+
110
+ `D:\MyApp\Coopanion\data\home\companion\config.json` 的 `worlds.wechat-notifier`:
111
+
112
+ ```json
113
+ "wechat-notifier": {
114
+ "mode": "in-process",
115
+ "enabled": true,
116
+ "notifySelf": false,
117
+ "notifyGroup": true,
118
+ "autoFetchKeys": true,
119
+ "pollIntervalSec": 1,
120
+ "previewMaxChars": 60,
121
+ "throttle": { "global_min_gap_sec": 20, "per_chat_cooldown_sec": 120 },
122
+ "quietHours": { "enabled": true, "start": "23:30", "end": "08:00" }
123
+ }
124
+ ```
125
+
126
+ | 键 | 默认 | 含义 |
127
+ |---|---|---|
128
+ | `mode` | `in-process` | `in-process`=扩展自己读库(推荐);`bridge`=收外部进程推来的事件(兼容旧架构) |
129
+ | `notifySelf` | `false` | 是否提醒"自己发的消息" |
130
+ | `notifyGroup` | `true` | 是否提醒群消息 |
131
+ | `autoFetchKeys` | `true` | 密钥缺失/失效时自动拉起取密钥工具 |
132
+ | `pollIntervalSec` | `1` | 轮询间隔(秒) |
133
+ | `previewMaxChars` | `60` | 正文截断长度(隐私 + 上下文成本) |
134
+ | `throttle.*` | 20 / 120 | 全局最短间隔、同会话冷却(秒) |
135
+ | `quietHours.*` | `23:30–08:00` | 静音时段;**被吞掉即丢弃、不补发** |
136
+
137
+ ---
138
+
139
+ ## 准备密钥
140
+
141
+ 密钥只在微信进程内存里,需要一个 64 位 Python 环境来取(**建议装在 `%LOCALAPPDATA%` 下**)。
142
+
143
+ ```powershell
144
+ # 建环境(一次性)
145
+ python -m venv "$env:LOCALAPPDATA\wechat-notifier-venv"
146
+ $py = "$env:LOCALAPPDATA\wechat-notifier-venv\Scripts\python.exe"
147
+ & $py -m pip install -U pip
148
+ & $py -m pip install "wechatauto-replica>=1.2.6.1" `
149
+ -i https://mirrors.cloud.tencent.com/pypi/simple --no-build-isolation
150
+
151
+ # 取密钥(微信需已登录)
152
+ & $py "<扩展目录>\tools\fetch-keys.py"
153
+ # 产出 %LOCALAPPDATA%\WeChatNotifier\keys.json(24 个库密钥)
154
+ ```
155
+
156
+ **一般不用手动跑**:扩展启动时发现没有 `keys.json`,会自己拉起这个脚本(实测冷启动 4 秒完成)。
157
+ 微信重新登录 / 升级导致密钥变化时会自动重取。
158
+
159
+ ---
160
+
161
+ ## 确认在工作
162
+
163
+ 日志在 `D:\MyApp\Coopanion\data\logs\core.log`,搜 `wechat-notifier`:
164
+
165
+ ```
166
+ [wechat-notifier] 读库模式:db_storage=...\xwechat_files\<账号>\db_storage
167
+ [wechat-notifier] 消息分片 4 个
168
+ [wechat-notifier] [listener] 联系人 8269 条
169
+ [wechat-notifier] [listener] 放行 chat=文件传输助手 sender=胡闹 preview=测试3
170
+ [wechat-notifier] 收到 文件传输助手 的消息 → 推给她:【微信】…
171
+ ```
172
+
173
+ > 状态端点(`http://127.0.0.1:19391/wechat/status`)**只在 `mode: "bridge"` 下存在**
174
+ > (那种模式才起 WS/HTTP 服务)。`in-process` 模式靠上面的日志判断。
175
+
176
+ ### 没反应时按顺序查
177
+
178
+ | 现象 | 原因 | 处理 |
179
+ |---|---|---|
180
+ | 日志里 `keysError` 非空 | 取不到密钥 | 确认微信**已登录**;把 Python 换成 64 位;杀软放行读内存 |
181
+ | 日志停在"密钥缓存不存在" | 取密钥工具起不来 | 检查 Python 环境是否存在、`wechatauto-replica` 是否装好 |
182
+ | 有"放行"但桌宠不吭声 | 问题在桌宠侧 | 看 core.log 有无 `收到 … 推给她`;有则说明已推,检查她的 persona / TTS 开关 |
183
+ | 完全没日志 | 扩展没装载 | 扩展在**启动时**装载:改完配置或代码必须**重启 Coopanion** |
184
+ | 几分钟没有任何"放行" | 可能确实没人发消息 | 用手机发一条;记得看是否被限流吞掉(日志会写 `限流吞掉 … global_gap`) |
185
+
186
+ ---
187
+
188
+ ## 测试
189
+
190
+ ```bash
191
+ cd cortico-world-wechat
192
+
193
+ # 纯逻辑(26 例:正文规范化、限流、类型映射、WS 服务、状态端点、事件推送)
194
+ node test/listener.test.mjs
195
+ node test/format.test.mjs
196
+ node test/world.test.mjs
197
+
198
+ # 集成(需要真机微信库 + keys.json):验证解密正确性
199
+ # 微信升级后先跑这个 —— integrity_check = ok 才说明页布局仍被正确解密
200
+ node test/decrypt-verify.mjs 'contact\contact.db'
201
+ ```
202
+
203
+ > 在受限沙箱里不要用 `node --test` / `npm test`(可能 `spawn EPERM`),**逐个文件直跑**。
204
+
205
+ ---
206
+
207
+ ## 发布与分发
208
+
209
+ - npm:`npm publish`(版本号改 `package.json` 并在 `CHANGELOG.md` 加 `## <版本>` 段)
210
+ - 离线包:`pwsh -File release/build-package.ps1 -Version <版本>`
211
+ - 官方扩展市场:机制与清单见 [docs/发布到官方市场.md](docs/发布到官方市场.md)
212
+
213
+ ---
214
+
215
+ ## 已知限制
216
+
217
+ 1. 仅 Windows x64;微信必须**已登录**(密钥在其进程内存里)。
218
+ 2. 取密钥需要读微信进程内存,**可能被杀软拦截**(当前未遇)。
219
+ 3. 依赖上游 [`wechatauto-replica`](https://pypi.org/project/wechatauto-replica/) 取密钥;
220
+ 微信大版本升级后若取不到密钥,先跑它的体检命令排查。
221
+ 4. 只读微信库:不回消息、不写库、不驱动微信窗口。
222
+ 5. 消息频率高,**限流是必需的**(每次推送都会真实唤醒桌宠、走一次 LLM 调用)。
223
+ 6. **消息正文会进入桌宠的模型上下文**(随 `pushEvent` 交给她处理)——扩展在市场里已声明
224
+ `sensitive-data` 警示标签,装之前请知悉。
225
+ 7. 主要在**一台机器、一个微信版本(4.1.15.13)、单账号**上验证过;市场标签为 `experimental`。
226
+
227
+ ---
228
+
229
+ ## 实现要点(踩过的坑,改代码前先读)
230
+
231
+ | 坑 | 现象 | 正解 |
232
+ |---|---|---|
233
+ | **水位必须用 `sort_seq`** | 用 `local_id` 会漏消息:`local_id` 在每个会话表内各自计数,跨 `Msg_*` 表不单调(实测一条新消息 `local_id=2392` 小于"分片最大 2564"被永久跳过) | 水位存最大 `sort_seq`(毫秒) |
234
+ | **IV 在页内 `[4016:4032]`** | 当成文件头盐或页尾 IV 会解出乱码 | 保留区前 16 字节 |
235
+ | **第 2 页起密文从 offset 0 开始** | 第 1 页才是 `[16:4016)`;一律从 16 开始会在第 2 页起解错 | 按页号分支 |
236
+ | **必须 `setAutoPadding(false)`** | Node 默认校验 PKCS#7,SQLCipher 页是 raw CBC → `bad decrypt` | 关闭自动填充 |
237
+ | **WAL 帧只合并同 salt 的** | checkpoint 会重置 WAL,旧世代帧合并会用过期页覆盖新数据 | 比对 WAL 头 salt |
238
+ | **`real_sender_id` 要按所在分片解析** | 用别的表解析会把发送者显示成无关的人(上游 issue #34) | 查该分片自己的 `Name2Id` |
239
+
240
+ ---
241
+
242
+ ## License
243
+
244
+ MIT
package/package.json CHANGED
@@ -1,52 +1,52 @@
1
- {
2
- "name": "cortico-world-wechat",
3
- "version": "0.1.1",
4
- "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
5
- "license": "MIT",
6
- "type": "module",
7
- "main": "src/index.js",
8
- "exports": {
9
- ".": "./src/index.js",
10
- "./package.json": "./package.json"
11
- },
12
- "files": [
13
- "src",
14
- "tools",
15
- "test",
16
- "describe.json",
17
- "CHANGELOG.md",
18
- "README.md"
19
- ],
20
- "os": [
21
- "win32"
22
- ],
23
- "engines": {
24
- "node": "^22.19.0 || >=24.0.0"
25
- },
26
- "dependencies": {
27
- "better-sqlite3-multiple-ciphers": "^13.0.3",
28
- "ws": "^8.21.3"
29
- },
30
- "keywords": [
31
- "cortico-world",
32
- "cortico",
33
- "wechat",
34
- "weixin",
35
- "notifier"
36
- ],
37
- "cortico": {
38
- "displayName": "微信消息提示",
39
- "kind": "world",
40
- "api": 6,
41
- "describe": "describe.json",
42
- "tags": [
43
- "im",
44
- "external-setup",
45
- "experimental",
46
- "sensitive-data"
47
- ]
48
- },
49
- "scripts": {
50
- "test": "node --test test/format.test.mjs test/world.test.mjs"
51
- }
52
- }
1
+ {
2
+ "name": "cortico-world-wechat",
3
+ "version": "0.1.3",
4
+ "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "src/index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./package.json": "./package.json"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "tools",
15
+ "test",
16
+ "describe.json",
17
+ "CHANGELOG.md",
18
+ "README.md"
19
+ ],
20
+ "os": [
21
+ "win32"
22
+ ],
23
+ "engines": {
24
+ "node": "^22.19.0 || >=24.0.0"
25
+ },
26
+ "dependencies": {
27
+ "better-sqlite3-multiple-ciphers": "^13.0.3",
28
+ "ws": "^8.21.3"
29
+ },
30
+ "keywords": [
31
+ "cortico-world",
32
+ "cortico",
33
+ "wechat",
34
+ "weixin",
35
+ "notifier"
36
+ ],
37
+ "cortico": {
38
+ "displayName": "微信消息提示",
39
+ "kind": "world",
40
+ "api": 6,
41
+ "describe": "describe.json",
42
+ "tags": [
43
+ "im",
44
+ "external-setup",
45
+ "experimental",
46
+ "sensitive-data"
47
+ ]
48
+ },
49
+ "scripts": {
50
+ "test": "node --test test/format.test.mjs test/world.test.mjs"
51
+ }
52
+ }
package/src/wechat-db.js CHANGED
@@ -9,10 +9,10 @@
9
9
  * 只读:不写微信库、不动微信进程;解密产物落在自己的缓存目录。
10
10
  */
11
11
  import { createRequire } from 'node:module';
12
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync, copyFileSync, renameSync } from 'node:fs';
13
13
  import { join, basename } from 'node:path';
14
14
 
15
- import { PAGE_SIZE, decryptPage, decryptFile, readPage1, verifyDbKey } from './wechat-crypto.js';
15
+ import { PAGE_SIZE, decryptPage, decryptFile } from './wechat-crypto.js';
16
16
 
17
17
  const require = createRequire(import.meta.url);
18
18
 
@@ -69,11 +69,17 @@ export function listMessageShards(dbStorage) {
69
69
  * 把 -wal 里的加密帧合并进已解密的主库(与上游 _merge_wal 等价)。
70
70
  * 帧:24 字节头 + 4096 页,数值全为**大端**;只合并 salt 与 WAL 头一致的帧
71
71
  * (checkpoint 会重置 WAL,旧世代帧合并会用过期页覆盖新数据 → 库损坏)。
72
+ *
73
+ * ⚠️ 用**临时文件 + 原子替换**:旧实现在正式缓存文件上原地 r+ 覆盖,
74
+ * 一旦中途被并发读取或异常打断,缓存就变成"半新半旧"的不可用状态,
75
+ * 并且调用方会每秒重试 → 永久失败(实测踩过)。
72
76
  */
73
77
  export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
74
78
  if (!existsSync(decryptedPath) || !existsSync(walPath)) return { applied: 0, frames: 0 };
79
+ const tempPath = `${decryptedPath}.merge-tmp`;
80
+ copyFileSync(decryptedPath, tempPath);
75
81
  const walFd = openSync(walPath, 'r');
76
- const dstFd = openSync(decryptedPath, 'r+');
82
+ const dstFd = openSync(tempPath, 'r+');
77
83
  let applied = 0;
78
84
  let last = fromFrame;
79
85
  try {
@@ -105,7 +111,7 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
105
111
  if (maxPgno > 0) {
106
112
  const page1 = Buffer.alloc(PAGE_SIZE);
107
113
  readSync(dstFd, page1, 0, PAGE_SIZE, 0);
108
- const fileSize = statSync(decryptedPath).size;
114
+ const fileSize = statSync(tempPath).size;
109
115
  const dbPages = Math.ceil(fileSize / PAGE_SIZE);
110
116
  const headerPages = page1.readUInt32BE(28);
111
117
  const newPages = Math.max(headerPages, maxPgno, dbPages);
@@ -115,11 +121,13 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
115
121
  writeFileSync(dstFd, patched, { offset: 0 });
116
122
  }
117
123
  }
118
- return { applied, frames: last };
119
124
  } finally {
120
125
  closeSync(walFd);
121
126
  closeSync(dstFd);
122
127
  }
128
+ // 合并完成才替换正式缓存:中途异常时正式缓存保持原样
129
+ renameSync(tempPath, decryptedPath);
130
+ return { applied, frames: last };
123
131
  }
124
132
 
125
133
  /** 读取器:管理"密钥 + 解密缓存 + 增量读消息" */
@@ -130,9 +138,11 @@ export class WeChatReader {
130
138
  constructor(options) {
131
139
  this.dbStorage = options.dbStorage;
132
140
  this.keys = options.keys; // Map<'contact\\contact.db', Buffer>
133
- this.cacheDir = options.cacheDir;
141
+ // 缓存目录按进程隔离:多个进程(扩展 + 排查脚本)共用同一目录会互相踩
142
+ // ——一个在 WAL 合并中途写,另一个正好读到半成品,就永久报 "file is not a database"(实测踩过)
143
+ this.cacheDir = join(options.cacheDir, `p${process.pid}`);
134
144
  this.log = options.logger ?? { info: () => {}, warn: () => {}, debug: () => {} };
135
- this._stamps = new Map(); // rel -> { mtimeMs, size, walFrames }
145
+ this._stamps = new Map(); // rel -> { sourceSize, walSize, walFrames }
136
146
  mkdirSync(this.cacheDir, { recursive: true });
137
147
  }
138
148
 
@@ -140,7 +150,16 @@ export class WeChatReader {
140
150
  return this.keys.get(rel) ?? null;
141
151
  }
142
152
 
143
- /** 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做 */
153
+ /**
154
+ * 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做。
155
+ *
156
+ * 自愈设计(实测踩过:一次读取失败后每秒重试每秒失败,永久不自愈):
157
+ * ① WAL 合并**不直接改缓存**,先合并到临时文件、验证能打开、才原子替换 ——
158
+ * 合并中途被打断/读到半成品,都不会污染正式缓存;
159
+ * ② 任何一步探测到缓存不可用,就丢弃时间戳与缓存文件,下次全量重建;
160
+ * ③ 最坏情况(合并反复失败)退化到"只有主库"的缓存,仍能读到消息——
161
+ * 少的是最近未 checkpoint 的增量,而不是整个分片废掉。
162
+ */
144
163
  ensureDecrypted(rel) {
145
164
  const srcPath = join(this.dbStorage, rel);
146
165
  const walPath = `${srcPath}-wal`;
@@ -151,36 +170,56 @@ export class WeChatReader {
151
170
  const walStat = existsSync(walPath) ? statSync(walPath) : null;
152
171
  const stamp = this._stamps.get(rel);
153
172
 
154
- const sameMain = stamp && stamp.mtimeMs === srcStat.mtimeMs && stamp.size === srcStat.size;
173
+ const sameMain = stamp && stamp.sourceSize === srcStat.size;
155
174
  const sameWal = stamp && (stamp.walSize ?? 0) === (walStat?.size ?? 0);
156
- if (sameMain && sameWal && existsSync(dstPath)) return dstPath;
175
+ if (sameMain && sameWal && existsSync(dstPath) && this._isUsable(dstPath)) return dstPath;
157
176
 
158
- if (!sameMain || !existsSync(dstPath)) {
177
+ // 缓存不可用、或主库大小变了(= 微信做过 checkpoint):全量重建
178
+ //
179
+ // ⚠️ 判断依据是 **size 而不是 mtime**:微信活着时 mtime 几乎每秒都在变
180
+ // (WAL 写入/checkpoint 都会碰它),用 mtime 会导致每秒全量解密 20MB。
181
+ // 内容的新增靠 WAL 增量合并补齐,所以只看 size 既省又能拿到最新消息。
182
+ if (!sameMain || !existsSync(dstPath) || !this._isUsable(dstPath)) {
183
+ this._stamps.delete(rel);
159
184
  const pages = decryptFile(srcPath, dstPath, key);
160
185
  this.log.debug?.(`[reader] 解密 ${rel}(${pages} 页)`);
161
- this._stamps.set(rel, { mtimeMs: srcStat.mtimeMs, size: srcStat.size, walSize: 0, walFrames: 0 });
162
- this._verifyOrThrow(rel, dstPath);
186
+ this._stamps.set(rel, { sourceSize: srcStat.size, walSize: 0, walFrames: 0 });
187
+ if (!this._isUsable(dstPath)) {
188
+ throw new Error(`解密结果不可用(微信是否正在写入该库?稍后会重试): ${rel}`);
189
+ }
163
190
  }
191
+
164
192
  if (walStat) {
165
193
  const prevFrames = this._stamps.get(rel)?.walFrames ?? 0;
166
- const { frames } = mergeWal(dstPath, walPath, key, prevFrames);
167
- const current = this._stamps.get(rel) ?? {};
168
- this._stamps.set(rel, { ...current, walSize: walStat.size, walFrames: frames });
194
+ try {
195
+ const { frames } = mergeWal(dstPath, walPath, key, prevFrames);
196
+ if (!this._isUsable(dstPath)) {
197
+ throw new Error('合并 WAL 后缓存不可用,回退到上次可用状态');
198
+ }
199
+ const current = this._stamps.get(rel) ?? {};
200
+ this._stamps.set(rel, { ...current, walSize: walStat.size, walFrames: frames });
201
+ } catch (error) {
202
+ // 合并失败不致命:丢掉时间戳,下次全量重建(只损失未 checkpoint 的增量)
203
+ this.log.warn?.(`[reader] ${rel} WAL 合并失败(下次重建):${error.message}`);
204
+ this._stamps.delete(rel);
205
+ }
169
206
  }
170
207
  return dstPath;
171
208
  }
172
209
 
173
- /** 解密结果是否真是这个库(防止拿错密钥时静默读出垃圾) */
174
- _verifyOrThrow(rel, dstPath) {
175
- const page1 = readPage1(dstPath);
176
- if (!page1) throw new Error(`解密产物读不到首页: ${rel}`);
177
- if (!page1.subarray(0, 16).equals(Buffer.from('SQLite format 3\0', 'latin1'))) {
178
- throw new Error(`解密结果不是 SQLite 库(密钥可能已失效): ${rel}`);
179
- }
180
- const probe = join(this.dbStorage, rel);
181
- const enc = readPage1(probe);
182
- if (enc && !verifyDbKey(this.keyFor(rel), enc)) {
183
- throw new Error(`密钥已失效(微信可能重新登录过): ${rel}`);
210
+ /** 能否被 sqlite 真正打开(只看文件头不够:半成品也可能头是好的) */
211
+ _isUsable(dbPath) {
212
+ try {
213
+ const Sqlite = getDatabase();
214
+ const db = new Sqlite(dbPath, { readonly: true, fileMustExist: true });
215
+ try {
216
+ db.prepare("SELECT count(*) AS c FROM sqlite_master WHERE type='table'").get();
217
+ return true;
218
+ } finally {
219
+ db.close();
220
+ }
221
+ } catch {
222
+ return false;
184
223
  }
185
224
  }
186
225