cortico-world-wechat 0.1.6 → 0.1.8

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
@@ -3,6 +3,42 @@
3
3
  本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
4
4
 
5
5
 
6
+ ## 0.1.8
7
+
8
+ **这一版修的是"消息会永久漏报",三个原因叠在一起,都是实测排查出来的。**
9
+
10
+ - **修复:新消息可能再也看不到(缓存陈旧)。** 判断"要不要重新解密"过去**只看文件大小**,
11
+ 而微信写新消息时经常一个 size 都不改:它就地覆盖已分配的页,并把新帧写进**预分配好的
12
+ 4 MiB WAL**(本机实测 WAL 恒为 4,194,304 字节)。于是扩展一直用陈旧副本、**也不打任何日志**,
13
+ 表现就是"有时报、有时不报"。
14
+ 现在指纹带 mtime,并按"变的是哪个文件"分档:只有 WAL 变 → 只重合并 WAL(成本约一个 WAL 大小);
15
+ 主库真的多了页 / WAL 被截断重置 / 根本没有 WAL 而主库 mtime 变 → 才全量重解密。
16
+ 空闲时仍走快路径,零开销(实测静止 10 秒 mtime 不动)。
17
+ - **修复:WAL 合并会把帧写到文件开头,直接毁掉解密副本。** `writeFileSync(fd, buf, { offset })`
18
+ 里的 `offset` 是**缓冲区偏移**、不是文件位置(Node 在 fd 上会忽略它并从当前偏移写),
19
+ 所以每一帧都被写到 offset 0,覆盖页 1 → 合并后的缓存打不开、报 `file is not a database`。
20
+ 改用 `writeSync(fd, buf, 0, len, (pgno-1)*PAGE_SIZE)`。此前"几乎从不合并"掩盖了它。
21
+ - **修复:解密副本旁的 SQLite 边车文件会让下一次打开失败。** 副本继承了源库的 WAL 模式头,
22
+ SQLite 每次打开都会建 `<缓存>-shm`/`-wal`;而缓存是原子替换的,替换后边车就陈旧了 →
23
+ 下次打开报 `file is not a database`。现在把副本标成 rollback 模式(不再产生边车),
24
+ 并在替换后清掉遗留边车。
25
+ - **修复(可定位性)**:`toEvent()` 遇到"会话表映射不到 session 表"时原本直接 `return null`,
26
+ 消息被静默丢弃。现在每个这样的表 warn 一次,日志能看出到底是谁被丢了。
27
+ - 新增配置 `minRefreshIntervalMs`(默认 1000):重解密/重合并的最小间隔;被推迟的刷新不会丢,
28
+ 指纹没更新,下一次轮询会补上。慢盘或大库可以调大。
29
+ - 新增 `test/reader-cache.test.mjs`(8 例):判定矩阵 + 用**包自带 SQLCipher** 造出真格式的
30
+ 加密库与"预分配 4 MiB + 主库 size 不变"的 WAL,端到端重现上面第一个缺陷(旧实现必失败)。
31
+ - 合并 WAL 一律**从第 0 帧重扫**:预分配 WAL 会复用帧槽,"上次扫到第几帧"不可信;
32
+ 合并幂等(按 pgno 覆盖写),salt 不匹配的旧世代帧会被跳过。
33
+
34
+
35
+ ## 0.1.7
36
+
37
+ - **版本对齐,无功能变化**:本版内容与 0.1.6 完全相同(已把 npm 上的 0.1.6 tarball 与仓库
38
+ 逐文件 SHA256 比对,19/19 一致)。发这一版只是让「npm 已发布版本」与「仓库提交」一一对应,
39
+ 便于以后回查某一版对应哪次提交;**已装 0.1.6 的用户不必为此升级**。
40
+
41
+
6
42
  ## 0.1.6
7
43
 
8
44
  - **装完即用:不再需要用户自己装 Python。** 取密钥这一步仍然用上游 `wechatauto-replica`
package/README.md CHANGED
@@ -157,6 +157,7 @@ pnpm add better-sqlite3-multiple-ciphers
157
157
  | `runtimeMirror` | `''` | 自带运行时的下载基地址(留空=内置镜像;可指向自建镜像或离线包目录) |
158
158
  | `dbStorage` | `''` | 微信数据目录(留空=让取密钥脚本自动探测;探测不到时才需要填) |
159
159
  | `pollIntervalSec` | `1` | 轮询间隔(秒) |
