cortico-world-wechat 0.1.0 → 0.1.1

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,13 @@
2
2
 
3
3
  本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
4
4
 
5
+ ## 0.1.1
6
+
7
+ - **打包内容调整**(无功能变化):
8
+ - 把打包脚本移出 npm 包(仓库里保留在 `scripts/`)——它是构建用的,最终用户用不到;
9
+ - npm 包内补上 `test/`,方便使用者自行验证(尤其是微信升级后跑 `test/decrypt-verify.mjs`,
10
+ 它用 `PRAGMA integrity_check` 判断页布局是否仍被正确解密)。
11
+
5
12
  ## 0.1.0
6
13
 
7
14
  首个公开版本。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-wechat",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "把 Windows 微信新消息播报给桌宠:扩展自己解密并轮询微信本地库,把新消息推进桌宠的事件流,由桌宠决定怎么汇报。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "src",
14
14
  "tools",
15
+ "test",
15
16
  "describe.json",
16
17
  "CHANGELOG.md",
17
18
  "README.md"
@@ -0,0 +1,56 @@
1
+ /**
2
+ * 解密验证(集成测试,需要真机微信库与密钥)。
3
+ *
4
+ * 为什么保留:它验证的是"Node 能否读懂微信加密库"这条**地基**,
5
+ * 一旦微信升级导致页布局变化,这个测试会最先报红。
6
+ *
7
+ * 前置:先用 tools/fetch-keys.py 生成 keys.json(含各库密钥与 db_storage 路径)。
8
+ * 用法:node test/decrypt-verify.mjs [库相对名,默认 contact\\contact.db]
9
+ */
10
+ import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ import { KeyOracle } from '../src/keys.js';
16
+ import { WeChatReader } from '../src/wechat-db.js';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const root = join(here, '..');
21
+
22
+ const rel = process.argv[2] ?? 'contact\\contact.db';
23
+ const keysPath = process.argv[3] ?? join(process.env.LOCALAPPDATA ?? '.', 'WeChatNotifier', 'keys.json');
24
+
25
+ const oracle = new KeyOracle({ keysPath, logger: { info: () => {}, warn: (m) => console.error(m) } });
26
+ const payload = oracle.loadCache();
27
+ if (!payload) {
28
+ console.error(`没有 ${keysPath},先运行: python tools/fetch-keys.py`);
29
+ process.exit(2);
30
+ }
31
+
32
+ const reader = new WeChatReader({
33
+ dbStorage: payload.db_storage,
34
+ keys: oracle.keys(),
35
+ cacheDir: join(dirname(keysPath), 'cache'),
36
+ logger: { info: () => {}, warn: (m) => console.error(m), debug: () => {} },
37
+ });
38
+
39
+ console.log(`库: ${rel}`);
40
+ const started = Date.now();
41
+ const path = reader.ensureDecrypted(rel);
42
+ console.log(` 解密到 ${path}(用时 ${((Date.now() - started) / 1000).toFixed(2)}s)`);
43
+
44
+ const Sqlite = require('better-sqlite3-multiple-ciphers');
45
+ const db = new Sqlite(path, { readonly: true });
46
+ const integrity = db.pragma('integrity_check', { simple: true });
47
+ const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().length;
48
+ db.close();
49
+
50
+ console.log(` integrity_check = ${integrity}`);
51
+ console.log(` 表数量 = ${tables}`);
52
+ if (integrity !== 'ok') {
53
+ console.error('❌ 解密结果不完整:页布局可能已随微信版本变化');
54
+ process.exit(1);
55
+ }
56
+ console.log('✅ 解密正确');
@@ -0,0 +1,55 @@
1
+ /**
2
+ * format.js 单测:把"她会听到什么"钉死。
3
+ * 运行:node test/format.test.mjs(DSH 沙箱内不要用 node --test,见 README)
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import test from 'node:test';
7
+
8
+ import { frameToText } from '../src/format.js';
9
+
10
+ test('单聊消息:说清谁说了什么,并带动作要求', () => {
11
+ const text = frameToText({
12
+ t: 'notice', kind: 'message', chat: '张三', sender: '张三',
13
+ is_group: false, preview: '下午三点开会',
14
+ });
15
+ assert.equal(text, '【微信】张三发来消息:下午三点开会。跟胡闹说一声。');
16
+ });
17
+
18
+ test('群聊消息:带群名与发言人', () => {
19
+ const text = frameToText({
20
+ t: 'notice', kind: 'message', chat: '云南项目部', sender: '李四',
21
+ is_group: true, preview: '明天上线',
22
+ });
23
+ assert.equal(text, '【微信】群「云南项目部」里的 李四发来消息:明天上线。跟胡闹说一声。');
24
+ });
25
+
26
+ test('自己发的消息不催办', () => {
27
+ const text = frameToText({
28
+ t: 'notice', kind: 'message', chat: '文件传输助手', sender: '我',
29
+ is_group: false, preview: '测试',
30
+ });
31
+ assert.equal(text, '【微信】你在「文件传输助手」发了一条消息。');
32
+ assert.ok(!text.includes('胡闹说一声'), '自己发的消息不该催她汇报');
33
+ });
34
+
35
+ test('空正文给占位,不出现空串', () => {
36
+ const text = frameToText({
37
+ t: 'notice', kind: 'message', chat: '张三', sender: '张三', is_group: false, preview: '',
38
+ });
39
+ assert.ok(text.includes('没有正文'), `空正文应有占位说明,实际: ${text}`);
40
+ });
41
+
42
+ test('控制帧不产生正文', () => {
43
+ assert.equal(frameToText({ t: 'hello', role: 'wechat-listener' }), null);
44
+ assert.equal(frameToText(null), null);
45
+ assert.equal(frameToText({ t: 'notice', kind: 'other' }), null);
46
+ });
47
+
48
+ test('超长正文被截断', () => {
49
+ const text = frameToText({
50
+ t: 'notice', kind: 'message', chat: '张三', sender: '张三', is_group: false,
51
+ preview: '长'.repeat(500),
52
+ });
53
+ assert.ok(text.length < 220, `应被截断,实际长度 ${text.length}`);
54
+ assert.ok(text.includes('…'));
55
+ });
@@ -0,0 +1,60 @@
1
+ /**
2
+ * 纯逻辑单测:正文规范化、限流、类型映射。
3
+ * 运行:node test/listener.test.mjs(DSH 沙箱内不要用 node --test)
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import test from 'node:test';
7
+
8
+ import { Throttle, previewText, typeName } from '../src/wechat-listener.js';
9
+
10
+ test('类型映射:码 → 展示名', () => {
11
+ assert.equal(typeName(1), '文本');
12
+ assert.equal(typeName(3), '[图片]');
13
+ assert.equal(typeName(34), '[语音]');
14
+ assert.equal(typeName(49), '[文件/链接/卡片]');
15
+ assert.equal(typeName(8594229559345), '[文件/链接/卡片]'); // 复合类型的低 32 位分解
16
+ });
17
+
18
+ test('正文:XML 类只给占位符,绝不把 XML 塞进上下文', () => {
19
+ const xml = '<msg><img aeskey="deadbeef" md5="abc" cdnbigimgurl="305f0201"/></msg>';
20
+ const result = previewText(3, xml, 60);
21
+ assert.equal(result, '[图片]');
22
+ assert.ok(!result.includes('aeskey'), '不能泄露 cdn 地址与密钥');
23
+ });
24
+
25
+ test('正文:普通文本单行化并截断', () => {
26
+ assert.equal(previewText(1, '第一行\n第二行', 60), '第一行 第二行');
27
+ assert.equal(previewText(1, '长'.repeat(100), 10), `${'长'.repeat(10)}…`);
28
+ });
29
+
30
+ test('正文:空内容给类型占位,不出现空串', () => {
31
+ assert.equal(previewText(1, '', 60), '文本');
32
+ assert.equal(previewText(3, '', 60), '[图片]');
33
+ });
34
+
35
+ test('正文:未识别的 XML 容器不原样透出', () => {
36
+ const result = previewText(99, '<sysmsg type="revokemsg"><revoke/></sysmsg>', 60);
37
+ assert.ok(!result.includes('<'), `不应透出 XML:${result}`);
38
+ });
39
+
40
+ test('限流:正常放行 / 全局间隔 / 同会话冷却', () => {
41
+ const throttle = new Throttle({ throttle: { global_min_gap_sec: 20, per_chat_cooldown_sec: 120 } });
42
+ assert.equal(throttle.allow('chat_a', 1_000_000).allowed, true);
43
+ assert.equal(throttle.allow('chat_b', 1_000_000 + 5_000).reason, 'global_gap');
44
+ const t2 = new Throttle({ throttle: { global_min_gap_sec: 0, per_chat_cooldown_sec: 120 } });
45
+ assert.equal(t2.allow('chat_a', 1_000_000).allowed, true);
46
+ assert.equal(t2.allow('chat_a', 1_000_000 + 60_000).reason, 'per_chat_cooldown');
47
+ assert.equal(t2.allow('chat_a', 1_000_000 + 121_000).allowed, true);
48
+ });
49
+
50
+ test('限流:静音时段(含跨零点)一条不放行', () => {
51
+ const throttle = new Throttle({ quiet_hours: { enabled: true, start: '23:30', end: '08:00' } });
52
+ const at = (h, m) => new Date(2026, 0, 1, h, m);
53
+ assert.equal(throttle.inQuietHours(at(23, 45)), true);
54
+ assert.equal(throttle.inQuietHours(at(7, 59)), true);
55
+ assert.equal(throttle.inQuietHours(at(12, 0)), false);
56
+ // 把"当前时间"注入进去,避免测试结果依赖跑测试的真实时刻
57
+ assert.equal(throttle.allow('chat_a', 1_000_000, at(23, 45)).reason, 'quiet_hours');
58
+ assert.equal(throttle.allow('chat_a', 1_000_000, at(7, 59)).reason, 'quiet_hours');
59
+ assert.equal(throttle.allow('chat_a', 1_000_000, at(12, 0)).allowed, true);
60
+ });
@@ -0,0 +1,194 @@
1
+ /**
2
+ * world.js 单测:真起 WS 服务 + 真 ws 客户端,验证契约行为。
3
+ * 运行:node test/world.test.mjs(DSH 沙箱内不要用 node --test)
4
+ */
5
+ import assert from 'node:assert/strict';
6
+ import test from 'node:test';
7
+
8
+ import { WECHAT_ID, WECHAT_WORLD, WechatWorld, wechatDefaults } from '../src/index.js';
9
+ import { WebSocket } from 'ws';
10
+
11
+ const loadWs = () => import('ws');
12
+
13
+ /** 起一个 World 实例并等它开始监听。
14
+ *
15
+ * 显式指定 mode='bridge':本文件测的是"外部进程推事件"那条腿(WS 服务 + 状态端点)。
16
+ * in-process 读库模式需要真实微信库与密钥,属于集成测试,不放在这里(见 test-inprocess 系列)。
17
+ */
18
+ async function startWorld(config = {}) {
19
+ const port = 19400 + Math.floor(Math.random() * 200);
20
+ const world = new WechatWorld(
21
+ WECHAT_ID,
22
+ { mode: 'bridge', url: `ws://127.0.0.1:${port}/wechat/pet`, ...config },
23
+ loadWs,
24
+ );
25
+ const pushes = [];
26
+ const logs = [];
27
+ const host = {
28
+ pushEvent: async (event, opts) => {
29
+ pushes.push({ event, opts });
30
+ return event;
31
+ },
32
+ log: { info: (text) => logs.push(text), warn: () => {}, error: () => {}, debug: () => {} },
33
+ };
34
+ await world.start(host);
35
+ await new Promise((resolve) => setTimeout(resolve, 120));
36
+ return { world, pushes, logs, port };
37
+ }
38
+
39
+ /** 连上去并等 hello 帧。 */
40
+ function connect(port, onFrame) {
41
+ return new Promise((resolve, reject) => {
42
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/wechat/pet`);
43
+ socket.on('message', (raw) => onFrame?.(JSON.parse(raw.toString())));
44
+ socket.on('open', () => resolve(socket));
45
+ socket.on('error', reject);
46
+ });
47
+ }
48
+
49
+ test('声明的事件类型必须与运行时推送的类型一致(否则框架会警告)', () => {
50
+ // 站过一次坑:describe.events 写了 'wechat-notice',而 pushEvent 用的是
51
+ // 'wechat-notifier.notice' → Core 日志出现 "事件 type 不在 describe.events 里"。
52
+ assert.equal(WECHAT_WORLD.describe.events[0].type, `${WECHAT_ID}.notice`);
53
+ });
54
+
55
+ test('状态端点:GET /wechat/status 返回 JSON,未知路径 404', async () => {
56
+ const { world, port } = await startWorld();
57
+ const status = await fetch(`http://127.0.0.1:${port}/wechat/status`).then((r) => r.json());
58
+ assert.equal(status.ok, true);
59
+ assert.equal(status.world, WECHAT_ID);
60
+ assert.equal(status.listening, true);
61
+ assert.equal(status.clients, 0);
62
+ assert.match(status.statusUrl, /\/wechat\/status$/);
63
+
64
+ const missing = await fetch(`http://127.0.0.1:${port}/nope`);
65
+ assert.equal(missing.status, 404);
66
+ await world.stop();
67
+ });
68
+
69
+ test('状态端点可关闭,且关闭后 WS 仍可用', async () => {
70
+ const { world, pushes, port } = await startWorld({ statusEnabled: false });
71
+ const status = await fetch(`http://127.0.0.1:${port}/wechat/status`);
72
+ assert.equal(status.status, 404, '关闭后状态端点应 404');
73
+ const socket = await connect(port);
74
+ socket.send(JSON.stringify({ t: 'notice', kind: 'message', chat: '张三', sender: '张三', is_group: false, preview: '仍然可用' }));
75
+ await new Promise((resolve) => setTimeout(resolve, 200));
76
+ assert.equal(pushes.length, 1, '关闭状态端点不应影响事件推送');
77
+ socket.close();
78
+ await world.stop();
79
+ });
80
+
81
+ test('升级请求路径不匹配时被拒绝(不静默吞掉)', async () => {
82
+ const { world, port } = await startWorld();
83
+ const socket = new WebSocket(`ws://127.0.0.1:${port}/wrong/path`);
84
+ const rejected = await new Promise((resolve) => {
85
+ socket.on('open', () => resolve(false));
86
+ socket.on('error', () => resolve(true));
87
+ });
88
+ assert.equal(rejected, true, '错误路径的升级请求应被拒绝');
89
+ await world.stop();
90
+ });
91
+
92
+ test('默认配置符合预期(仅回环、默认关闭)', () => {
93
+ const defaults = wechatDefaults();
94
+ assert.equal(defaults.enabled, false);
95
+ assert.match(defaults.url, /^ws:\/\/127\.0\.0\.1:\d+\/wechat\/pet$/);
96
+ assert.equal(defaults.notifySelf, false);
97
+ assert.equal(defaults.notifyGroup, true);
98
+ });
99
+
100
+ test('envPromptVars 必须含"要主动汇报"的职责说明', () => {
101
+ const world = new WechatWorld(WECHAT_ID, {}, loadWs);
102
+ const vars = world.envPromptVars();
103
+ assert.ok(vars.wechat_notifier_url, '应注入地址变量');
104
+ assert.ok(vars.wechat_notifier_duty.includes('pet_say'), '缺了这句她会读完不吭声');
105
+ assert.ok(vars.wechat_notifier_duty.includes('不要只是记下来'));
106
+ });
107
+
108
+ test('客户端连上后收到 hello', async () => {
109
+ const { world, port } = await startWorld();
110
+ const frames = [];
111
+ const socket = await connect(port, (frame) => frames.push(frame));
112
+ await new Promise((resolve) => setTimeout(resolve, 100));
113
+ assert.equal(frames[0].t, 'hello');
114
+ assert.equal(frames[0].world, WECHAT_ID);
115
+ socket.close();
116
+ await world.stop();
117
+ });
118
+
119
+ test('收到微信消息 -> 翻译并 pushEvent(带 trigger:flush 与 worldId.notice 类型)', async () => {
120
+ const { world, pushes, port } = await startWorld();
121
+ const socket = await connect(port);
122
+ socket.send(JSON.stringify({
123
+ t: 'notice', kind: 'message', chat: '张三', sender: '张三',
124
+ is_group: false, preview: '下午三点开会', ts: 1792137092000,
125
+ }));
126
+ await new Promise((resolve) => setTimeout(resolve, 200));
127
+ assert.equal(pushes.length, 1);
128
+ assert.equal(pushes[0].event.type, `${WECHAT_ID}.notice`);
129
+ assert.equal(pushes[0].event.source, WECHAT_ID);
130
+ assert.match(pushes[0].event.text, /张三发来消息:下午三点开会/);
131
+ assert.equal(pushes[0].opts.trigger, 'flush');
132
+ socket.close();
133
+ await world.stop();
134
+ });
135
+
136
+ test('自己发的消息按配置不推送', async () => {
137
+ const { world, pushes, port } = await startWorld({ notifySelf: false });
138
+ const socket = await connect(port);
139
+ socket.send(JSON.stringify({
140
+ t: 'notice', kind: 'message', chat: '文件传输助手', sender: '我', is_group: false, preview: '测试',
141
+ }));
142
+ await new Promise((resolve) => setTimeout(resolve, 200));
143
+ assert.equal(pushes.length, 0, '自己发的消息不该推给她');
144
+ socket.close();
145
+ await world.stop();
146
+ });
147
+
148
+ test('关闭群消息后群聊不推送、单聊仍推送', async () => {
149
+ const { world, pushes, port } = await startWorld({ notifyGroup: false });
150
+ const socket = await connect(port);
151
+ socket.send(JSON.stringify({ t: 'notice', kind: 'message', chat: '某群', sender: '李四', is_group: true, preview: '群里的话' }));
152
+ socket.send(JSON.stringify({ t: 'notice', kind: 'message', chat: '张三', sender: '张三', is_group: false, preview: '单聊的话' }));
153
+ await new Promise((resolve) => setTimeout(resolve, 250));
154
+ assert.equal(pushes.length, 1);
155
+ assert.match(pushes[0].event.text, /单聊的话/);
156
+ socket.close();
157
+ await world.stop();
158
+ });
159
+
160
+ test('坏帧不崩、控制帧不误推', async () => {
161
+ const { world, pushes, port } = await startWorld();
162
+ const socket = await connect(port);
163
+ socket.send('这不是 JSON');
164
+ socket.send(JSON.stringify({ t: 'hello', role: 'wechat-listener' }));
165
+ await new Promise((resolve) => setTimeout(resolve, 200));
166
+ assert.equal(pushes.length, 0);
167
+ socket.close();
168
+ await world.stop();
169
+ });
170
+
171
+ test('status() 反映连接数与收帧数', async () => {
172
+ const { world, port } = await startWorld();
173
+ const socket = await connect(port);
174
+ socket.send(JSON.stringify({ t: 'notice', kind: 'message', chat: '张三', sender: '张三', is_group: false, preview: 'x' }));
175
+ await new Promise((resolve) => setTimeout(resolve, 200));
176
+ const status = world.status();
177
+ assert.equal(status.clients, 1);
178
+ assert.equal(status.received, 1);
179
+ assert.equal(status.listening, true);
180
+ socket.close();
181
+ await world.stop();
182
+ assert.equal(world.status().listening, false, 'stop 后不该还报在监听');
183
+ });
184
+
185
+ test('stop 之后端口释放(可以立刻重新起在同一端口)', async () => {
186
+ const { world, port } = await startWorld();
187
+ await world.stop();
188
+ const again = new WechatWorld(WECHAT_ID, { url: `ws://127.0.0.1:${port}/wechat/pet` }, loadWs);
189
+ const host = { pushEvent: async (e) => e, log: { info: () => {} } };
190
+ await again.start(host);
191
+ await new Promise((resolve) => setTimeout(resolve, 120));
192
+ assert.equal(again.status().listening, true);
193
+ await again.stop();
194
+ });
@@ -1,79 +0,0 @@
1
- /**
2
- * 依赖自描述生成器:从扩展包里查找依赖的原生模块目录,打包进 native/。
3
- *
4
- * 为什么需要:扩展依赖 better-sqlite3-multiple-ciphers(原生 .node)。
5
- * 普通用户不会自己 npm install,缺了它扩展根本起不来。
6
- * 只保留 win32-x64 的 prebuild,并剔除 C 源码,避免包从 1.3MB 涨到 30MB+。
7
- *
8
- * 用法:node tools/pack-native.mjs <目标 native 目录> [查找根...]
9
- */
10
- import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from 'node:fs';
11
- import { join } from 'node:path';
12
-
13
- const MODULES = ['better-sqlite3-multiple-ciphers', 'node-addon-api'];
14
- /** 这些内容用户用不到、体积又大:C 源码、其它平台的加载器 */
15
- const DROP_DIRS = new Set(['deps', 'src']);
16
- const KEEP_PREBUILD = /^win32-x64\./;
17
- const DROP_PLATFORM_FILE = /^(darwin|linux|linuxmusl|win32-arm64)/;
18
-
19
- const target = process.argv[2];
20
- const searchRoots = process.argv.slice(3);
21
- if (!target || searchRoots.length === 0) {
22
- console.error('用法: node tools/pack-native.mjs <目标 native 目录> <查找根...>');
23
- process.exit(2);
24
- }
25
-
26
- function locate(moduleName) {
27
- for (const root of searchRoots) {
28
- const candidate = join(root, 'node_modules', moduleName);
29
- if (existsSync(candidate)) return candidate;
30
- }
31
- return null;
32
- }
33
-
34
- /** 先整拷,再删用不到的东西——比在拷贝时做过滤判断更不容易出错 */
35
- function slimModule(dir) {
36
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
37
- const full = join(dir, entry.name);
38
- if (entry.isDirectory()) {
39
- if (DROP_DIRS.has(entry.name)) {
40
- rmSync(full, { recursive: true, force: true });
41
- } else if (entry.name === 'prebuilds') {
42
- for (const file of readdirSync(full)) {
43
- if (!KEEP_PREBUILD.test(file)) rmSync(join(full, file), { force: true });
44
- }
45
- } else {
46
- slimModule(full);
47
- }
48
- } else if (entry.name === 'binding.gyp' || DROP_PLATFORM_FILE.test(entry.name)) {
49
- rmSync(full, { force: true });
50
- }
51
- }
52
- }
53
-
54
- mkdirSync(target, { recursive: true });
55
- let packed = 0;
56
- for (const moduleName of MODULES) {
57
- const source = locate(moduleName);
58
- if (!source) {
59
- console.warn(` [!] 找不到 ${moduleName}`);
60
- continue;
61
- }
62
- const dest = join(target, moduleName);
63
- rmSync(dest, { recursive: true, force: true });
64
- cpSync(source, dest, { recursive: true });
65
- slimModule(dest);
66
- const bytes = (function total(dir) {
67
- let sum = 0;
68
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
69
- const full = join(dir, entry.name);
70
- if (entry.isDirectory()) sum += total(full);
71
- else if (entry.isFile()) sum += statSync(full).size;
72
- }
73
- return sum;
74
- })(dest);
75
- console.log(` [OK] ${moduleName} ${(bytes / 1024 / 1024).toFixed(2)} MB`);
76
- packed += 1;
77
- }
78
- console.log(`已打包 ${packed}/${MODULES.length} -> ${target}`);
79
- process.exit(packed === MODULES.length ? 0 : 1);