weflow-cli 1.5.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/LICENSE +21 -0
- package/README.md +149 -0
- package/bin/weflow-cli-electron.cjs +59 -0
- package/bin/weflow-cli.ts +2311 -0
- package/cli.cjs +3 -0
- package/dist/bin/weflow-cli.d.ts +3 -0
- package/dist/bin/weflow-cli.d.ts.map +1 -0
- package/dist/bin/weflow-cli.js +2270 -0
- package/dist/bin/weflow-cli.js.map +1 -0
- package/dist/mcp-server/index.d.ts +3 -0
- package/dist/mcp-server/index.d.ts.map +1 -0
- package/dist/mcp-server/index.js +690 -0
- package/dist/mcp-server/index.js.map +1 -0
- package/dist/src/core/dbPathService.d.ts +40 -0
- package/dist/src/core/dbPathService.d.ts.map +1 -0
- package/dist/src/core/dbPathService.js +324 -0
- package/dist/src/core/dbPathService.js.map +1 -0
- package/dist/src/core/keyService.d.ts +59 -0
- package/dist/src/core/keyService.d.ts.map +1 -0
- package/dist/src/core/keyService.js +368 -0
- package/dist/src/core/keyService.js.map +1 -0
- package/dist/src/core/ntCore.d.ts +62 -0
- package/dist/src/core/ntCore.d.ts.map +1 -0
- package/dist/src/core/ntCore.js +191 -0
- package/dist/src/core/ntCore.js.map +1 -0
- package/dist/src/core/sqlcipherCore.d.ts +119 -0
- package/dist/src/core/sqlcipherCore.d.ts.map +1 -0
- package/dist/src/core/sqlcipherCore.js +693 -0
- package/dist/src/core/sqlcipherCore.js.map +1 -0
- package/dist/src/core/wcdbCore.d.ts +738 -0
- package/dist/src/core/wcdbCore.d.ts.map +1 -0
- package/dist/src/core/wcdbCore.js +4545 -0
- package/dist/src/core/wcdbCore.js.map +1 -0
- package/dist/src/core/wechatClient.d.ts +23 -0
- package/dist/src/core/wechatClient.d.ts.map +1 -0
- package/dist/src/core/wechatClient.js +166 -0
- package/dist/src/core/wechatClient.js.map +1 -0
- package/dist/src/services/chatService.d.ts +25 -0
- package/dist/src/services/chatService.d.ts.map +1 -0
- package/dist/src/services/chatService.js +291 -0
- package/dist/src/services/chatService.js.map +1 -0
- package/dist/src/services/configService.d.ts +43 -0
- package/dist/src/services/configService.d.ts.map +1 -0
- package/dist/src/services/configService.js +156 -0
- package/dist/src/services/configService.js.map +1 -0
- package/dist/src/services/exportService.d.ts +30 -0
- package/dist/src/services/exportService.d.ts.map +1 -0
- package/dist/src/services/exportService.js +273 -0
- package/dist/src/services/exportService.js.map +1 -0
- package/dist/src/services/wechat-formatter.d.ts +69 -0
- package/dist/src/services/wechat-formatter.d.ts.map +1 -0
- package/dist/src/services/wechat-formatter.js +248 -0
- package/dist/src/services/wechat-formatter.js.map +1 -0
- package/dist/src/services/wechatMessageService.d.ts +28 -0
- package/dist/src/services/wechatMessageService.d.ts.map +1 -0
- package/dist/src/services/wechatMessageService.js +341 -0
- package/dist/src/services/wechatMessageService.js.map +1 -0
- package/dist/src/services/wereadService.d.ts +206 -0
- package/dist/src/services/wereadService.d.ts.map +1 -0
- package/dist/src/services/wereadService.js +145 -0
- package/dist/src/services/wereadService.js.map +1 -0
- package/dist/src/services/whitelistService.d.ts +20 -0
- package/dist/src/services/whitelistService.d.ts.map +1 -0
- package/dist/src/services/whitelistService.js +60 -0
- package/dist/src/services/whitelistService.js.map +1 -0
- package/dist/src/types.d.ts +140 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +2 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils/errors.d.ts +25 -0
- package/dist/src/utils/errors.d.ts.map +1 -0
- package/dist/src/utils/errors.js +43 -0
- package/dist/src/utils/errors.js.map +1 -0
- package/dist/src/utils/pathUtils.d.ts +5 -0
- package/dist/src/utils/pathUtils.d.ts.map +1 -0
- package/dist/src/utils/pathUtils.js +16 -0
- package/dist/src/utils/pathUtils.js.map +1 -0
- package/dist/src/utils/pythonRunner.d.ts +27 -0
- package/dist/src/utils/pythonRunner.d.ts.map +1 -0
- package/dist/src/utils/pythonRunner.js +62 -0
- package/dist/src/utils/pythonRunner.js.map +1 -0
- package/dist/src/utils/talkerUtils.d.ts +14 -0
- package/dist/src/utils/talkerUtils.d.ts.map +1 -0
- package/dist/src/utils/talkerUtils.js +73 -0
- package/dist/src/utils/talkerUtils.js.map +1 -0
- package/mcp-server/index.ts +757 -0
- package/package.json +72 -0
- package/resources/key/win32/x64/wx_key.dll +0 -0
- package/resources/wcdb/win32/x64/SDL2.dll +0 -0
- package/resources/wcdb/win32/x64/WCDB.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140.dll +0 -0
- package/resources/wcdb/win32/x64/msvcp140_1.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140.dll +0 -0
- package/resources/wcdb/win32/x64/vcruntime140_1.dll +0 -0
- package/resources/wcdb/win32/x64/wcdb_api.dll +0 -0
- package/scripts/_batch_link_sources.py +73 -0
- package/scripts/_utils.py +531 -0
- package/scripts/annual_report.py +796 -0
- package/scripts/auto_tag.py +128 -0
- package/scripts/biz_daily.py +896 -0
- package/scripts/chat_report.py +397 -0
- package/scripts/chat_stats.py +702 -0
- package/scripts/classify_daily.py +411 -0
- package/scripts/compile_wiki.py +258 -0
- package/scripts/create_reading_notes.py +370 -0
- package/scripts/enrich_backlinks.py +163 -0
- package/scripts/export_chat_html.py +766 -0
- package/scripts/extract_3x_key.py +86 -0
- package/scripts/extract_todos.py +404 -0
- package/scripts/fav_server.py +392 -0
- package/scripts/fix_topics.py +111 -0
- package/scripts/generate_ai_report.py +367 -0
- package/scripts/generate_html.py +1372 -0
- package/scripts/generate_review.py +170 -0
- package/scripts/mcp_bridge.py +398 -0
- package/scripts/nt_decrypt.py +562 -0
- package/scripts/pipeline.py +145 -0
- package/scripts/promote_all.py +310 -0
- package/scripts/promote_ideas.py +297 -0
- package/scripts/rag_chat.py +191 -0
- package/scripts/scan_decrypt_4x.py +329 -0
- package/scripts/semantic_search.py +431 -0
- package/scripts/sync_fav.py +146 -0
- package/scripts/sync_weread.py +192 -0
- package/scripts/vault_rag.py +139 -0
- package/scripts/vault_search.py +141 -0
- package/src/core/dbPathService.ts +348 -0
- package/src/core/keyService.ts +409 -0
- package/src/core/ntCore.ts +224 -0
- package/src/core/sqlcipherCore.ts +764 -0
- package/src/core/wcdbCore.ts +4568 -0
- package/src/core/wechatClient.ts +204 -0
- package/src/lz4.d.ts +10 -0
- package/src/services/chatService.ts +296 -0
- package/src/services/configService.ts +197 -0
- package/src/services/exportService.ts +305 -0
- package/src/services/wechat-formatter.ts +348 -0
- package/src/services/wechatMessageService.ts +383 -0
- package/src/services/wereadService.ts +308 -0
- package/src/services/whitelistService.ts +72 -0
- package/src/types.ts +141 -0
- package/src/utils/errors.ts +42 -0
- package/src/utils/pathUtils.ts +16 -0
- package/src/utils/pythonRunner.ts +81 -0
- package/src/utils/talkerUtils.ts +84 -0
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SQLCipher Core - 3.x 数据库访问层
|
|
3
|
+
*
|
|
4
|
+
* WeChat 3.x 使用自定义 SQLCipher 加密格式:
|
|
5
|
+
* - 页大小: 4096 字节
|
|
6
|
+
* - KDF: PBKDF2-HMAC-SHA1, 64000 次迭代
|
|
7
|
+
* - 加密: AES-256-CBC
|
|
8
|
+
* - HMAC: HMAC-SHA1 (用于验证, 解密时不需要)
|
|
9
|
+
*
|
|
10
|
+
* 每页结构 (文件字节布局):
|
|
11
|
+
* - 文件头 16 字节: Salt (仅首页之前)
|
|
12
|
+
* - 数据区: 4048 字节加密数据
|
|
13
|
+
* - 保留区 48 字节: [IV(16) | HMAC-SHA1(20) | 保留(12)]
|
|
14
|
+
*/
|
|
15
|
+
import crypto from 'crypto';
|
|
16
|
+
import { existsSync, readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync } from 'fs';
|
|
17
|
+
import { join, basename, dirname } from 'path';
|
|
18
|
+
import os from 'os';
|
|
19
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
20
|
+
import lz4 from 'lz4';
|
|
21
|
+
const PAGE_SIZE = 4096;
|
|
22
|
+
const KEY_SIZE = 32;
|
|
23
|
+
const DEFAULT_ITER = 64000;
|
|
24
|
+
const RESERVED_LEN = 48; // IV(16) + HMAC(20) + 保留(12)
|
|
25
|
+
const SQLITE_HEADER = 'SQLite format 3\x00';
|
|
26
|
+
export class SqlcipherCore {
|
|
27
|
+
db = null;
|
|
28
|
+
tempDir = '';
|
|
29
|
+
tempFiles = new Map();
|
|
30
|
+
pageKey = null;
|
|
31
|
+
opened = false;
|
|
32
|
+
wxid = '';
|
|
33
|
+
wxDirRoot = '';
|
|
34
|
+
/**
|
|
35
|
+
* 解密数据库到临时文件
|
|
36
|
+
*/
|
|
37
|
+
decryptToTemp(dbPath, keyHex) {
|
|
38
|
+
const cached = this.tempFiles.get(dbPath);
|
|
39
|
+
if (cached && existsSync(cached))
|
|
40
|
+
return cached;
|
|
41
|
+
const password = Buffer.from(keyHex, 'hex');
|
|
42
|
+
const blist = readFileSync(dbPath);
|
|
43
|
+
const salt = blist.subarray(0, 16);
|
|
44
|
+
// 派生页面加密密钥 (AES-256 key)
|
|
45
|
+
this.pageKey = crypto.pbkdf2Sync(password, salt, DEFAULT_ITER, KEY_SIZE, 'sha1');
|
|
46
|
+
// 创建临时目录
|
|
47
|
+
if (!this.tempDir) {
|
|
48
|
+
this.tempDir = join(os.tmpdir(), 'weflow_3x_decrypt');
|
|
49
|
+
if (!existsSync(this.tempDir)) {
|
|
50
|
+
mkdirSync(this.tempDir, { recursive: true });
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const tempPath = join(this.tempDir, basename(dbPath) + '.decrypted.db');
|
|
54
|
+
const output = [];
|
|
55
|
+
// 写入 SQLite 文件头 "SQLite format 3\0"
|
|
56
|
+
output.push(Buffer.from(SQLITE_HEADER));
|
|
57
|
+
// 处理每一页
|
|
58
|
+
for (let i = 0; i < blist.length; i += PAGE_SIZE) {
|
|
59
|
+
if (i === 0) {
|
|
60
|
+
// 第一页: 跳过 salt (16字节), 取 blist[16:4096] = 4080 字节
|
|
61
|
+
const pageData = blist.subarray(16, i + PAGE_SIZE);
|
|
62
|
+
this.decryptPage(pageData, output);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
const pageData = blist.subarray(i, Math.min(i + PAGE_SIZE, blist.length));
|
|
66
|
+
this.decryptPage(pageData, output);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
writeFileSync(tempPath, Buffer.concat(output));
|
|
70
|
+
this.tempFiles.set(dbPath, tempPath);
|
|
71
|
+
return tempPath;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* 解密单个页面
|
|
75
|
+
* pageData: 第一页 4080 字节, 其他页 4096 字节
|
|
76
|
+
* 结构: [加密数据] + [IV(16) | HMAC(20) | 保留(12)]
|
|
77
|
+
*/
|
|
78
|
+
decryptPage(pageData, output) {
|
|
79
|
+
if (pageData.length === 0)
|
|
80
|
+
return;
|
|
81
|
+
if (pageData.length <= RESERVED_LEN) {
|
|
82
|
+
output.push(pageData);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
const encryptedLen = pageData.length - RESERVED_LEN;
|
|
86
|
+
const encryptedData = pageData.subarray(0, encryptedLen);
|
|
87
|
+
const reserved = pageData.subarray(encryptedLen);
|
|
88
|
+
const iv = reserved.subarray(0, 16);
|
|
89
|
+
try {
|
|
90
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', this.pageKey, iv);
|
|
91
|
+
decipher.setAutoPadding(false);
|
|
92
|
+
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
|
93
|
+
output.push(decrypted);
|
|
94
|
+
output.push(reserved);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// 解密失败时保持原始数据
|
|
98
|
+
output.push(pageData);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* 直接打开已解密的 SQLite 数据库 (跳过解密步骤)
|
|
103
|
+
* 用于 4.x 已解密的 MSG0_decrypted.db 或测试数据库
|
|
104
|
+
*/
|
|
105
|
+
async openRaw(dbPath, wxid) {
|
|
106
|
+
if (this.opened)
|
|
107
|
+
this.close();
|
|
108
|
+
if (!existsSync(dbPath)) {
|
|
109
|
+
return { success: false, error: `数据库不存在: ${dbPath}` };
|
|
110
|
+
}
|
|
111
|
+
this.wxid = wxid || '';
|
|
112
|
+
try {
|
|
113
|
+
this.db = new DatabaseSync(dbPath);
|
|
114
|
+
this.opened = true;
|
|
115
|
+
return { success: true };
|
|
116
|
+
}
|
|
117
|
+
catch (e) {
|
|
118
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
119
|
+
return { success: false, error: `打开 4.x 数据库失败: ${msg}` };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* 尝试直接解密 4.x MSG0.db
|
|
124
|
+
*
|
|
125
|
+
* 4.x 使用 WCDB/SQLCipher 4 加密:
|
|
126
|
+
* - AES-256-CBC
|
|
127
|
+
* - PBKDF2-HMAC-SHA512, 256000 次迭代
|
|
128
|
+
* - 页大小: 4096 字节
|
|
129
|
+
* - 保留区: 80 字节 (IV 16 + HMAC 64) 或 48 字节 (兼容旧版)
|
|
130
|
+
*
|
|
131
|
+
* 注意: 密钥必须是微信登录时通过 wx_key.dll Hook 提取的正确密钥。
|
|
132
|
+
* 已登录后提取的密钥可能不正确。
|
|
133
|
+
*/
|
|
134
|
+
async open4x(dbPath, keyHex, wxid) {
|
|
135
|
+
if (this.opened)
|
|
136
|
+
this.close();
|
|
137
|
+
if (!existsSync(dbPath)) {
|
|
138
|
+
return { success: false, error: `数据库不存在: ${dbPath}` };
|
|
139
|
+
}
|
|
140
|
+
if (!keyHex || keyHex.length !== 64) {
|
|
141
|
+
return { success: false, error: '4.x 密钥格式错误,需要 64 位十六进制字符串' };
|
|
142
|
+
}
|
|
143
|
+
this.wxid = wxid || '';
|
|
144
|
+
try {
|
|
145
|
+
this.wxDirRoot = dirname(dirname(dirname(dirname(dbPath))));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
this.wxDirRoot = '';
|
|
149
|
+
}
|
|
150
|
+
const password = Buffer.from(keyHex, 'hex');
|
|
151
|
+
const blist = readFileSync(dbPath);
|
|
152
|
+
const salt = blist.subarray(0, 16);
|
|
153
|
+
// 4.x 参数组合 (按优先级尝试)
|
|
154
|
+
const configs = [
|
|
155
|
+
{ sha: 'sha512', iters: 256000, reserve: 80 }, // WCDB/SQLCipher 4 标准
|
|
156
|
+
{ sha: 'sha512', iters: 256000, reserve: 48 }, // SQLCipher 4 + 48 字节保留区
|
|
157
|
+
{ sha: 'sha1', iters: 64000, reserve: 48 }, // SQLCipher 3 兼容
|
|
158
|
+
{ sha: 'sha512', iters: 64000, reserve: 80 },
|
|
159
|
+
{ sha: 'sha256', iters: 256000, reserve: 80 },
|
|
160
|
+
];
|
|
161
|
+
// 创建临时目录
|
|
162
|
+
if (!this.tempDir) {
|
|
163
|
+
this.tempDir = join(os.tmpdir(), 'weflow_4x_decrypt');
|
|
164
|
+
if (!existsSync(this.tempDir)) {
|
|
165
|
+
mkdirSync(this.tempDir, { recursive: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
for (const cfg of configs) {
|
|
169
|
+
try {
|
|
170
|
+
const pageKey = crypto.pbkdf2Sync(password, salt, cfg.iters, KEY_SIZE, cfg.sha);
|
|
171
|
+
const tempPath = join(this.tempDir, basename(dbPath) + '.4x_decrypted.db');
|
|
172
|
+
this.decryptPages(blist, pageKey, salt, cfg.reserve, tempPath);
|
|
173
|
+
// 验证解密结果
|
|
174
|
+
const db = new DatabaseSync(tempPath);
|
|
175
|
+
this.db = db;
|
|
176
|
+
this.opened = true;
|
|
177
|
+
this.tempFiles.set(dbPath, tempPath);
|
|
178
|
+
return { success: true };
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// 尝试下一组参数
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 如果 PBKDF2 派生失败,尝试原始密钥直接使用
|
|
185
|
+
for (const reserve of [80, 48]) {
|
|
186
|
+
try {
|
|
187
|
+
const tempPath = join(this.tempDir, basename(dbPath) + '.4x_raw.decrypted.db');
|
|
188
|
+
this.decryptPages(blist, password, salt, reserve, tempPath);
|
|
189
|
+
const db = new DatabaseSync(tempPath);
|
|
190
|
+
this.db = db;
|
|
191
|
+
this.opened = true;
|
|
192
|
+
this.tempFiles.set(dbPath, tempPath);
|
|
193
|
+
return { success: true };
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// 尝试下一组参数
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return {
|
|
200
|
+
success: false,
|
|
201
|
+
error: [
|
|
202
|
+
'4.x 数据库直接解密失败,可能原因:',
|
|
203
|
+
' 1. 密钥不是微信登录时提取的 (wx_key.dll 仅在登录时捕获密钥)',
|
|
204
|
+
' 2. 密钥与数据库版本不匹配',
|
|
205
|
+
' 3. 数据库使用了非标准加密参数',
|
|
206
|
+
'',
|
|
207
|
+
'解决方案:',
|
|
208
|
+
' - 使用 python scripts/scan_decrypt_4x.py 自动扫描解密',
|
|
209
|
+
' - 或重启微信并在登录时运行: weflow-cli dbkey --timeout 120000',
|
|
210
|
+
].join('\n'),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* 按页面解密 4.x 数据库
|
|
215
|
+
*/
|
|
216
|
+
decryptPages(blist, pageKey, salt, reserveLen, outputPath) {
|
|
217
|
+
const output = [];
|
|
218
|
+
// 写入 SQLite 文件头
|
|
219
|
+
output.push(Buffer.from(SQLITE_HEADER));
|
|
220
|
+
for (let i = 0; i < blist.length; i += PAGE_SIZE) {
|
|
221
|
+
const pageData = blist.subarray(i, Math.min(i + PAGE_SIZE, blist.length));
|
|
222
|
+
if (pageData.length === 0)
|
|
223
|
+
continue;
|
|
224
|
+
if (i === 0) {
|
|
225
|
+
// 第一页: 跳过 salt(16), 解密数据区
|
|
226
|
+
const encryptedLen = pageData.length - 16 - reserveLen;
|
|
227
|
+
if (encryptedLen <= 0) {
|
|
228
|
+
output.push(pageData.subarray(16));
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const encryptedData = pageData.subarray(16, 16 + encryptedLen);
|
|
232
|
+
const reserved = pageData.subarray(16 + encryptedLen);
|
|
233
|
+
const iv = reserved.subarray(0, 16);
|
|
234
|
+
try {
|
|
235
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', pageKey, iv);
|
|
236
|
+
decipher.setAutoPadding(false);
|
|
237
|
+
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
|
238
|
+
output.push(decrypted);
|
|
239
|
+
output.push(reserved);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
throw new Error('解密失败');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
else {
|
|
246
|
+
// 后续页: 全页加密数据区 + 保留区
|
|
247
|
+
const encryptedLen = pageData.length - reserveLen;
|
|
248
|
+
if (encryptedLen <= 0) {
|
|
249
|
+
output.push(pageData);
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
const encryptedData = pageData.subarray(0, encryptedLen);
|
|
253
|
+
const reserved = pageData.subarray(encryptedLen);
|
|
254
|
+
const iv = reserved.subarray(0, 16);
|
|
255
|
+
try {
|
|
256
|
+
const decipher = crypto.createDecipheriv('aes-256-cbc', pageKey, iv);
|
|
257
|
+
decipher.setAutoPadding(false);
|
|
258
|
+
const decrypted = Buffer.concat([decipher.update(encryptedData), decipher.final()]);
|
|
259
|
+
output.push(decrypted);
|
|
260
|
+
output.push(reserved);
|
|
261
|
+
}
|
|
262
|
+
catch {
|
|
263
|
+
throw new Error('解密失败');
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
writeFileSync(outputPath, Buffer.concat(output));
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* 打开 3.x 数据库
|
|
271
|
+
*/
|
|
272
|
+
async open(dbPath, keyHex, wxid) {
|
|
273
|
+
if (this.opened)
|
|
274
|
+
this.close();
|
|
275
|
+
if (!existsSync(dbPath)) {
|
|
276
|
+
return { success: false, error: `数据库不存在: ${dbPath}` };
|
|
277
|
+
}
|
|
278
|
+
if (!keyHex || keyHex.length !== 64) {
|
|
279
|
+
return { success: false, error: '3.x 密钥格式错误,需要 64 位十六进制字符串' };
|
|
280
|
+
}
|
|
281
|
+
this.wxid = wxid || '';
|
|
282
|
+
// 从 dbPath 推导 WeChat Files 根目录
|
|
283
|
+
// dbPath: {wxDirRoot}/{wxid}/Msg/Multi/MSG0.db
|
|
284
|
+
// wxDirRoot = dirname(dirname(dirname(dbPath)))
|
|
285
|
+
try {
|
|
286
|
+
this.wxDirRoot = dirname(dirname(dirname(dirname(dbPath))));
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
this.wxDirRoot = '';
|
|
290
|
+
}
|
|
291
|
+
try {
|
|
292
|
+
const tempPath = this.decryptToTemp(dbPath, keyHex);
|
|
293
|
+
this.db = new DatabaseSync(tempPath);
|
|
294
|
+
this.opened = true;
|
|
295
|
+
return { success: true };
|
|
296
|
+
}
|
|
297
|
+
catch (e) {
|
|
298
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
299
|
+
return { success: false, error: `打开 3.x 数据库失败: ${msg}` };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* 获取会话列表 (从 MSG 表中聚合 StrTalker)
|
|
304
|
+
*/
|
|
305
|
+
async getSessions() {
|
|
306
|
+
if (!this.db)
|
|
307
|
+
return { success: false, error: '数据库未打开' };
|
|
308
|
+
try {
|
|
309
|
+
const stmt = this.db.prepare(`
|
|
310
|
+
SELECT
|
|
311
|
+
m.StrTalker as username,
|
|
312
|
+
MAX(m.CreateTime) as lastTimestamp,
|
|
313
|
+
(SELECT m2.StrContent FROM MSG m2
|
|
314
|
+
WHERE m2.StrTalker = m.StrTalker
|
|
315
|
+
ORDER BY m2.CreateTime DESC LIMIT 1) as summary
|
|
316
|
+
FROM MSG m
|
|
317
|
+
WHERE m.StrTalker != ''
|
|
318
|
+
GROUP BY m.StrTalker
|
|
319
|
+
ORDER BY lastTimestamp DESC
|
|
320
|
+
LIMIT 500
|
|
321
|
+
`);
|
|
322
|
+
const rows = stmt.all();
|
|
323
|
+
const sessions = rows.map((row) => ({
|
|
324
|
+
username: row.username || '',
|
|
325
|
+
type: (row.username || '').includes('@chatroom') ? 1 : 0,
|
|
326
|
+
unreadCount: 0,
|
|
327
|
+
summary: row.summary || '',
|
|
328
|
+
sortTimestamp: row.lastTimestamp || 0,
|
|
329
|
+
lastTimestamp: row.lastTimestamp || 0,
|
|
330
|
+
displayName: row.username || '',
|
|
331
|
+
}));
|
|
332
|
+
return { success: true, sessions };
|
|
333
|
+
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
336
|
+
return { success: false, error: `获取会话失败: ${msg}` };
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* 获取消息列表
|
|
341
|
+
*/
|
|
342
|
+
async getMessages(talker, limit = 0, offset = 0) {
|
|
343
|
+
if (!this.db)
|
|
344
|
+
return { success: false, error: '数据库未打开' };
|
|
345
|
+
try {
|
|
346
|
+
let rows;
|
|
347
|
+
if (limit > 0) {
|
|
348
|
+
const stmt = this.db.prepare(`
|
|
349
|
+
SELECT
|
|
350
|
+
localId, CAST(MsgSvrID AS TEXT) as MsgSvrID, Type, SubType, IsSender,
|
|
351
|
+
CreateTime, StrTalker, StrContent, CompressContent, BytesExtra
|
|
352
|
+
FROM MSG
|
|
353
|
+
WHERE StrTalker = ?
|
|
354
|
+
ORDER BY CreateTime DESC
|
|
355
|
+
LIMIT ? OFFSET ?
|
|
356
|
+
`);
|
|
357
|
+
rows = stmt.all(talker, limit, offset);
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
const stmt = this.db.prepare(`
|
|
361
|
+
SELECT
|
|
362
|
+
localId, CAST(MsgSvrID AS TEXT) as MsgSvrID, Type, SubType, IsSender,
|
|
363
|
+
CreateTime, StrTalker, StrContent, CompressContent, BytesExtra
|
|
364
|
+
FROM MSG
|
|
365
|
+
WHERE StrTalker = ?
|
|
366
|
+
ORDER BY CreateTime DESC
|
|
367
|
+
`);
|
|
368
|
+
rows = stmt.all(talker);
|
|
369
|
+
}
|
|
370
|
+
const messages = rows.map((row) => this.processMessage(row));
|
|
371
|
+
return { success: true, messages };
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
375
|
+
return { success: false, error: `获取消息失败: ${msg}` };
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
/**
|
|
379
|
+
* 处理单条消息,根据类型解析内容
|
|
380
|
+
*/
|
|
381
|
+
processMessage(row) {
|
|
382
|
+
const localType = Number(row.Type) || 0;
|
|
383
|
+
const isSend = Number(row.IsSender) || 0;
|
|
384
|
+
const strContent = String(row.StrContent ?? '');
|
|
385
|
+
const compressContent = row.CompressContent;
|
|
386
|
+
const bytesExtra = row.BytesExtra;
|
|
387
|
+
let parsedContent = '';
|
|
388
|
+
let imageBase64;
|
|
389
|
+
let imageFileName;
|
|
390
|
+
let appTitle;
|
|
391
|
+
let appDescription;
|
|
392
|
+
let appUrl;
|
|
393
|
+
switch (localType) {
|
|
394
|
+
case 1: // 文本
|
|
395
|
+
parsedContent = strContent;
|
|
396
|
+
break;
|
|
397
|
+
case 3: { // 图片
|
|
398
|
+
const imgInfo = this.parseImageXml(strContent);
|
|
399
|
+
// 从 BytesExtra 提取图片文件名
|
|
400
|
+
imageFileName = this.parseBytesExtraImageFileName(bytesExtra);
|
|
401
|
+
if (imageFileName) {
|
|
402
|
+
parsedContent = `[图片: ${imageFileName}]`;
|
|
403
|
+
}
|
|
404
|
+
else {
|
|
405
|
+
parsedContent = '[图片]';
|
|
406
|
+
}
|
|
407
|
+
// 尝试查找缩略图
|
|
408
|
+
if (imageFileName) {
|
|
409
|
+
imageBase64 = this.findThumbnail(imageFileName, row.CreateTime);
|
|
410
|
+
}
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case 34: // 语音
|
|
414
|
+
parsedContent = '[语音]';
|
|
415
|
+
break;
|
|
416
|
+
case 43: // 视频
|
|
417
|
+
parsedContent = '[视频]';
|
|
418
|
+
break;
|
|
419
|
+
case 47: // 表情
|
|
420
|
+
parsedContent = strContent ? `[表情: ${strContent}]` : '[表情]';
|
|
421
|
+
break;
|
|
422
|
+
case 49: { // AppMsg (链接/文件/引用)
|
|
423
|
+
const xml = this.decompressCompressContent(compressContent);
|
|
424
|
+
if (xml) {
|
|
425
|
+
const appInfo = this.parseAppMsgXml(xml);
|
|
426
|
+
appTitle = appInfo.title;
|
|
427
|
+
appDescription = appInfo.description;
|
|
428
|
+
appUrl = appInfo.url;
|
|
429
|
+
parsedContent = this.formatAppMsg(appInfo);
|
|
430
|
+
}
|
|
431
|
+
else {
|
|
432
|
+
// 部分 Type=49 消息内容在 StrContent 中
|
|
433
|
+
parsedContent = strContent || '[链接/文件]';
|
|
434
|
+
}
|
|
435
|
+
break;
|
|
436
|
+
}
|
|
437
|
+
case 50: // 语音通话
|
|
438
|
+
parsedContent = '[语音通话]';
|
|
439
|
+
break;
|
|
440
|
+
case 10000: // 系统消息
|
|
441
|
+
parsedContent = strContent.replace(/\n/g, ' ');
|
|
442
|
+
break;
|
|
443
|
+
default:
|
|
444
|
+
parsedContent = strContent || '';
|
|
445
|
+
}
|
|
446
|
+
return {
|
|
447
|
+
localId: Number(row.localId) || 0,
|
|
448
|
+
serverId: String(row.MsgSvrID ?? ''),
|
|
449
|
+
localType,
|
|
450
|
+
createTime: Number(row.CreateTime) || 0,
|
|
451
|
+
isSend,
|
|
452
|
+
senderUsername: isSend ? '' : (row.StrTalker || ''),
|
|
453
|
+
content: strContent,
|
|
454
|
+
rawContent: strContent,
|
|
455
|
+
parsedContent,
|
|
456
|
+
imageBase64,
|
|
457
|
+
imageFileName,
|
|
458
|
+
appTitle,
|
|
459
|
+
appDescription,
|
|
460
|
+
appUrl,
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* 将 node:sqlite 返回的 BLOB (可能是 Uint8Array 或 Buffer) 转换为 Buffer
|
|
465
|
+
*/
|
|
466
|
+
toBuffer(data) {
|
|
467
|
+
if (!data || data.length === 0)
|
|
468
|
+
return null;
|
|
469
|
+
if (Buffer.isBuffer(data))
|
|
470
|
+
return data;
|
|
471
|
+
// Uint8Array from node:sqlite
|
|
472
|
+
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
|
|
473
|
+
}
|
|
474
|
+
/**
|
|
475
|
+
* LZ4 解压 CompressContent
|
|
476
|
+
*/
|
|
477
|
+
decompressCompressContent(data) {
|
|
478
|
+
const buf = this.toBuffer(data);
|
|
479
|
+
if (!buf)
|
|
480
|
+
return null;
|
|
481
|
+
try {
|
|
482
|
+
const maxSize = buf.length * 256;
|
|
483
|
+
const outBuf = Buffer.alloc(maxSize);
|
|
484
|
+
const actualSize = lz4.decodeBlock(buf, outBuf);
|
|
485
|
+
return outBuf.subarray(0, actualSize).toString('utf8');
|
|
486
|
+
}
|
|
487
|
+
catch {
|
|
488
|
+
return null;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* 解析 Type=3 图片 XML
|
|
493
|
+
*/
|
|
494
|
+
parseImageXml(xml) {
|
|
495
|
+
try {
|
|
496
|
+
const aesMatch = xml.match(/aeskey="([^"]+)"/);
|
|
497
|
+
const urlMatch = xml.match(/cdnthumburl="([^"]+)"/);
|
|
498
|
+
return {
|
|
499
|
+
aeskey: aesMatch ? aesMatch[1] : undefined,
|
|
500
|
+
cdnthumburl: urlMatch ? urlMatch[1] : undefined,
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
return {};
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 从 BytesExtra (protobuf) 中提取图片文件名
|
|
509
|
+
* BytesExtra 包含 <msgsource><img_file_name>uuid.png</img_file_name></msgsource> 之类的 XML
|
|
510
|
+
*/
|
|
511
|
+
parseBytesExtraImageFileName(data) {
|
|
512
|
+
const buf = this.toBuffer(data);
|
|
513
|
+
if (!buf)
|
|
514
|
+
return undefined;
|
|
515
|
+
try {
|
|
516
|
+
const str = buf.toString('utf8');
|
|
517
|
+
const match = str.match(/<img_file_name>([^<]+)<\/img_file_name>/);
|
|
518
|
+
return match ? match[1] : undefined;
|
|
519
|
+
}
|
|
520
|
+
catch {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* 查找缩略图并返回 base64
|
|
526
|
+
* 缩略图在 FileStorage/Cache/YYYY-MM/ 目录下,以 _t.jpg 结尾
|
|
527
|
+
* 文件名基于 MD5 哈希,我们需要遍历目录查找匹配图片文件名的缩略图
|
|
528
|
+
*/
|
|
529
|
+
findThumbnail(imageFileName, createTime) {
|
|
530
|
+
if (!this.wxDirRoot || !this.wxid)
|
|
531
|
+
return undefined;
|
|
532
|
+
try {
|
|
533
|
+
// 从时间戳推断月份目录
|
|
534
|
+
const date = new Date(createTime * 1000);
|
|
535
|
+
const yearMonth = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
|
536
|
+
const cacheDir = join(this.wxDirRoot, this.wxid, 'FileStorage', 'Cache', yearMonth);
|
|
537
|
+
if (!existsSync(cacheDir))
|
|
538
|
+
return undefined;
|
|
539
|
+
// 读取目录中的所有文件
|
|
540
|
+
const files = readdirSync(cacheDir);
|
|
541
|
+
// 图片文件名格式: {uuid}.png → 对应的缩略图可能以 _t.jpg 结尾
|
|
542
|
+
// 缩略图命名是基于 MD5,而不是 UUID,所以无法直接匹配
|
|
543
|
+
// 返回 undefined,让调用方只显示图片文件名
|
|
544
|
+
return undefined;
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return undefined;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
/**
|
|
551
|
+
* 解析 Type=49 AppMsg XML
|
|
552
|
+
*/
|
|
553
|
+
parseAppMsgXml(xml) {
|
|
554
|
+
try {
|
|
555
|
+
const titleMatch = xml.match(/<title>([^<]*)<\/title>/);
|
|
556
|
+
const desMatch = xml.match(/<des>([^<]*)<\/des>/);
|
|
557
|
+
const urlMatch = xml.match(/<url>([^<]*)<\/url>/);
|
|
558
|
+
const typeMatch = xml.match(/<type>(\d+)<\/type>/);
|
|
559
|
+
return {
|
|
560
|
+
title: titleMatch ? this.decodeXmlEntities(titleMatch[1]) : undefined,
|
|
561
|
+
description: desMatch ? this.decodeXmlEntities(desMatch[1]) : undefined,
|
|
562
|
+
url: urlMatch ? this.decodeXmlEntities(urlMatch[1]) : undefined,
|
|
563
|
+
type: typeMatch ? typeMatch[1] : undefined,
|
|
564
|
+
};
|
|
565
|
+
}
|
|
566
|
+
catch {
|
|
567
|
+
return {};
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 解码 XML 实体字符
|
|
572
|
+
*/
|
|
573
|
+
decodeXmlEntities(str) {
|
|
574
|
+
return str
|
|
575
|
+
.replace(/&/g, '&')
|
|
576
|
+
.replace(/</g, '<')
|
|
577
|
+
.replace(/>/g, '>')
|
|
578
|
+
.replace(/"/g, '"')
|
|
579
|
+
.replace(/'/g, "'");
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* 格式化 AppMsg 为可读文本
|
|
583
|
+
*/
|
|
584
|
+
formatAppMsg(info) {
|
|
585
|
+
const parts = [];
|
|
586
|
+
switch (info.type) {
|
|
587
|
+
case '5': // 链接分享
|
|
588
|
+
if (info.title)
|
|
589
|
+
parts.push(`[分享] ${info.title}`);
|
|
590
|
+
if (info.description)
|
|
591
|
+
parts.push(info.description);
|
|
592
|
+
if (info.url)
|
|
593
|
+
parts.push(info.url);
|
|
594
|
+
break;
|
|
595
|
+
case '6': // 文件分享
|
|
596
|
+
if (info.title)
|
|
597
|
+
parts.push(`[文件] ${info.title}`);
|
|
598
|
+
break;
|
|
599
|
+
case '57': // 引用回复
|
|
600
|
+
if (info.title)
|
|
601
|
+
parts.push(`[引用] ${info.title}`);
|
|
602
|
+
break;
|
|
603
|
+
default:
|
|
604
|
+
if (info.title)
|
|
605
|
+
parts.push(`[AppMsg] ${info.title}`);
|
|
606
|
+
if (info.description)
|
|
607
|
+
parts.push(info.description);
|
|
608
|
+
break;
|
|
609
|
+
}
|
|
610
|
+
return parts.join('\n') || '[链接/文件]';
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* 获取联系人列表
|
|
614
|
+
*/
|
|
615
|
+
async getContacts() {
|
|
616
|
+
if (!this.db)
|
|
617
|
+
return { success: false, error: '数据库未打开' };
|
|
618
|
+
try {
|
|
619
|
+
const stmt = this.db.prepare(`
|
|
620
|
+
SELECT UsrName as username
|
|
621
|
+
FROM Name2ID
|
|
622
|
+
ORDER BY UsrName
|
|
623
|
+
LIMIT 500
|
|
624
|
+
`);
|
|
625
|
+
const rows = stmt.all();
|
|
626
|
+
const contacts = rows.map((row) => ({
|
|
627
|
+
username: row.username || '',
|
|
628
|
+
displayName: row.username || '',
|
|
629
|
+
}));
|
|
630
|
+
return { success: true, contacts };
|
|
631
|
+
}
|
|
632
|
+
catch (e) {
|
|
633
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
634
|
+
return { success: false, error: `获取联系人失败: ${msg}` };
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
/**
|
|
638
|
+
* 验证密钥是否正确 (使用第一页 HMAC)
|
|
639
|
+
*/
|
|
640
|
+
static verifyKey(keyHex, dbPath) {
|
|
641
|
+
try {
|
|
642
|
+
const password = Buffer.from(keyHex, 'hex');
|
|
643
|
+
const blist = readFileSync(dbPath);
|
|
644
|
+
if (blist.length < PAGE_SIZE)
|
|
645
|
+
return false;
|
|
646
|
+
const salt = blist.subarray(0, 16);
|
|
647
|
+
// first = blist[16:4096] = 4080 bytes (第一页跳过salt)
|
|
648
|
+
const first = blist.subarray(16, PAGE_SIZE);
|
|
649
|
+
// 派生页面密钥
|
|
650
|
+
const byteHmac = crypto.pbkdf2Sync(password, salt, DEFAULT_ITER, KEY_SIZE, 'sha1');
|
|
651
|
+
// 派生 HMAC 密钥: mac_salt = salt ^ 0x3A
|
|
652
|
+
const macSalt = Buffer.alloc(16);
|
|
653
|
+
for (let i = 0; i < 16; i++) {
|
|
654
|
+
macSalt[i] = salt[i] ^ 58;
|
|
655
|
+
}
|
|
656
|
+
const macKey = crypto.pbkdf2Sync(byteHmac, macSalt, 2, KEY_SIZE, 'sha1');
|
|
657
|
+
// 计算 HMAC: hmac(macKey, data[16:4064] + page_number)
|
|
658
|
+
// data[16:4064] = 4048 bytes (加密数据区)
|
|
659
|
+
const hmac = crypto.createHmac('sha1', macKey);
|
|
660
|
+
hmac.update(blist.subarray(16, PAGE_SIZE - 32)); // bytes 16-4063 = 4048 bytes
|
|
661
|
+
hmac.update(Buffer.from([1, 0, 0, 0])); // page 1, little-endian
|
|
662
|
+
// first[-32:-12] = HMAC at position 4048-4067 within first = file bytes 4064-4083
|
|
663
|
+
const expected = first.subarray(first.length - 32, first.length - 12);
|
|
664
|
+
return hmac.digest().equals(expected);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
return false;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
/**
|
|
671
|
+
* 关闭数据库并清理临时文件
|
|
672
|
+
*/
|
|
673
|
+
close() {
|
|
674
|
+
if (this.db) {
|
|
675
|
+
try {
|
|
676
|
+
this.db.close();
|
|
677
|
+
}
|
|
678
|
+
catch { }
|
|
679
|
+
this.db = null;
|
|
680
|
+
}
|
|
681
|
+
for (const tempPath of this.tempFiles.values()) {
|
|
682
|
+
try {
|
|
683
|
+
if (existsSync(tempPath))
|
|
684
|
+
unlinkSync(tempPath);
|
|
685
|
+
}
|
|
686
|
+
catch { }
|
|
687
|
+
}
|
|
688
|
+
this.tempFiles.clear();
|
|
689
|
+
this.pageKey = null;
|
|
690
|
+
this.opened = false;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
//# sourceMappingURL=sqlcipherCore.js.map
|