160
+ | `minRefreshIntervalMs` | `1000` | 解密缓存的最小刷新间隔;慢盘/大库可调大(被推迟的刷新会在下次轮询补上) |
160
161
  | `previewMaxChars` | `60` | 正文截断长度(隐私 + 上下文成本) |
161
162
  | `relayQuoteMax` | `40` | 转达分档:正文不超过它就照原文转达,超过只转述要点 |
162
163
  | `throttle.*` | 20 / 120 | 全局最短间隔、同会话冷却(秒) |
@@ -236,11 +237,13 @@ pnpm add better-sqlite3-multiple-ciphers
236
237
  ```bash
237
238
  cd cortico-world-wechat
238
239
 
239
- # 纯逻辑(36 例:正文规范化、限流、类型映射、WS 服务、状态端点、事件推送、自带运行时准备)
240
+ # 纯逻辑 + 本地可复现的集成(44 例:正文规范化、限流、类型映射、WS 服务、状态端点、
241
+ # 事件推送、自带运行时准备、解密缓存的失效判定与预分配 WAL 合并)
240
242
  node test/listener.test.mjs
241
243
  node test/format.test.mjs
242
244
  node test/world.test.mjs
243
245
  node test/runtime.test.mjs
246
+ node test/reader-cache.test.mjs
244
247
 
245
248
  # 集成(需要真机微信库 + keys.json):验证解密正确性
246
249
  # 微信升级后先跑这个 —— integrity_check = ok 才说明页布局仍被正确解密
@@ -287,6 +290,9 @@ node test/decrypt-verify.mjs 'contact\contact.db'
287
290
  | **`real_sender_id` 要按所在分片解析** | 用别的表解析会把发送者显示成无关的人(上游 issue #34) | 查该分片自己的 `Name2Id` |
288
291
  | **嵌入式 Python 不加载 site-packages** | 不写 `python313._pth` 里那行,解压出来的 wheel 永远 import 不到 | `_pth` 显式加 `Lib\site-packages` 与 `import site` |
289
292
  | **`from wechatauto import …` 会拉起整套重依赖** | 走包 `__init__` 就需要 pywin32/opencv/Pillow/uiautomation/winsdk(winsdk 在 3.13 无轮子) | `fetch-keys.py` 按路径直接加载 `wechatauto/db.py` |
293
+ | **缓存判据不能只看 size** | 微信就地写页 + 预分配 4 MiB WAL 时**所有 size 都不变** → 缓存永远陈旧、新消息永久漏报、且一行日志都没有(实测) | 指纹带 mtime,并按"变的是哪个文件"分档刷新 |
294
+ | **`writeFileSync(fd, buf, {offset})` 的 offset 不是文件位置** | 每一帧都被写到文件开头、覆盖页 1 → 合并后的缓存打不开(`file is not a database`) | `writeSync(fd, buf, 0, len, (pgno-1)*PAGE_SIZE)` |
295
+ | **解密副本要标成 rollback 模式** | 副本头写着 WAL,SQLite 每次打开都建 `-shm`/`-wal`;缓存被原子替换后边车陈旧 → 下次打开 NOTADB | 页 1 版本字节改 1/1 + 替换后清遗留边车 |
290
296
 
291
297
  ---
292
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-wechat",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "author": "静音胡闹 <2310468741@qq.com>",
5
5
  "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
6
6
  "license": "MIT",
package/src/wechat-db.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * 只读:不写微信库、不动微信进程;解密产物落在自己的缓存目录。
10
10
  */
11
11
  import { createRequire } from 'node:module';
12
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync, copyFileSync, renameSync } from 'node:fs';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, writeSync, closeSync, copyFileSync, renameSync, rmSync } from 'node:fs';
13
13
  import { join, basename } from 'node:path';
14
14
 
15
15
  import { PAGE_SIZE, decryptPage, decryptFile } from './wechat-crypto.js';
@@ -65,6 +65,80 @@ export function listMessageShards(dbStorage) {
65
65
  .sort();
66
66
  }
67
67
 
68
+ /** 一个文件的变更指纹:大小 + 修改时间。 */
69
+ function fileStamp(path) {
70
+ const st = statSync(path);
71
+ return { size: st.size, mtimeMs: Math.round(st.mtimeMs) };
72
+ }
73
+
74
+ /**
75
+ * 把解密副本标记成 **rollback-journal 模式**(页 1 的读写版本字节 = 1/1)。
76
+ *
77
+ * 为什么需要:源库是 WAL 模式,解密出来的副本头里也写着 WAL,于是 SQLite 每次打开它
78
+ * 都会在旁边建 `<缓存>-shm` / `<缓存>-wal`。而缓存是**原子替换**的(WAL 合并、全量重解密),
79
+ * 替换后那些边车文件就对不上新文件了 —— 下一次打开直接报 `file is not a database`
80
+ * (实测踩到,被 test/reader-cache.test.mjs 抓出来)。
81
+ * 副本只读、不写,标成 rollback 模式既不会再产生边车,也不影响读取。
82
+ */
83
+ function forceRollbackMode(dbPath) {
84
+ const fd = openSync(dbPath, 'r+');
85
+ try {
86
+ writeSync(fd, Buffer.from([1, 1]), 0, 2, 18);
87
+ } finally {
88
+ closeSync(fd);
89
+ }
90
+ }
91
+
92
+ /** 删掉上一次打开留下的边车文件(老版本跑过就会留下,光靠 forceRollbackMode 清不掉)。 */
93
+ function dropSidecars(dbPath) {
94
+ for (const suffix of ['-wal', '-shm']) rmSync(`${dbPath}${suffix}`, { force: true });
95
+ }
96
+
97
+ /**
98
+ * 判断手上的解密缓存还能不能用,以及需要做哪一档刷新。
99
+ *
100
+ * ⚠️ **只看文件大小是不够的**(0.1.0–0.1.7 的缺陷,实测踩到):微信写新消息时经常
101
+ * 一个文件大小都不改 —— 它就地覆盖已分配的页,并把新帧写进**预分配好的 WAL**
102
+ * (本机实测 WAL 恒为 4 MiB)。那时 size 全都不动 → 缓存永远陈旧 → 新消息永远看不到,
103
+ * 而且**一行日志都不打**,用户只能看到"没反应"。
104
+ *
105
+ * 所以指纹必须带 mtime;但**不能一看到 mtime 变就全量重解密**(主库可达 20MB,
106
+ * 微信活跃时主库/WAL 的 mtime 都会频繁变)。按"变的是哪个文件"分档:
107
+ *
108
+ * - 只有 **WAL 指纹**变(新消息落在预分配 WAL 里)→ 只重合并 WAL,成本约一个 WAL 大小;
109
+ * - 主库 **size** 变(真的多了页)、或 WAL 被**截断/重置**(checkpoint 把帧写回了主库)、
110
+ * 或**根本没有 WAL** 而主库 mtime 变(数据只能写在主库里)→ 全量重解密;
111
+ * - 主库 mtime 变而 WAL 没变(checkpoint 把已在 WAL 里的帧写回主库)→ 什么都不用做,
112
+ * 那些帧早就合并进缓存了,更新指纹即可。
113
+ *
114
+ * 合并一律**从第 0 帧重新扫**:预分配 WAL 会复用帧槽,"上次扫到第几帧"不可信;
115
+ * 合并本身幂等(按 pgno 覆盖写),salt 不匹配的旧世代帧会被跳过。
116
+ *
117
+ * 抽成纯函数是为了让单测直接钉住这个矩阵(见 test/reader-cache.test.mjs)——这个缺陷
118
+ * 当初就是因为判据只有一个维度而漏掉的。
119
+ *
120
+ * @param {{main: {size:number,mtimeMs:number}, wal: {size:number,mtimeMs:number}|null}|undefined} stamp 上次成功刷新后的指纹
121
+ * @param {{size:number,mtimeMs:number}} src 主库当前指纹
122
+ * @param {{size:number,mtimeMs:number}|null} wal WAL 当前指纹(不存在为 null)
123
+ * @returns {{fast: boolean, redecrypt: boolean, merge: boolean}}
124
+ */
125
+ export function cacheRefreshPlan(stamp, src, wal) {
126
+ const hasWal = wal != null;
127
+ const hadWal = stamp?.wal != null;
128
+ const mainSizeChanged = !stamp || stamp.main.size !== src.size;
129
+ const mainMtimeChanged = !stamp || stamp.main.mtimeMs !== src.mtimeMs;
130
+ const walChanged = hasWal !== hadWal
131
+ || (hasWal && hadWal && (stamp.wal.size !== wal.size || stamp.wal.mtimeMs !== wal.mtimeMs));
132
+ // WAL 变小(或消失)= 被截断/重置:帧可能已经被写回主库,此时只能重解密主库
133
+ const walReset = hadWal && (!hasWal || wal.size < stamp.wal.size);
134
+ const redecrypt = !stamp || mainSizeChanged || walReset || (!hasWal && mainMtimeChanged);
135
+ return {
136
+ fast: !!stamp && !mainSizeChanged && !mainMtimeChanged && !walChanged && !walReset,
137
+ redecrypt,
138
+ merge: hasWal && (walChanged || redecrypt),
139
+ };
140
+ }
141
+
68
142
  /**
69
143
  * 把 -wal 里的加密帧合并进已解密的主库(与上游 _merge_wal 等价)。
70
144
  * 帧:24 字节头 + 4096 页,数值全为**大端**;只合并 salt 与 WAL 头一致的帧
@@ -103,7 +177,7 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
103
177
  } else if (![0, 2, 5, 10, 13].includes(plain[0])) {
104
178
  continue;
105
179
  }
106
- writeFileSync(dstFd, plain, { offset: (pgno - 1) * PAGE_SIZE });
180
+ writeSync(dstFd, plain, 0, plain.length, (pgno - 1) * PAGE_SIZE);
107
181
  if (pgno > maxPgno) maxPgno = pgno;
108
182
  applied += 1;
109
183
  }
@@ -118,7 +192,7 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
118
192
  if (newPages !== headerPages) {
119
193
  const patched = Buffer.from(page1);
120
194
  patched.writeUInt32BE(newPages, 28);
121
- writeFileSync(dstFd, patched, { offset: 0 });
195
+ writeSync(dstFd, patched, 0, patched.length, 0);
122
196
  }
123
197
  }
124
198
  } finally {
@@ -133,7 +207,12 @@ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
133
207
  /** 读取器:管理"密钥 + 解密缓存 + 增量读消息" */
134
208
  export class WeChatReader {
135
209
  /**
136
- * @param {{ dbStorage: string, keys: Map<string, Buffer>, cacheDir: string, logger?: object }} options
210
+ * @param {{
211
+ * dbStorage: string, keys: Map<string, Buffer>, cacheDir: string,
212
+ * minRefreshIntervalMs?: number, logger?: object
213
+ * }} options
214
+ * minRefreshIntervalMs:重解密/重合并的最小间隔(默认 1000ms)。微信活跃时文件
215
+ * 时间戳会频繁变,限频避免每秒做重活;被推迟的刷新不会丢——指纹没有更新,下次轮询会补上。
137
216
  */
138
217
  constructor(options) {
139
218
  this.dbStorage = options.dbStorage;
@@ -142,7 +221,9 @@ export class WeChatReader {
142
221
  // ——一个在 WAL 合并中途写,另一个正好读到半成品,就永久报 "file is not a database"(实测踩过)
143
222
  this.cacheDir = join(options.cacheDir, `p${process.pid}`);
144
223
  this.log = options.logger ?? { info: () => {}, warn: () => {}, debug: () => {} };
145
- this._stamps = new Map(); // rel -> { sourceSize, walSize, walFrames }
224
+ this.minRefreshIntervalMs = options.minRefreshIntervalMs ?? 1000;
225
+ this._stamps = new Map(); // rel -> { main: {size,mtimeMs}, wal: {size,mtimeMs}|null }
226
+ this._lastRefreshAt = 0;
146
227
  mkdirSync(this.cacheDir, { recursive: true });
147
228
  }
148
229
 
@@ -151,7 +232,7 @@ export class WeChatReader {
151
232
  }
152
233
 
153
234
  /**
154
- * 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做。
235
+ * 按需解密(含 WAL 合并);主库或 WAL 变了才重做,且按"变的是哪个"分档(见 cacheRefreshPlan)。
155
236
  *
156
237
  * 自愈设计(实测踩过:一次读取失败后每秒重试每秒失败,永久不自愈):
157
238
  * ① WAL 合并**不直接改缓存**,先合并到临时文件、验证能打开、才原子替换 ——
@@ -166,43 +247,57 @@ export class WeChatReader {
166
247
  const dstPath = join(this.cacheDir, rel.replace(/[\\/]/g, '__'));
167
248
  const key = this.keyFor(rel);
168
249
  if (!key) throw new Error(`没有该库的密钥: ${rel}`);
169
- const srcStat = statSync(srcPath);
170
- const walStat = existsSync(walPath) ? statSync(walPath) : null;
171
- const stamp = this._stamps.get(rel);
172
250
 
173
- const sameMain = stamp && stamp.sourceSize === srcStat.size;
174
- const sameWal = stamp && (stamp.walSize ?? 0) === (walStat?.size ?? 0);
175
- if (sameMain && sameWal && existsSync(dstPath) && this._isUsable(dstPath)) return dstPath;
251
+ const src = fileStamp(srcPath);
252
+ const wal = existsSync(walPath) ? fileStamp(walPath) : null;
253
+ const plan = cacheRefreshPlan(this._stamps.get(rel), src, wal);
254
+ const usable = () => existsSync(dstPath) && this._isUsable(dstPath);
255
+
256
+ if (plan.fast && usable()) return dstPath;
176
257
 
177
- // 缓存不可用、或主库大小变了(= 微信做过 checkpoint):全量重建
178
- //
179
- // ⚠️ 判断依据是 **size 而不是 mtime**:微信活着时 mtime 几乎每秒都在变
180
- // (WAL 写入/checkpoint 都会碰它),用 mtime 会导致每秒全量解密 20MB。
181
- // 内容的新增靠 WAL 增量合并补齐,所以只看 size 既省又能拿到最新消息。
182
- if (!sameMain || !existsSync(dstPath) || !this._isUsable(dstPath)) {
258
+ // 限频只在"手上有一份能用的缓存"时才生效;缓存不可用必须立刻重建,否则这一轮什么都读不到。
259
+ // 被推迟的那次刷新不会丢:指纹没有更新,下一次轮询还会走到这里。
260
+ const now = Date.now();
261
+ if (usable() && now - this._lastRefreshAt < this.minRefreshIntervalMs) return dstPath;
262
+ this._lastRefreshAt = now;
263
+
264
+ const needRebuild = plan.redecrypt || !existsSync(dstPath) || !this._isUsable(dstPath);
265
+ if (needRebuild) {
183
266
  this._stamps.delete(rel);
184
267
  const pages = decryptFile(srcPath, dstPath, key);
185
- this.log.debug?.(`[reader] 解密 ${rel}(${pages} 页)`);
186
- this._stamps.set(rel, { sourceSize: srcStat.size, walSize: 0, walFrames: 0 });
268
+ forceRollbackMode(dstPath);
269
+ dropSidecars(dstPath);
270
+ this.log.info?.(`[reader] 解密 ${rel}(${pages} 页)`);
271
+ this._stamps.set(rel, { main: src, wal: null });
187
272
  if (!this._isUsable(dstPath)) {
188
273
  throw new Error(`解密结果不可用(微信是否正在写入该库?稍后会重试): ${rel}`);
189
274
  }
190
275
  }
191
276
 
192
- if (walStat) {
193
- const prevFrames = this._stamps.get(rel)?.walFrames ?? 0;
277
+ if (wal && (plan.merge || needRebuild)) {
194
278
  try {
195
- const { frames } = mergeWal(dstPath, walPath, key, prevFrames);
279
+ // 从第 0 帧重新合并:预分配 WAL 会复用帧槽,"上次扫到第几帧"不可信;
280
+ // 合并幂等(按 pgno 覆盖写),salt 不匹配的旧世代帧会被跳过。
281
+ const { applied, frames } = mergeWal(dstPath, walPath, key, 0);
282
+ forceRollbackMode(dstPath);
283
+ dropSidecars(dstPath);
196
284
  if (!this._isUsable(dstPath)) {
197
285
  throw new Error('合并 WAL 后缓存不可用,回退到上次可用状态');
198
286
  }
199
- const current = this._stamps.get(rel) ?? {};
200
- this._stamps.set(rel, { ...current, walSize: walStat.size, walFrames: frames });
287
+ this._stamps.set(rel, { main: src, wal });
288
+ if (applied > 0) this.log.info?.(`[reader] ${rel} 合并 WAL ${applied}/${frames} 帧`);
201
289
  } catch (error) {
202
- // 合并失败不致命:丢掉时间戳,下次全量重建(只损失未 checkpoint 的增量)
203
- this.log.warn?.(`[reader] ${rel} WAL 合并失败(下次重建):${error.message}`);
290
+ // 合并失败不致命:丢掉指纹,下次重做(只损失未 checkpoint 的增量)
291
+ this.log.warn?.(`[reader] ${rel} WAL 合并失败(下次重做):${error.message}`);
204
292
  this._stamps.delete(rel);
205
293
  }
294
+ return dstPath;
295
+ }
296
+
297
+ if (!needRebuild) {
298
+ // 主库 mtime 变了但 WAL 没变(checkpoint 把已在 WAL 里的帧写回主库):
299
+ // 那些帧早就合并进缓存了,什么都不用做,只需把指纹更新掉、恢复快路径。
300
+ this._stamps.set(rel, { main: src, wal });
206
301
  }
207
302
  return dstPath;
208
303
  }
@@ -218,7 +218,12 @@ export class WeChatListener {
218
218
  /** 把一行消息变成事件(不含限流;限流在 runOnce 里做) */
219
219
  toEvent(shardRel, row) {
220
220
  const chatUsername = this._chatOfTable(row.table);
221
- if (!chatUsername) return null;
221
+ if (!chatUsername) {
222
+ // 以前这里直接 return null:消息被静默丢掉,用户永远查不到原因(实测排查时踩到)。
223
+ // 现在每个映射不到的表只告警一次,日志里能看出是"表名对不上 session 映射"。
224
+ this._warnUnknownTable(row.table);
225
+ return null;
226
+ }
222
227
  const preview = previewText(row.local_type, row.message_content, this.config.preview?.max_chars ?? 60);
223
228
  return {
224
229
  t: 'notice',
@@ -243,6 +248,14 @@ export class WeChatListener {
243
248
  return this._tableToChat.get(tableName) ?? null;
244
249
  }
245
250
 
251
+ /** 映射不到的会话表只告警一次(每轮轮询都会遇到,不能刷屏)。 */
252
+ _warnUnknownTable(tableName) {
253
+ if (!this._unknownTables) this._unknownTables = new Set();
254
+ if (this._unknownTables.has(tableName)) return;
255
+ this._unknownTables.add(tableName);
256
+ this.log.warn?.(`[listener] 会话表 ${tableName} 不在 session 表的映射里,该会话的消息会被跳过`);
257
+ }
258
+
246
259
  _buildTableMap() {
247
260
  try {
248
261
  const sessions = this.reader.listSessions(2000);
package/src/world.js CHANGED
@@ -63,6 +63,11 @@ export function wechatDefaults() {
63
63
  cacheDir: '',
64
64
  /** 轮询间隔(秒)。 */
65
65
  pollIntervalSec: 1,
66
+ /**
67
+ * 解密缓存的最小刷新间隔(毫秒):微信活跃时文件时间戳频繁变,限频避免每秒重做。
68
+ * 被推迟的刷新不会丢——指纹没更新,下一次轮询会补上。慢盘/大库可以调大。
69
+ */
70
+ minRefreshIntervalMs: 1000,
66
71
  /** 转达口径:正文短于此长度就照原文转达,超过只转述要点(避免她复述长文跑偏)。 */
67
72
  relayQuoteMax: 40,
68
73
  };
@@ -190,6 +195,7 @@ export class WechatWorld {
190
195
  dbStorage: resolvedStorage,
191
196
  keys: this.oracle.keys(),
192
197
  cacheDir,
198
+ minRefreshIntervalMs: this.cfg.minRefreshIntervalMs ?? 1000,
193
199
  logger: { info: (m) => this.log(m), warn: (m) => this.log(m), debug: () => {} },
194
200
  });
195
201
  this.listener = new WeChatListener({
@@ -0,0 +1,162 @@
1
+ /**
2
+ * wechat-db.js 缓存失效判定单测。
3
+ *
4
+ * 钉住的是实测踩到的缺陷:微信写新消息时**一个文件大小都不改**(就地覆盖已分配的页 +
5
+ * 把新帧写进**预分配好的 4MiB WAL**),而旧实现只比 size → 缓存永远陈旧 → 新消息永远
6
+ * 看不到、且一行日志都不打。
7
+ *
8
+ * 第二个用例不是手写加密字节:它用**包自带的 SQLCipher**(`cipher='sqlcipher'` + `legacy=4`
9
+ * + 原始 hex 密钥)造出真格式的加密库与加密 WAL(仓库解密器能解、verifyDbKey 为 true),
10
+ * 再按微信的方式把 WAL 预分配到 4MiB、只在里面追加帧 —— 完整重现上面那个场景。
11
+ *
12
+ * 运行:node test/reader-cache.test.mjs(受限沙箱里不要用 node --test)
13
+ */
14
+ import assert from 'node:assert/strict';
15
+ import { createRequire } from 'node:module';
16
+ import { randomBytes } from 'node:crypto';
17
+ import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
18
+ import { tmpdir } from 'node:os';
19
+ import { join } from 'node:path';
20
+ import test from 'node:test';
21
+
22
+ import { WeChatReader, cacheRefreshPlan } from '../src/wechat-db.js';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const Sqlite = require('better-sqlite3-multiple-ciphers');
26
+
27
+ const WAL_HEADER_SIZE = 32;
28
+ const WAL_PREALLOC = 4 * 1024 * 1024; // 微信的 WAL 恒为 4MiB(预分配),size 因此不会变
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // 纯判定逻辑:这一层就是当初漏掉的那一维
32
+ // ---------------------------------------------------------------------------
33
+
34
+ const stampOf = (size, mtimeMs, wal = null) => ({ main: { size, mtimeMs }, wal });
35
+
36
+ test('什么都没变 → 走快路径,不做任何重活', () => {
37
+ const plan = cacheRefreshPlan(stampOf(100, 5, { size: 200, mtimeMs: 6 }), { size: 100, mtimeMs: 5 }, { size: 200, mtimeMs: 6 });
38
+ assert.deepEqual(plan, { fast: true, redecrypt: false, merge: false });
39
+ });
40
+
41
+ test('只有主库 size 变 → 全量重解密(真的多了页)', () => {
42
+ const plan = cacheRefreshPlan(stampOf(100, 5, null), { size: 108, mtimeMs: 5 }, null);
43
+ assert.equal(plan.fast, false);
44
+ assert.equal(plan.redecrypt, true);
45
+ });
46
+
47
+ test('预分配 WAL:只有 WAL 的 mtime 变(size 不变)→ 只重合并,不重解密', () => {
48
+ const plan = cacheRefreshPlan(stampOf(100, 5, { size: WAL_PREALLOC, mtimeMs: 6 }), { size: 100, mtimeMs: 5 }, { size: WAL_PREALLOC, mtimeMs: 9 });
49
+ assert.equal(plan.fast, false, '不能认为没变化——否则缓存永远陈旧');
50
+ assert.equal(plan.redecrypt, false, '主库没换页,不该重解密(20MB 代价)');
51
+ assert.equal(plan.merge, true, '必须重合并 WAL');
52
+ });
53
+
54
+ test('WAL 被截断/重置 → 必须重解密主库(帧已被写回主库)', () => {
55
+ const plan = cacheRefreshPlan(stampOf(100, 5, { size: WAL_PREALLOC, mtimeMs: 6 }), { size: 100, mtimeMs: 5 }, null);
56
+ assert.equal(plan.redecrypt, true);
57
+ assert.equal(plan.merge, false);
58
+ });
59
+
60
+ test('没有 WAL 而主库 mtime 变 → 数据只能写在主库里,必须重解密', () => {
61
+ const plan = cacheRefreshPlan(stampOf(100, 5, null), { size: 100, mtimeMs: 7 }, null);
62
+ assert.equal(plan.redecrypt, true);
63
+ });
64
+
65
+ test('主库 mtime 变但 WAL 没变(checkpoint 把已有帧写回主库)→ 什么都不用做', () => {
66
+ const plan = cacheRefreshPlan(stampOf(100, 5, { size: WAL_PREALLOC, mtimeMs: 6 }), { size: 100, mtimeMs: 8 }, { size: WAL_PREALLOC, mtimeMs: 6 });
67
+ assert.equal(plan.redecrypt, false);
68
+ assert.equal(plan.merge, false);
69
+ });
70
+
71
+ test('首次(没有指纹)→ 必须重解密', () => {
72
+ const plan = cacheRefreshPlan(undefined, { size: 100, mtimeMs: 5 }, null);
73
+ assert.equal(plan.redecrypt, true);
74
+ });
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // 真格式假分片(SQLCipher 造库 + 预分配 WAL)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ /** 用包自带 SQLCipher 打开/创建"微信格式"的加密库:原始 32 字节密钥。 */
81
+ function openCipherDb(path, keyHex) {
82
+ const db = new Sqlite(path);
83
+ db.pragma("cipher='sqlcipher'");
84
+ db.pragma('legacy=4');
85
+ db.pragma(`key="x'${keyHex}'"`);
86
+ return db;
87
+ }
88
+
89
+ /**
90
+ * 造出"主库只有第一条、第二条只在 WAL 里"这一对字节。
91
+ * 这正是微信的常态:新消息先落 WAL,主库要等 checkpoint 才变。
92
+ */
93
+ function buildMainAndWal(dir, keyHex) {
94
+ const work = join(dir, 'work');
95
+ mkdirSync(work, { recursive: true });
96
+ const path = join(work, 'message_1.db');
97
+ const db = openCipherDb(path, keyHex);
98
+ db.pragma('journal_mode = WAL');
99
+ db.exec('CREATE TABLE Msg_demo (local_id INTEGER PRIMARY KEY, server_id INTEGER, local_type INTEGER, sort_seq INTEGER, real_sender_id INTEGER, create_time INTEGER, message_content BLOB)');
100
+ const insert = db.prepare('INSERT INTO Msg_demo VALUES (?,?,?,?,?,?,?)');
101
+ insert.run(1, 10, 1, 1000, 1, 1000, Buffer.from('第一条', 'utf8'));
102
+ db.pragma('wal_checkpoint(TRUNCATE)'); // 第一条落到主库,WAL 清空
103
+ insert.run(2, 20, 1, 2000, 1, 2000, Buffer.from('第二条', 'utf8'));
104
+ const main = readFileSync(path); // 主库此刻只有第一条
105
+ const wal = readFileSync(`${path}-wal`); // 第二条的帧只在这里
106
+ db.close();
107
+ return { main, wal };
108
+ }
109
+
110
+ /** 把 WAL 补到预分配大小:微信的 WAL 恒为 4MiB,追加帧不会再改 size。 */
111
+ const padToPrealloc = (buf) => Buffer.concat([buf, Buffer.alloc(WAL_PREALLOC - buf.length)]);
112
+ /** 只保留 WAL 头(含 salt)+ 零尾巴:等价于"WAL 里还没有任何有效帧"。 */
113
+ const emptyPreallocatedWal = (wal) => padToPrealloc(Buffer.from(wal.subarray(0, WAL_HEADER_SIZE)));
114
+
115
+ test('预分配 WAL 里追加的新消息必须被看到(旧实现会永久漏掉)', () => {
116
+ const root = mkdtempSync(join(tmpdir(), 'wxreader-'));
117
+ try {
118
+ const key = randomBytes(32);
119
+ const keyHex = key.toString('hex');
120
+ const dbStorage = join(root, 'db_storage');
121
+ const rel = join('message', 'message_1.db');
122
+ const srcPath = join(dbStorage, rel);
123
+ mkdirSync(join(dbStorage, 'message'), { recursive: true });
124
+
125
+ const built = buildMainAndWal(root, keyHex);
126
+ writeFileSync(srcPath, built.main);
127
+ writeFileSync(`${srcPath}-wal`, emptyPreallocatedWal(built.wal)); // 初始:预分配 4MiB、无有效帧
128
+
129
+ const reader = new WeChatReader({
130
+ dbStorage,
131
+ keys: new Map([[rel, key]]),
132
+ cacheDir: join(root, 'cache'),
133
+ minRefreshIntervalMs: 0,
134
+ logger: { info: () => {}, warn: (m) => console.log(` [warn] ${m}`), debug: () => {} },
135
+ });
136
+
137
+ // ① 第一次读:只应看到第一条(对齐真机上"建立水位"的那一刻)
138
+ const first = reader.readNewRows(rel, 0);
139
+ assert.equal(first.length, 1, `首次应只看到第一条,实际 ${first.length} 条`);
140
+ assert.equal(first[0].sort_seq, 1000);
141
+ const cachePath = join(reader.cacheDir, rel.replace(/[\\/]/g, '__'));
142
+
143
+ // ② 主库一字不改,只把第二条的帧写进**已经预分配好**的 WAL:size 不变、只有 mtime 变
144
+ const srcSizeBefore = statSync(srcPath).size;
145
+ writeFileSync(`${srcPath}-wal`, padToPrealloc(built.wal));
146
+ assert.equal(statSync(srcPath).size, srcSizeBefore, '前提:主库 size 不变');
147
+ assert.equal(statSync(`${srcPath}-wal`).size, WAL_PREALLOC, '前提:WAL 仍是预分配的 4MiB');
148
+
149
+ // ③ 再读:旧实现这里会返回陈旧缓存(一条都看不到)
150
+ const rows = reader.readNewRows(rel, 1000);
151
+ assert.equal(rows.length, 1, `应看到新消息,实际 ${rows.length} 条`);
152
+ assert.equal(rows[0].sort_seq, 2000);
153
+ assert.equal(rows[0].message_content.toString('utf8'), '第二条');
154
+
155
+ // ④ 之后没有变化时走快路径:缓存文件不该被重写(防止退化成每秒重做)
156
+ const cacheBefore = statSync(cachePath).mtimeMs;
157
+ reader.readNewRows(rel, 1000);
158
+ assert.equal(statSync(cachePath).mtimeMs, cacheBefore, '没有变化时不该重写缓存');
159
+ } finally {
160
+ rmSync(root, { recursive: true, force: true, maxRetries: 3 });
161
+ }
162
+ });