cortico-world-wechat 0.1.0 → 0.1.2
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 +13 -0
- package/README.md +244 -0
- package/package.json +52 -51
- package/test/decrypt-verify.mjs +56 -0
- package/test/format.test.mjs +55 -0
- package/test/listener.test.mjs +60 -0
- package/test/world.test.mjs +194 -0
- package/tools/pack-native.mjs +0 -79
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
本文件的 `## <版本号>` 段落会显示在扩展市场的发布时间线上(索引仓的 Action 按同样规则切段)。
|
|
4
4
|
|
|
5
|
+
## 0.1.2
|
|
6
|
+
|
|
7
|
+
- 文档重写:安装给出三条路径(npm / 离线包 / 源码,按推荐顺序)、配置项逐条说明、
|
|
8
|
+
"没反应时按顺序查"的排查表、以及"消息正文会进入模型上下文"的明确提示。
|
|
9
|
+
- 无功能变化。
|
|
10
|
+
## 0.1.1
|
|
11
|
+
|
|
12
|
+
- **打包内容调整**(无功能变化):
|
|
13
|
+
- 把打包脚本移出 npm 包(仓库里保留在 `scripts/`)——它是构建用的,最终用户用不到;
|
|
14
|
+
- npm 包内补上 `test/`,方便使用者自行验证(尤其是微信升级后跑 `test/decrypt-verify.mjs`,
|
|
15
|
+
它用 `PRAGMA integrity_check` 判断页布局是否仍被正确解密)。
|
|
16
|
+
|
|
5
17
|
## 0.1.0
|
|
6
18
|
|
|
7
19
|
首个公开版本。
|
|
@@ -14,3 +26,4 @@
|
|
|
14
26
|
- **取密钥**:微信的库密钥只存在于其进程内存中,由 `tools/fetch-keys.py` 按需取出
|
|
15
27
|
(短命进程,取完即退;失效时自动重取)。这是本项目唯一用到 Python 的地方。
|
|
16
28
|
- 实测:真机库解密 `PRAGMA integrity_check = ok`;测试 26 例(7 监听逻辑 + 6 文本格式化 + 13 World 行为)。
|
|
29
|
+
|
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,51 +1,52 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cortico-world-wechat",
|
|
3
|
-
"version": "0.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
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
"
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
"cortico",
|
|
32
|
-
"
|
|
33
|
-
"
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"
|
|
39
|
-
"
|
|
40
|
-
"
|
|
41
|
-
"
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cortico-world-wechat",
|
|
3
|
+
"version": "0.1.2",
|
|
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
|
+
}
|
|
@@ -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
|
+
});
|
package/tools/pack-native.mjs
DELETED
|
@@ -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);
|