cortico-world-wechat 0.0.0-stage → 0.1.0

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 ADDED
@@ -0,0 +1,16 @@
1
+ # Changelog
2
+
3
+ 本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
4
+
5
+ ## 0.1.0
6
+
7
+ 首个公开版本。
8
+
9
+ - **World 扩展(主体)**:监听、解密、轮询、限流、推送全部在扩展内,随 Coopanion 启动,
10
+ 没有独立常驻进程、没有 exe、没有开机自启任务。
11
+ - **解密**:纯 `node:crypto` 按页解密微信 4.x 本地库(AES-256-CBC raw CBC),并合并 `-wal` 增量帧。
12
+ - **轮询**:以 `sort_seq` 为水位(落盘),按会话增量读取新消息。
13
+ - **限流**:全局最短间隔、同会话冷却、静默时段(被吞掉即丢弃、不补发)。
14
+ - **取密钥**:微信的库密钥只存在于其进程内存中,由 `tools/fetch-keys.py` 按需取出
15
+ (短命进程,取完即退;失效时自动重取)。这是本项目唯一用到 Python 的地方。
16
+ - 实测:真机库解密 `PRAGMA integrity_check = ok`;测试 26 例(7 监听逻辑 + 6 文本格式化 + 13 World 行为)。
package/describe.json ADDED
@@ -0,0 +1,24 @@
1
+ {
2
+ "tools": [],
3
+ "events": [
4
+ {
5
+ "type": "wechat-notifier.notice",
6
+ "description": {
7
+ "zh": "微信里有人给你发消息了。",
8
+ "en": "Someone sent you a WeChat message."
9
+ },
10
+ "trigger": "flush",
11
+ "tags": [
12
+ "speak"
13
+ ]
14
+ }
15
+ ],
16
+ "prompts": [
17
+ {
18
+ "key": "wechat_notifier_duty",
19
+ "title": "微信消息提示的职责说明",
20
+ "role": "envPrompt",
21
+ "content": "微信消息提示是给你看的:微信里有人给你发消息了。看到要主动用 pet_say 简短汇报给胡闹(一句话说清\"谁在哪个会话说了什么\",别念全文),不要只是记下来就结束这一轮。自己发的消息不用汇报。"
22
+ }
23
+ ]
24
+ }
package/package.json CHANGED
@@ -1,6 +1,51 @@
1
1
  {
2
2
  "name": "cortico-world-wechat",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "describe.json",
16
+ "CHANGELOG.md",
17
+ "README.md"
18
+ ],
19
+ "os": [
20
+ "win32"
21
+ ],
22
+ "engines": {
23
+ "node": "^22.19.0 || >=24.0.0"
24
+ },
25
+ "dependencies": {
26
+ "better-sqlite3-multiple-ciphers": "^13.0.3",
27
+ "ws": "^8.21.3"
28
+ },
29
+ "keywords": [
30
+ "cortico-world",
31
+ "cortico",
32
+ "wechat",
33
+ "weixin",
34
+ "notifier"
35
+ ],
36
+ "cortico": {
37
+ "displayName": "微信消息提示",
38
+ "kind": "world",
39
+ "api": 6,
40
+ "describe": "describe.json",
41
+ "tags": [
42
+ "im",
43
+ "external-setup",
44
+ "experimental",
45
+ "sensitive-data"
46
+ ]
47
+ },
48
+ "scripts": {
49
+ "test": "node --test test/format.test.mjs test/world.test.mjs"
50
+ }
51
+ }
package/src/format.js ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * 微信事件帧 → 给桌宠看的一句人话。
3
+ *
4
+ * 刻意做成纯函数:不碰网络、不碰宿主,方便单测把"她会听到什么"钉死。
5
+ *
6
+ * 措辞原则(沿用 companion-bridge 的实测教训,2026-10-16):
7
+ * 只陈述事实("张三发了条消息")会被她当成**状态信息**——她完整读到、跑一轮推理,
8
+ * 然后 end_turn 一声不吭。所以正文要**带上动作要求**,把"信息"变成"要回应的请求"。
9
+ */
10
+
11
+ /** 单条文字上限,避免一帧把气泡撑爆。 */
12
+ const TEXT_MAX = 160;
13
+
14
+ /** 折叠空白并截断。 */
15
+ function clip(text, max = TEXT_MAX) {
16
+ const s = String(text ?? '').replace(/\s+/g, ' ').trim();
17
+ if (s.length <= max) return s;
18
+ return `${s.slice(0, max)}…`;
19
+ }
20
+
21
+ /**
22
+ * 事件帧 → 事件正文。
23
+ *
24
+ * 帧形状(Python 侧 ws_client.py 定义,字段全部来自微信库实测):
25
+ * { t:'notice', v:1, kind:'message', chat, chat_username, sender, is_group, preview, ts, local_id, shard }
26
+ * { t:'hello', v:1, role:'wechat-listener' }
27
+ *
28
+ * @param {Record<string, unknown>} frame
29
+ * @returns {string | null} null = 这一帧不需要她知道
30
+ */
31
+ export function frameToText(frame) {
32
+ if (!frame || typeof frame !== 'object') return null;
33
+ if (frame.t !== 'notice') return null; // hello 等控制帧不是给她听的内容
34
+ if (frame.kind && frame.kind !== 'message') return null;
35
+
36
+ const chat = clip(frame.chat, 40) || '某个会话';
37
+ const sender = clip(frame.sender, 24) || '某人';
38
+ const preview = clip(frame.preview, 100);
39
+
40
+ // 自己发的消息不催办:她不需要被提醒"你自己说了句话"
41
+ if (sender === '我') {
42
+ return `【微信】你在「${chat}」发了一条消息。`;
43
+ }
44
+
45
+ const where = frame.is_group === true ? `群「${chat}」里的 ${sender}` : sender;
46
+ const content = preview ? `:${preview}` : '(没有正文,可能是图片或文件)';
47
+ return `【微信】${where}发来消息${content}。跟胡闹说一声。`;
48
+ }
49
+
50
+ export { clip };
package/src/index.js ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * cortico-world-wechat —— 包入口:默认导出就是 WorldDefinition。
3
+ *
4
+ * 契约要点(照本机 Coopanion 0.2.0 的 cortico-world-fatfish/types/cortico.d.ts 写,别凭记忆):
5
+ * definition = { id, label, defaults(), preflight?, create(ctx) }
6
+ * create 返回的实例 = { id, envPromptVars(), tools(), console?(), start(host), stop() }
7
+ * 安装:包放进 Coopanion 的 data/extensions/node_modules/,
8
+ * 在 data/home/companion/config.json 的 worlds.wechat-notifier.enabled 打开,重启生效。
9
+ */
10
+ import { WECHAT_ID, WechatWorld, wechatDefaults } from './world.js';
11
+
12
+ /** 延迟取 ws 实现:顶层 import 会在她加载扩展时就解析依赖,失败会连累整个扩展。 */
13
+ const loadWs = () => import('ws');
14
+
15
+ /** World 定义。 */
16
+ export const WECHAT_WORLD = {
17
+ id: WECHAT_ID,
18
+ label: '微信消息提示',
19
+ /** 每次返回新对象,避免框架深处的修改互相串台。 */
20
+ defaults: wechatDefaults,
21
+ preflight(ctx) {
22
+ const url = String(ctx?.cfg?.url ?? '');
23
+ if (!/^ws:\/\/127\.0\.0\.1:\d+\//.test(url)) {
24
+ throw new Error(`微信提示需要 ws://127.0.0.1:<port>/<path> 形式的地址,实际: ${url}`);
25
+ }
26
+ },
27
+ create(ctx) {
28
+ return new WechatWorld(WECHAT_ID, ctx?.cfg ?? {}, loadWs);
29
+ },
30
+ describe: {
31
+ tools: [],
32
+ events: [
33
+ {
34
+ // 必须是 pushEvent 实际用的类型(<worldId>.notice),否则框架会警告
35
+ // "事件 type 不在 describe.events 里"。已用运行时实测锁定,见 test/world.test.mjs。
36
+ type: `${WECHAT_ID}.notice`,
37
+ description: {
38
+ zh: '微信里有人给你发消息了。',
39
+ en: 'Someone sent you a WeChat message.',
40
+ },
41
+ tags: ['speak'],
42
+ },
43
+ ],
44
+ },
45
+ };
46
+
47
+ export default WECHAT_WORLD;
48
+ export { WechatWorld, wechatDefaults, WECHAT_ID } from './world.js';
49
+ export { frameToText } from './format.js';
package/src/keys.js ADDED
@@ -0,0 +1,109 @@
1
+ /**
2
+ * 密钥来源(key oracle):向"短命取密钥工具"要密钥,并缓存到本地 JSON。
3
+ *
4
+ * 职责边界:
5
+ * - 它**不做**内存扫描(那部分由 tools/fetch-keys.py 完成,见该文件头注释解释原因);
6
+ * - 它只负责:按需拉起工具、读回 keys.json、判断缓存是否可用、密钥失效时重新取。
7
+ *
8
+ * 失效判定:解密时若某库的页 HMAC 校验不过,说明微信重新登录/升级导致密钥变了,
9
+ * 此时调用 refresh() 重取一次即可(用户无感)。
10
+ */
11
+ import { execFile } from 'node:child_process';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
+ import { dirname, join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import { promisify } from 'node:util';
16
+
17
+ const execFileAsync = promisify(execFile);
18
+ const here = dirname(fileURLToPath(import.meta.url));
19
+ const packageRoot = join(here, '..');
20
+
21
+ /** 默认输出位置:与安装目录无关,换版本不丢(keys.json 含密钥,切勿入库) */
22
+ export function defaultKeysPath() {
23
+ const base = process.env.LOCALAPPDATA ?? process.env.HOME ?? '.';
24
+ return join(base, 'WeChatNotifier', 'keys.json');
25
+ }
26
+
27
+ /** 找一个能跑取密钥脚本的 Python:优先工作区外的 venv,其次 PATH 里的 python。 */
28
+ function resolvePython(explicit) {
29
+ if (explicit) return explicit;
30
+ const candidates = [];
31
+ if (process.env.WECHAT_NOTIFIER_PYTHON) candidates.push(process.env.WECHAT_NOTIFIER_PYTHON);
32
+ if (process.env.LOCALAPPDATA) {
33
+ candidates.push(join(process.env.LOCALAPPDATA, 'wechat-notifier-venv', 'Scripts', 'python.exe'));
34
+ }
35
+ candidates.push('python');
36
+ return candidates;
37
+ }
38
+
39
+ export class KeyOracle {
40
+ /**
41
+ * @param {{ keysPath?: string, python?: string, timeoutMs?: number, logger?: object }} [options]
42
+ */
43
+ constructor(options = {}) {
44
+ this.keysPath = options.keysPath ?? defaultKeysPath();
45
+ this.pythonCandidates = resolvePython(options.python);
46
+ this.timeoutMs = options.timeoutMs ?? 120000;
47
+ this.log = options.logger ?? { info: () => {}, warn: () => {} };
48
+ this.cache = null;
49
+ }
50
+
51
+ /** 读缓存(不触发取密钥);文件不存在或非法返回 null */
52
+ loadCache() {
53
+ try {
54
+ if (!existsSync(this.keysPath)) return null;
55
+ const payload = JSON.parse(readFileSync(this.keysPath, 'utf8'));
56
+ if (!payload?.keys || typeof payload.keys !== 'object') return null;
57
+ this.cache = payload;
58
+ return payload;
59
+ } catch (error) {
60
+ this.log.warn?.(`[keys] 读缓存失败:${error.message}`);
61
+ return null;
62
+ }
63
+ }
64
+
65
+ /** 密钥对象:{ 'contact\\contact.db': Buffer } */
66
+ keys() {
67
+ const payload = this.cache ?? this.loadCache();
68
+ if (!payload) return new Map();
69
+ const out = new Map();
70
+ for (const [rel, hex] of Object.entries(payload.keys)) {
71
+ out.set(rel, Buffer.from(hex, 'hex'));
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** 强制重新取密钥(拉起短命 Python);成功返回 payload,失败抛错并带上原因 */
77
+ async refresh() {
78
+ const script = join(packageRoot, 'tools', 'fetch-keys.py');
79
+ if (!existsSync(script)) throw new Error(`找不到取密钥脚本: ${script}`);
80
+
81
+ let lastError = null;
82
+ for (const python of this.pythonCandidates) {
83
+ try {
84
+ this.log.info?.(`[keys] 用 ${python} 取密钥…`);
85
+ const { stdout, stderr } = await execFileAsync(
86
+ python,
87
+ [script, this.keysPath],
88
+ { timeout: this.timeoutMs, windowsHide: true, env: { ...process.env, PYTHONUTF8: '1' } },
89
+ );
90
+ if (stdout?.trim()) this.log.info?.(`[keys] ${stdout.trim()}`);
91
+ if (stderr?.trim()) this.log.warn?.(`[keys] ${stderr.trim()}`);
92
+ const payload = this.loadCache();
93
+ if (payload) return payload;
94
+ lastError = new Error('脚本执行成功但没有产出 keys.json');
95
+ } catch (error) {
96
+ lastError = error;
97
+ this.log.warn?.(`[keys] ${python} 失败:${error.message}`);
98
+ }
99
+ }
100
+ throw new Error(`取密钥失败(已试 ${this.pythonCandidates.length} 个解释器):${lastError?.message ?? '未知原因'}`);
101
+ }
102
+
103
+ /** 保证有可用密钥:缓存有就用,没有就取一次。 */
104
+ async ensure() {
105
+ const payload = this.loadCache();
106
+ if (payload) return payload;
107
+ return this.refresh();
108
+ }
109
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * 微信 4.x 本地加密库解密器(Node 实现,已验证)。
3
+ *
4
+ * 依据:wechatauto-replica 的 db.py(_decrypt_page / _verify_enc_key)。
5
+ * 本方已实测验证:contact.db 1867 页、message_3.db 5224 页,解密后
6
+ * `PRAGMA integrity_check` 均为 ok,并能读出真实消息行。
7
+ *
8
+ * ⚠️ 四个踩过的坑(任一错都得乱码或 bad decrypt):
9
+ * ① Node 的 AES 必须 setAutoPadding(false):SQLCipher 页是 raw CBC,无 PKCS#7 填充;
10
+ * ② IV 在页内偏移 [4016:4032](保留区前 16 字节),不是文件头盐、也不是页尾;
11
+ * ③ 第 1 页密文是 [16:4016),且明文头 16 字节是硬编码的 "SQLite format 3\0";
12
+ * ④ 第 2 页起密文从 offset 0 开始(即 [0:4016))。
13
+ */
14
+ import { createDecipheriv, createHmac, pbkdf2Sync } from 'node:crypto';
15
+ import { readFileSync, writeFileSync } from 'node:fs';
16
+ import { dirname } from 'node:path';
17
+ import { mkdirSync } from 'node:fs';
18
+
19
+ export const PAGE_SIZE = 4096;
20
+ export const RESERVE = 80;
21
+ /** 第 1 页明文头:SQLCipher 加密盐不参与明文,明库里是标准 SQLite 魔数 */
22
+ export const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1');
23
+
24
+ /** 解密一页(与上游 _decrypt_page 逐行等价) */
25
+ export function decryptPage(key, page, pageNumber) {
26
+ const reserveStart = PAGE_SIZE - RESERVE;
27
+ const iv = page.subarray(reserveStart, reserveStart + 16);
28
+ const cipherText = pageNumber === 1 ? page.subarray(16, reserveStart) : page.subarray(0, reserveStart);
29
+ const decipher = createDecipheriv('aes-256-cbc', key, iv);
30
+ decipher.setAutoPadding(false);
31
+ const plain = Buffer.concat([decipher.update(cipherText), decipher.final()]);
32
+ const head = pageNumber === 1 ? SQLITE_MAGIC : Buffer.alloc(0);
33
+ return Buffer.concat([head, plain, Buffer.alloc(RESERVE)]).subarray(0, PAGE_SIZE);
34
+ }
35
+
36
+ /** 整库解密到目标文件;返回页数 */
37
+ export function decryptFile(srcPath, dstPath, key) {
38
+ const data = readFileSync(srcPath);
39
+ const pages = Math.ceil(data.length / PAGE_SIZE);
40
+ const out = Buffer.alloc(pages * PAGE_SIZE);
41
+ for (let pageNumber = 1; pageNumber <= pages; pageNumber += 1) {
42
+ const start = (pageNumber - 1) * PAGE_SIZE;
43
+ const page = Buffer.alloc(PAGE_SIZE);
44
+ data.copy(page, 0, start, Math.min(start + PAGE_SIZE, data.length));
45
+ decryptPage(key, page, pageNumber).copy(out, start);
46
+ }
47
+ mkdirSync(dirname(dstPath), { recursive: true });
48
+ writeFileSync(dstPath, out);
49
+ return pages;
50
+ }
51
+
52
+ /** 读第一页(取不到返回 null) */
53
+ export function readPage1(filePath) {
54
+ try {
55
+ const data = readFileSync(filePath);
56
+ return data.length < PAGE_SIZE ? null : data.subarray(0, PAGE_SIZE);
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ /**
63
+ * SQLCipher4 页 1 的 HMAC 校验 —— 用来判断"这把密钥能不能开这个库"。
64
+ * 细节:mac_salt = salt ^ 0x3a,KDF 迭代 2,HMAC 输入末尾追加小端 4 字节页号。
65
+ */
66
+ export function verifyDbKey(dbKey, page1, explicitSalt = null) {
67
+ let key = dbKey;
68
+ let salt = explicitSalt ?? page1.subarray(0, 16);
69
+ if (dbKey.length === 48 && !explicitSalt) {
70
+ key = dbKey.subarray(0, 32);
71
+ salt = dbKey.subarray(32, 48);
72
+ } else if (dbKey.length === 48) {
73
+ key = dbKey.subarray(0, 32);
74
+ }
75
+ const macSalt = Buffer.from(salt);
76
+ for (let i = 0; i < macSalt.length; i += 1) macSalt[i] ^= 0x3a;
77
+ const macKey = pbkdf2Sync(key, macSalt, 2, 32, 'sha512');
78
+ const hmacData = page1.subarray(16, PAGE_SIZE - RESERVE + 16);
79
+ const stored = page1.subarray(PAGE_SIZE - 64, PAGE_SIZE);
80
+ const pageNo = Buffer.alloc(4);
81
+ pageNo.writeUInt32LE(1, 0);
82
+ const actual = createHmac('sha512', macKey).update(hmacData).update(pageNo).digest();
83
+ return actual.equals(stored);
84
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * 微信本地库读取器(Node):解密 + WAL 增量合并 + 按会话增量读新消息。
3
+ *
4
+ * 与 Python 侧(wechatauto-replica)的关系:本文件是它的**读库子集**在 Node 的等价实现。
5
+ * 算法依据全部来自 db.py 的对应函数,并已在本机实测:
6
+ * - contact.db / message_3.db 解密后 PRAGMA integrity_check = ok
7
+ * - 能读出真实消息行(local_id / create_time / local_type / message_content)
8
+ *
9
+ * 只读:不写微信库、不动微信进程;解密产物落在自己的缓存目录。
10
+ */
11
+ import { createRequire } from 'node:module';
12
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, openSync, readSync, closeSync } from 'node:fs';
13
+ import { join, basename } from 'node:path';
14
+
15
+ import { PAGE_SIZE, decryptPage, decryptFile, readPage1, verifyDbKey } from './wechat-crypto.js';
16
+
17
+ const require = createRequire(import.meta.url);
18
+
19
+ /**
20
+ * 惰性加载 sqlite 绑定。
21
+ *
22
+ * 为什么不在顶层 require:该模块是原生扩展(.node),顶层加载会让
23
+ * **纯逻辑使用方**(例如只想测 previewText/限流的单测)也被迫依赖它,
24
+ * 一旦运行环境没装原生模块就整体 ImportError。改成用到时才加载。
25
+ */
26
+ let Database = null;
27
+ function getDatabase() {
28
+ if (!Database) Database = require('better-sqlite3-multiple-ciphers');
29
+ return Database;
30
+ }
31
+
32
+ const WAL_HEADER_SIZE = 32;
33
+ const WAL_FRAME_SIZE = 24 + PAGE_SIZE;
34
+
35
+ /** 递归列出 db_storage 下所有 .db(跳过 -wal/-shm) */
36
+ export function listDbFiles(dbStorage) {
37
+ const out = [];
38
+ const walk = (dir, depth) => {
39
+ if (depth > 4) return;
40
+ let entries;
41
+ try {
42
+ entries = readdirSync(dir, { withFileTypes: true });
43
+ } catch {
44
+ return;
45
+ }
46
+ for (const entry of entries) {
47
+ const full = join(dir, entry.name);
48
+ if (entry.isDirectory()) walk(full, depth + 1);
49
+ else if (entry.name.endsWith('.db') && !/-wal$|-shm$/.test(entry.name)) out.push(full);
50
+ }
51
+ };
52
+ walk(dbStorage, 0);
53
+ return out;
54
+ }
55
+
56
+ /** 相对 db_storage 的键名(与上游 keys.json 的键一致,统一用反斜杠) */
57
+ export function relKey(dbStorage, filePath) {
58
+ return filePath.slice(dbStorage.length).replace(/^[\\/]/, '').replace(/\//g, '\\');
59
+ }
60
+
61
+ /** 消息分片:message\message_N.db */
62
+ export function listMessageShards(dbStorage) {
63
+ return listDbFiles(join(dbStorage, 'message'))
64
+ .filter((p) => /^message_\d+\.db$/.test(basename(p)))
65
+ .sort();
66
+ }
67
+
68
+ /**
69
+ * 把 -wal 里的加密帧合并进已解密的主库(与上游 _merge_wal 等价)。
70
+ * 帧:24 字节头 + 4096 页,数值全为**大端**;只合并 salt 与 WAL 头一致的帧
71
+ * (checkpoint 会重置 WAL,旧世代帧合并会用过期页覆盖新数据 → 库损坏)。
72
+ */
73
+ export function mergeWal(decryptedPath, walPath, key, fromFrame = 0) {
74
+ if (!existsSync(decryptedPath) || !existsSync(walPath)) return { applied: 0, frames: 0 };
75
+ const walFd = openSync(walPath, 'r');
76
+ const dstFd = openSync(decryptedPath, 'r+');
77
+ let applied = 0;
78
+ let last = fromFrame;
79
+ try {
80
+ const walSize = statSync(walPath).size;
81
+ const header = Buffer.alloc(WAL_HEADER_SIZE);
82
+ readSync(walFd, header, 0, WAL_HEADER_SIZE, 0);
83
+ const walSalt = header.subarray(16, 24);
84
+ const totalFrames = Math.floor((walSize - WAL_HEADER_SIZE) / WAL_FRAME_SIZE);
85
+ let maxPgno = 0;
86
+ for (let i = fromFrame; i < totalFrames; i += 1) {
87
+ const frame = Buffer.alloc(WAL_FRAME_SIZE);
88
+ const read = readSync(walFd, frame, 0, WAL_FRAME_SIZE, WAL_HEADER_SIZE + i * WAL_FRAME_SIZE);
89
+ if (read < WAL_FRAME_SIZE) break;
90
+ last = i + 1;
91
+ if (!frame.subarray(8, 16).equals(walSalt)) continue; // 旧世代帧:跳过
92
+ const pgno = frame.readUInt32BE(0);
93
+ const page = frame.subarray(24);
94
+ const plain = decryptPage(key, page, pgno);
95
+ if (pgno === 1) {
96
+ if (!plain.subarray(0, 16).equals(Buffer.from('SQLite format 3\0', 'latin1'))) continue;
97
+ } else if (![0, 2, 5, 10, 13].includes(plain[0])) {
98
+ continue;
99
+ }
100
+ writeFileSync(dstFd, plain, { offset: (pgno - 1) * PAGE_SIZE });
101
+ if (pgno > maxPgno) maxPgno = pgno;
102
+ applied += 1;
103
+ }
104
+ // 把实际的页数写回 header(+28,大端);不写会让 sqlite 认为库比实际短
105
+ if (maxPgno > 0) {
106
+ const page1 = Buffer.alloc(PAGE_SIZE);
107
+ readSync(dstFd, page1, 0, PAGE_SIZE, 0);
108
+ const fileSize = statSync(decryptedPath).size;
109
+ const dbPages = Math.ceil(fileSize / PAGE_SIZE);
110
+ const headerPages = page1.readUInt32BE(28);
111
+ const newPages = Math.max(headerPages, maxPgno, dbPages);
112
+ if (newPages !== headerPages) {
113
+ const patched = Buffer.from(page1);
114
+ patched.writeUInt32BE(newPages, 28);
115
+ writeFileSync(dstFd, patched, { offset: 0 });
116
+ }
117
+ }
118
+ return { applied, frames: last };
119
+ } finally {
120
+ closeSync(walFd);
121
+ closeSync(dstFd);
122
+ }
123
+ }
124
+
125
+ /** 读取器:管理"密钥 + 解密缓存 + 增量读消息" */
126
+ export class WeChatReader {
127
+ /**
128
+ * @param {{ dbStorage: string, keys: Map<string, Buffer>, cacheDir: string, logger?: object }} options
129
+ */
130
+ constructor(options) {
131
+ this.dbStorage = options.dbStorage;
132
+ this.keys = options.keys; // Map<'contact\\contact.db', Buffer>
133
+ this.cacheDir = options.cacheDir;
134
+ this.log = options.logger ?? { info: () => {}, warn: () => {}, debug: () => {} };
135
+ this._stamps = new Map(); // rel -> { mtimeMs, size, walFrames }
136
+ mkdirSync(this.cacheDir, { recursive: true });
137
+ }
138
+
139
+ keyFor(rel) {
140
+ return this.keys.get(rel) ?? null;
141
+ }
142
+
143
+ /** 按需解密(含 WAL 合并);主库或 WAL 变化时增量重做 */
144
+ ensureDecrypted(rel) {
145
+ const srcPath = join(this.dbStorage, rel);
146
+ const walPath = `${srcPath}-wal`;
147
+ const dstPath = join(this.cacheDir, rel.replace(/[\\/]/g, '__'));
148
+ const key = this.keyFor(rel);
149
+ if (!key) throw new Error(`没有该库的密钥: ${rel}`);
150
+ const srcStat = statSync(srcPath);
151
+ const walStat = existsSync(walPath) ? statSync(walPath) : null;
152
+ const stamp = this._stamps.get(rel);
153
+
154
+ const sameMain = stamp && stamp.mtimeMs === srcStat.mtimeMs && stamp.size === srcStat.size;
155
+ const sameWal = stamp && (stamp.walSize ?? 0) === (walStat?.size ?? 0);
156
+ if (sameMain && sameWal && existsSync(dstPath)) return dstPath;
157
+
158
+ if (!sameMain || !existsSync(dstPath)) {
159
+ const pages = decryptFile(srcPath, dstPath, key);
160
+ 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);
163
+ }
164
+ if (walStat) {
165
+ 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 });
169
+ }
170
+ return dstPath;
171
+ }
172
+
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}`);
184
+ }
185
+ }
186
+
187
+ open(rel) {
188
+ const path = this.ensureDecrypted(rel);
189
+ const Sqlite = getDatabase();
190
+ return new Sqlite(path, { readonly: true, fileMustExist: true });
191
+ }
192
+
193
+ /** 会话列表:username + 显示名 + 未读(用于"会话名 → wxid"解析) */
194
+ listSessions(limit = 200) {
195
+ const db = this.open('session\\session.db');
196
+ try {
197
+ const rows = db.prepare('SELECT * FROM SessionTable ORDER BY last_timestamp DESC LIMIT ?').all(limit);
198
+ return rows.map((row) => ({
199
+ username: row.username,
200
+ unread: row.unread_count ?? 0,
201
+ summary: row.summary ?? '',
202
+ lastTime: row.last_timestamp ?? 0,
203
+ }));
204
+ } finally {
205
+ db.close();
206
+ }
207
+ }
208
+
209
+ /** 昵称/备注表:wxid → 显示名 */
210
+ listContacts() {
211
+ const db = this.open('contact\\contact.db');
212
+ try {
213
+ const rows = db.prepare('SELECT username, local_type, remark, nick_name FROM contact').all();
214
+ const map = new Map();
215
+ for (const row of rows) {
216
+ const name = row.remark || row.nick_name || row.username;
217
+ if (row.username) map.set(row.username, name);
218
+ }
219
+ return map;
220
+ } finally {
221
+ db.close();
222
+ }
223
+ }
224
+
225
+ /**
226
+ * 分片内所有 Msg_* 表的 sort_seq 最大值(毫秒)——水位基线用。
227
+ *
228
+ * ⚠️ 为什么不用 local_id:**local_id 在每个会话表内部各自计数**,不同 Msg_* 表之间不单调。
229
+ * 实测踩过:message_3.db 里一条 4 分钟前的新消息 local_id=2392 < 该分片"最大 local_id" 2564,
230
+ * 用 local_id 当水位会把它永久跳过。sort_seq(毫秒时间戳)才是可比的增量键。
231
+ */
232
+ maxSortSeq(shardRel) {
233
+ const db = this.open(shardRel);
234
+ try {
235
+ const tables = db
236
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
237
+ .all()
238
+ .map((t) => t.name);
239
+ let max = 0;
240
+ for (const table of tables) {
241
+ const row = db.prepare(`SELECT MAX(sort_seq) AS m FROM ${table}`).get();
242
+ if (row?.m && row.m > max) max = row.m;
243
+ }
244
+ return { max, tables };
245
+ } finally {
246
+ db.close();
247
+ }
248
+ }
249
+
250
+ /** 读某个分片里 sort_seq > since 的新消息(按会话分组前的原始行) */
251
+ readNewRows(shardRel, since) {
252
+ const db = this.open(shardRel);
253
+ try {
254
+ const tables = db
255
+ .prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Msg_%'")
256
+ .all()
257
+ .map((t) => t.name);
258
+ const rows = [];
259
+ for (const table of tables) {
260
+ const list = db
261
+ .prepare(
262
+ `SELECT local_id, server_id, local_type, sort_seq, real_sender_id, create_time, message_content
263
+ FROM ${table} WHERE sort_seq > ? ORDER BY sort_seq ASC LIMIT 200`,
264
+ )
265
+ .all(since);
266
+ for (const row of list) rows.push({ ...row, table, shard: shardRel });
267
+ }
268
+ rows.sort((a, b) => (a.sort_seq ?? 0) - (b.sort_seq ?? 0));
269
+ return rows;
270
+ } finally {
271
+ db.close();
272
+ }
273
+ }
274
+ }