@chainlesschain/personal-data-hub 0.4.1 → 0.4.3
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/__tests__/adapter-guide.test.js +47 -0
- package/__tests__/adapters/local-im-pc.test.js +7 -2
- package/__tests__/adapters/pc-local-discovery.test.js +141 -0
- package/__tests__/adapters/qq-pc-direct-read.test.js +43 -2
- package/__tests__/adapters/social-douyin-adb-db-extension.test.js +114 -0
- package/__tests__/adapters/social-weibo-adb-cookies-extension.test.js +167 -0
- package/__tests__/adapters/wechat-pc-direct-read.test.js +160 -2
- package/__tests__/adapters/wechat-pc-v4-sidecar.test.js +72 -0
- package/__tests__/registry-readiness.test.js +59 -0
- package/lib/adapter-guide.js +54 -19
- package/lib/adapter-readiness.js +23 -0
- package/lib/adapters/_local-im-pc-adapter.js +34 -5
- package/lib/adapters/_pc-local-discovery.js +362 -0
- package/lib/adapters/qq-pc/index.js +118 -8
- package/lib/adapters/qq-pc/qqnt-sidecar.js +109 -0
- package/lib/adapters/social-douyin-adb/db-extension.js +66 -4
- package/lib/adapters/social-weibo-adb/cookies-extension.js +33 -6
- package/lib/adapters/wechat-pc/index.js +182 -8
- package/lib/adapters/wechat-pc/v4-sidecar.js +112 -0
- package/lib/registry.js +78 -2
- package/package.json +1 -1
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PC local-data auto-discovery — find a desktop App's local SQLite database(s)
|
|
5
|
+
* on the host WITHOUT the user having to type a path.
|
|
6
|
+
*
|
|
7
|
+
* Why this exists: every device-pull desktop IM adapter (wechat-pc / qq-pc /
|
|
8
|
+
* dingtalk-pc / feishu-pc) used to require an explicit `opts.dbPath`. With no
|
|
9
|
+
* path the readiness probe reported `DB_NOT_PULLED` and the UI offered no way
|
|
10
|
+
* to start — so "一键采集" was impossible for these sources even though the
|
|
11
|
+
* database sits at a well-known location on the very machine running cc.
|
|
12
|
+
*
|
|
13
|
+
* This module scans the known default install / data directories for each
|
|
14
|
+
* supported App and returns the discovered database files, grouped by the
|
|
15
|
+
* login account, with an `encrypted` flag and the *primary* message DB picked
|
|
16
|
+
* out. Adapters call it from `authenticate()` (readiness) and `sync()` (auto
|
|
17
|
+
* path) so the common case becomes truly one-click: install the App, log in,
|
|
18
|
+
* click 采集.
|
|
19
|
+
*
|
|
20
|
+
* Layout knowledge is version-aware where it matters:
|
|
21
|
+
* - WeChat 4.x: ~/Documents/xwechat_files/<wxid>_<n>/db_storage/...
|
|
22
|
+
* - WeChat 3.x: ~/Documents/WeChat Files/<wxid>/Msg/...
|
|
23
|
+
* - QQ NT: ~/Documents/Tencent Files/<uin>/nt_qq/nt_db/nt_msg.db
|
|
24
|
+
* - DingTalk: %APPDATA%/DingTalk/**, ~/Documents/DingTalk/** (best-effort)
|
|
25
|
+
* - Feishu/Lark: %APPDATA%/Feishu | Lark /** (best-effort)
|
|
26
|
+
*
|
|
27
|
+
* Pure + dependency-injectable: pass { fs, path, platform, home, env } so the
|
|
28
|
+
* whole thing is unit-testable against a synthetic filesystem with no real
|
|
29
|
+
* Apps installed. Defaults read the real host.
|
|
30
|
+
*
|
|
31
|
+
* Everything is best-effort and defensive: a missing directory, a permission
|
|
32
|
+
* error, or an unexpected layout yields `{ installed: false }` rather than
|
|
33
|
+
* throwing — discovery must never break a readiness probe.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
const nodeFs = require("node:fs");
|
|
37
|
+
const nodePath = require("node:path");
|
|
38
|
+
const nodeOs = require("node:os");
|
|
39
|
+
|
|
40
|
+
/** App keys this module knows how to discover. */
|
|
41
|
+
const SUPPORTED_APPS = Object.freeze([
|
|
42
|
+
"wechat-pc",
|
|
43
|
+
"qq-pc",
|
|
44
|
+
"dingtalk-pc",
|
|
45
|
+
"feishu-pc",
|
|
46
|
+
]);
|
|
47
|
+
|
|
48
|
+
// ─── injectable host accessors ─────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
function makeDeps(deps = {}) {
|
|
51
|
+
const fs = deps.fs || nodeFs;
|
|
52
|
+
const path = deps.path || nodePath;
|
|
53
|
+
const platform = deps.platform || process.platform;
|
|
54
|
+
const home =
|
|
55
|
+
deps.home || (deps.os && deps.os.homedir ? deps.os.homedir() : nodeOs.homedir());
|
|
56
|
+
const env = deps.env || process.env;
|
|
57
|
+
return { fs, path, platform, home, env };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function safeReaddir(fs, dir) {
|
|
61
|
+
try {
|
|
62
|
+
return fs.readdirSync(dir, { withFileTypes: true });
|
|
63
|
+
} catch (_e) {
|
|
64
|
+
return [];
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function safeExists(fs, p) {
|
|
69
|
+
try {
|
|
70
|
+
return fs.existsSync(p);
|
|
71
|
+
} catch (_e) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function safeSize(fs, p) {
|
|
77
|
+
try {
|
|
78
|
+
return fs.statSync(p).size;
|
|
79
|
+
} catch (_e) {
|
|
80
|
+
return 0;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** List immediate subdirectory names of `dir` (empty array on any error). */
|
|
85
|
+
function listSubdirs(fs, path, dir) {
|
|
86
|
+
return safeReaddir(fs, dir)
|
|
87
|
+
.filter((e) => e.isDirectory())
|
|
88
|
+
.map((e) => e.name);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Recursively collect *.db / *.sqlite / *.sqlite3 files under `root` up to
|
|
93
|
+
* `maxDepth`. Used for best-effort Apps (DingTalk / Feishu) whose internal
|
|
94
|
+
* layout is proprietary and version-volatile. Bounded so a huge tree can't
|
|
95
|
+
* stall a readiness probe.
|
|
96
|
+
*/
|
|
97
|
+
function collectDbFiles(fs, path, root, maxDepth, out, depth) {
|
|
98
|
+
if (depth > maxDepth) return out;
|
|
99
|
+
for (const e of safeReaddir(fs, root)) {
|
|
100
|
+
const fp = path.join(root, e.name);
|
|
101
|
+
if (e.isFile() && /\.(db|sqlite|sqlite3)$/i.test(e.name)) {
|
|
102
|
+
out.push(fp);
|
|
103
|
+
} else if (e.isDirectory()) {
|
|
104
|
+
collectDbFiles(fs, path, fp, maxDepth, out, depth + 1);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ─── per-App discovery ─────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* WeChat desktop — handles BOTH the 4.x (`xwechat_files`) and 3.x
|
|
114
|
+
* (`WeChat Files`) layouts. Both store SQLCipher-encrypted DBs.
|
|
115
|
+
*/
|
|
116
|
+
function discoverWeChat({ fs, path, home, env }) {
|
|
117
|
+
const accounts = [];
|
|
118
|
+
let layout = null;
|
|
119
|
+
|
|
120
|
+
// ── 4.x: ~/Documents/xwechat_files/<wxid>_<n>/db_storage/ ──
|
|
121
|
+
const v4Root = path.join(home, "Documents", "xwechat_files");
|
|
122
|
+
if (safeExists(fs, v4Root)) {
|
|
123
|
+
for (const name of listSubdirs(fs, path, v4Root)) {
|
|
124
|
+
// account dirs look like wxid_xxx_1234 ; skip all_users / login etc.
|
|
125
|
+
if (!/^wxid_/.test(name)) continue;
|
|
126
|
+
const storage = path.join(v4Root, name, "db_storage");
|
|
127
|
+
if (!safeExists(fs, storage)) continue;
|
|
128
|
+
const dbs = [];
|
|
129
|
+
const msgDir = path.join(storage, "message");
|
|
130
|
+
for (const e of safeReaddir(fs, msgDir)) {
|
|
131
|
+
if (e.isFile() && /^message_\d+\.db$/i.test(e.name)) {
|
|
132
|
+
dbs.push(mkDb(path.join(msgDir, e.name), "message", "私聊/群聊消息", true, fs));
|
|
133
|
+
}
|
|
134
|
+
if (e.isFile() && /^biz_message_\d+\.db$/i.test(e.name)) {
|
|
135
|
+
dbs.push(mkDb(path.join(msgDir, e.name), "biz-message", "公众号消息", true, fs));
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const contactDb = path.join(storage, "contact", "contact.db");
|
|
139
|
+
if (safeExists(fs, contactDb)) {
|
|
140
|
+
dbs.push(mkDb(contactDb, "contact", "联系人", true, fs));
|
|
141
|
+
}
|
|
142
|
+
const snsDb = path.join(storage, "sns", "sns.db");
|
|
143
|
+
if (safeExists(fs, snsDb)) dbs.push(mkDb(snsDb, "sns", "朋友圈", true, fs));
|
|
144
|
+
const favDb = path.join(storage, "favorite", "favorite.db");
|
|
145
|
+
if (safeExists(fs, favDb)) dbs.push(mkDb(favDb, "favorite", "收藏", true, fs));
|
|
146
|
+
if (dbs.length > 0) {
|
|
147
|
+
layout = "4.x";
|
|
148
|
+
accounts.push({ id: name.replace(/_\d+$/, ""), root: path.join(v4Root, name), dbs });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── 3.x: ~/Documents/WeChat Files/<wxid>/Msg/ ──
|
|
154
|
+
const v3Roots = [
|
|
155
|
+
path.join(home, "Documents", "WeChat Files"),
|
|
156
|
+
env.APPDATA ? path.join(env.APPDATA, "Tencent", "WeChat", "WeChat Files") : null,
|
|
157
|
+
].filter(Boolean);
|
|
158
|
+
for (const root of v3Roots) {
|
|
159
|
+
if (!safeExists(fs, root)) continue;
|
|
160
|
+
for (const name of listSubdirs(fs, path, root)) {
|
|
161
|
+
if (name === "All Users" || name === "Applet" || name === "WMPF") continue;
|
|
162
|
+
const msgDir = path.join(root, name, "Msg");
|
|
163
|
+
const multiDir = path.join(msgDir, "Multi");
|
|
164
|
+
const dbs = [];
|
|
165
|
+
for (const e of safeReaddir(fs, multiDir)) {
|
|
166
|
+
if (e.isFile() && /^MSG\d+\.db$/i.test(e.name)) {
|
|
167
|
+
dbs.push(mkDb(path.join(multiDir, e.name), "message", "私聊/群聊消息", true, fs));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
const microMsg = path.join(msgDir, "MicroMsg.db");
|
|
171
|
+
if (safeExists(fs, microMsg)) {
|
|
172
|
+
dbs.push(mkDb(microMsg, "contact", "联系人", true, fs));
|
|
173
|
+
}
|
|
174
|
+
if (dbs.length > 0) {
|
|
175
|
+
layout = layout || "3.x";
|
|
176
|
+
accounts.push({ id: name, root: path.join(root, name), dbs });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return finalize("wechat-pc", accounts, {
|
|
182
|
+
layout,
|
|
183
|
+
encryptedNote:
|
|
184
|
+
"微信本地库为 SQLCipher 加密,需提取数据库密钥后才能解密读取(见 wechat 密钥提取)",
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** QQ NT desktop — ~/Documents/Tencent Files/<uin>/nt_qq/nt_db/nt_msg.db */
|
|
189
|
+
function discoverQQ({ fs, path, home, env }) {
|
|
190
|
+
const accounts = [];
|
|
191
|
+
const roots = [
|
|
192
|
+
path.join(home, "Documents", "Tencent Files"),
|
|
193
|
+
env.APPDATA ? path.join(env.APPDATA, "Tencent", "QQ") : null,
|
|
194
|
+
].filter(Boolean);
|
|
195
|
+
for (const root of roots) {
|
|
196
|
+
if (!safeExists(fs, root)) continue;
|
|
197
|
+
for (const name of listSubdirs(fs, path, root)) {
|
|
198
|
+
// per-uin dirs are all-digit; skip nt_qq (global) etc.
|
|
199
|
+
if (!/^\d+$/.test(name)) continue;
|
|
200
|
+
const ntDb = path.join(root, name, "nt_qq", "nt_db", "nt_msg.db");
|
|
201
|
+
if (safeExists(fs, ntDb)) {
|
|
202
|
+
const dbs = [mkDb(ntDb, "message", "私聊/群聊消息", true, fs)];
|
|
203
|
+
const grpInfo = path.join(root, name, "nt_qq", "nt_db", "group_info.db");
|
|
204
|
+
if (safeExists(fs, grpInfo)) dbs.push(mkDb(grpInfo, "group-info", "群信息", true, fs));
|
|
205
|
+
const profile = path.join(root, name, "nt_qq", "nt_db", "profile_info.db");
|
|
206
|
+
if (safeExists(fs, profile)) dbs.push(mkDb(profile, "profile", "好友资料", true, fs));
|
|
207
|
+
accounts.push({ id: name, root: path.join(root, name, "nt_qq"), dbs });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return finalize("qq-pc", accounts, {
|
|
212
|
+
encryptedNote:
|
|
213
|
+
"QQ NT 本地库为 SQLCipher 加密(数字混淆列 + protobuf 消息体),需密钥解密",
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** DingTalk / Feishu — proprietary layout, best-effort recursive scan. */
|
|
218
|
+
function discoverGenericIm(appName, roots, { fs, path }) {
|
|
219
|
+
const accounts = [];
|
|
220
|
+
for (const root of roots) {
|
|
221
|
+
if (!safeExists(fs, root)) continue;
|
|
222
|
+
const files = collectDbFiles(fs, path, root, 5, [], 0);
|
|
223
|
+
// Heuristic: message DBs are the larger files whose name hints at chat.
|
|
224
|
+
const dbs = files
|
|
225
|
+
.filter((fp) => !/\b(fts|index|cache|emoji|emoticon|head_image)\b/i.test(fp))
|
|
226
|
+
.map((fp) => {
|
|
227
|
+
const base = path.basename(fp);
|
|
228
|
+
const isMsg = /msg|message|chat|conversation|im_/i.test(base);
|
|
229
|
+
return mkDb(fp, isMsg ? "message" : "other", base, /* encrypted */ false, fs);
|
|
230
|
+
})
|
|
231
|
+
.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
232
|
+
if (dbs.length > 0) {
|
|
233
|
+
accounts.push({ id: "default", root, dbs });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return finalize(appName, accounts, {
|
|
237
|
+
encryptedNote:
|
|
238
|
+
"桌面本地库为私有结构、可能加密、随版本变化;优先尝试明文直读,必要时先解密",
|
|
239
|
+
bestEffort: true,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function discoverDingTalk({ fs, path, home, env }) {
|
|
244
|
+
const roots = [
|
|
245
|
+
env.APPDATA ? path.join(env.APPDATA, "DingTalk") : null,
|
|
246
|
+
path.join(home, "Documents", "DingTalk"),
|
|
247
|
+
].filter(Boolean);
|
|
248
|
+
return discoverGenericIm("dingtalk-pc", roots, { fs, path });
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function discoverFeishu({ fs, path, home, env }) {
|
|
252
|
+
const roots = [
|
|
253
|
+
env.APPDATA ? path.join(env.APPDATA, "Feishu") : null,
|
|
254
|
+
env.APPDATA ? path.join(env.APPDATA, "Lark") : null,
|
|
255
|
+
].filter(Boolean);
|
|
256
|
+
return discoverGenericIm("feishu-pc", roots, { fs, path });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ─── helpers ───────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
function mkDb(p, purpose, label, encrypted, fs) {
|
|
262
|
+
return {
|
|
263
|
+
path: p,
|
|
264
|
+
purpose, // message | biz-message | contact | sns | favorite | group-info | profile | other
|
|
265
|
+
label,
|
|
266
|
+
encrypted: !!encrypted,
|
|
267
|
+
sizeBytes: safeSize(fs, p),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Common tail: pick the primary message DB (largest message-purpose file
|
|
273
|
+
* across all accounts) and compute the installed flag + summary.
|
|
274
|
+
*/
|
|
275
|
+
function finalize(app, accounts, extra = {}) {
|
|
276
|
+
const allDbs = accounts.flatMap((a) => a.dbs);
|
|
277
|
+
const messageDbs = allDbs
|
|
278
|
+
.filter((d) => d.purpose === "message")
|
|
279
|
+
.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
280
|
+
const primaryDb = (messageDbs[0] || allDbs[0] || null);
|
|
281
|
+
const anyEncrypted = allDbs.some((d) => d.encrypted);
|
|
282
|
+
return {
|
|
283
|
+
app,
|
|
284
|
+
installed: accounts.length > 0,
|
|
285
|
+
accounts,
|
|
286
|
+
primaryDb: primaryDb ? primaryDb.path : null,
|
|
287
|
+
primaryDbInfo: primaryDb,
|
|
288
|
+
encrypted: anyEncrypted,
|
|
289
|
+
dbCount: allDbs.length,
|
|
290
|
+
layout: extra.layout || null,
|
|
291
|
+
bestEffort: !!extra.bestEffort,
|
|
292
|
+
note: accounts.length > 0 ? extra.encryptedNote || null : "未检测到本地数据(可能未安装或未登录)",
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const DISCOVERERS = Object.freeze({
|
|
297
|
+
"wechat-pc": discoverWeChat,
|
|
298
|
+
"qq-pc": discoverQQ,
|
|
299
|
+
"dingtalk-pc": discoverDingTalk,
|
|
300
|
+
"feishu-pc": discoverFeishu,
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Discover one App's local databases.
|
|
305
|
+
*
|
|
306
|
+
* @param {string} appKey one of SUPPORTED_APPS
|
|
307
|
+
* @param {object} [deps] { fs, path, platform, home, env } — inject for tests
|
|
308
|
+
* @returns {DiscoveryResult}
|
|
309
|
+
*
|
|
310
|
+
* @typedef {object} DiscoveryResult
|
|
311
|
+
* @property {string} app
|
|
312
|
+
* @property {boolean} installed any account+DB found?
|
|
313
|
+
* @property {Array} accounts [{ id, root, dbs: [{path,purpose,label,encrypted,sizeBytes}] }]
|
|
314
|
+
* @property {string|null} primaryDb best message DB path to point sync at
|
|
315
|
+
* @property {object|null} primaryDbInfo
|
|
316
|
+
* @property {boolean} encrypted any discovered DB is encrypted?
|
|
317
|
+
* @property {number} dbCount
|
|
318
|
+
* @property {string|null} layout version layout hint (wechat: "4.x"/"3.x")
|
|
319
|
+
* @property {boolean} bestEffort heuristic discovery (dingtalk/feishu)?
|
|
320
|
+
* @property {string|null} note
|
|
321
|
+
*/
|
|
322
|
+
function discover(appKey, deps = {}) {
|
|
323
|
+
const fn = DISCOVERERS[appKey];
|
|
324
|
+
if (!fn) {
|
|
325
|
+
return {
|
|
326
|
+
app: appKey,
|
|
327
|
+
installed: false,
|
|
328
|
+
accounts: [],
|
|
329
|
+
primaryDb: null,
|
|
330
|
+
primaryDbInfo: null,
|
|
331
|
+
encrypted: false,
|
|
332
|
+
dbCount: 0,
|
|
333
|
+
layout: null,
|
|
334
|
+
bestEffort: false,
|
|
335
|
+
note: `不支持的 App: ${appKey}`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
try {
|
|
339
|
+
return fn(makeDeps(deps));
|
|
340
|
+
} catch (err) {
|
|
341
|
+
// Discovery must never throw into a readiness probe.
|
|
342
|
+
return {
|
|
343
|
+
app: appKey,
|
|
344
|
+
installed: false,
|
|
345
|
+
accounts: [],
|
|
346
|
+
primaryDb: null,
|
|
347
|
+
primaryDbInfo: null,
|
|
348
|
+
encrypted: false,
|
|
349
|
+
dbCount: 0,
|
|
350
|
+
layout: null,
|
|
351
|
+
bestEffort: false,
|
|
352
|
+
note: `自动发现出错:${err && err.message ? err.message : String(err)}`,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
module.exports = {
|
|
358
|
+
discover,
|
|
359
|
+
SUPPORTED_APPS,
|
|
360
|
+
// exported for unit tests
|
|
361
|
+
_internals: { collectDbFiles, finalize, mkDb },
|
|
362
|
+
};
|
|
@@ -37,6 +37,9 @@ class QQPcAdapter {
|
|
|
37
37
|
constructor(opts = {}) {
|
|
38
38
|
this._dbPath = opts.dbPath || null;
|
|
39
39
|
this._key = opts.key || null;
|
|
40
|
+
// QQ NT passphrase (16-char ASCII from qq-win-db-key). When present, sync
|
|
41
|
+
// routes through the Python sidecar (decrypt + protobuf parse).
|
|
42
|
+
this._passphrase = opts.passphrase || null;
|
|
40
43
|
|
|
41
44
|
this.name = NAME;
|
|
42
45
|
this.version = VERSION;
|
|
@@ -58,20 +61,53 @@ class QQPcAdapter {
|
|
|
58
61
|
this._deps = {
|
|
59
62
|
fs,
|
|
60
63
|
dbDriverFactory: opts.dbDriverFactory || null,
|
|
64
|
+
// DI seam: tests inject a fake QQ sidecar collector; default lazy-loads
|
|
65
|
+
// the forensics-bridge invoker.
|
|
66
|
+
qqCollector: opts.qqCollector || null,
|
|
67
|
+
discoveryDeps: opts.discoveryDeps || undefined,
|
|
61
68
|
};
|
|
62
69
|
}
|
|
63
70
|
|
|
71
|
+
_autoDiscover() {
|
|
72
|
+
if (this._discovered !== undefined) return this._discovered;
|
|
73
|
+
try {
|
|
74
|
+
// eslint-disable-next-line global-require
|
|
75
|
+
const { discover } = require("../_pc-local-discovery");
|
|
76
|
+
this._discovered = discover("qq-pc", this._deps.discoveryDeps || {});
|
|
77
|
+
} catch (_e) {
|
|
78
|
+
this._discovered = null;
|
|
79
|
+
}
|
|
80
|
+
return this._discovered;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
_resolveDiscoveredDbPath() {
|
|
84
|
+
const disc = this._autoDiscover();
|
|
85
|
+
return disc && disc.installed && disc.primaryDb ? disc.primaryDb : null;
|
|
86
|
+
}
|
|
87
|
+
|
|
64
88
|
async authenticate(ctx = {}) {
|
|
65
89
|
if (ctx && ctx.readinessOnly) {
|
|
66
90
|
if (this._dbPath) return { ok: true, mode: "configured" };
|
|
91
|
+
const disc = this._autoDiscover();
|
|
92
|
+
if (disc && disc.installed) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "DB_FOUND_NEEDS_KEY",
|
|
96
|
+
message: `已找到本机 QQ 库(${disc.accounts.length} 个账号,主库 ${disc.primaryDb})`,
|
|
97
|
+
discovered: disc,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
67
100
|
return {
|
|
68
101
|
ok: false,
|
|
69
|
-
reason: "
|
|
70
|
-
message:
|
|
71
|
-
"qq-pc: 需提供电脑版 QQ 的 nt_msg.db 路径(加密库需先解密或提供 key)",
|
|
102
|
+
reason: "APP_NOT_INSTALLED",
|
|
103
|
+
message: (disc && disc.note) || "未检测到本机 QQ NT 数据(可能未安装或未登录)",
|
|
72
104
|
};
|
|
73
105
|
}
|
|
74
|
-
const dbPath =
|
|
106
|
+
const dbPath =
|
|
107
|
+
(ctx && ctx.inputPath) ||
|
|
108
|
+
(ctx && ctx.dbPath) ||
|
|
109
|
+
this._dbPath ||
|
|
110
|
+
this._resolveDiscoveredDbPath();
|
|
75
111
|
if (dbPath) {
|
|
76
112
|
try {
|
|
77
113
|
this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
|
|
@@ -84,10 +120,19 @@ class QQPcAdapter {
|
|
|
84
120
|
}
|
|
85
121
|
return { ok: true, mode: "sqlite" };
|
|
86
122
|
}
|
|
123
|
+
const disc = this._autoDiscover();
|
|
124
|
+
if (disc && disc.installed) {
|
|
125
|
+
return {
|
|
126
|
+
ok: false,
|
|
127
|
+
reason: "DB_FOUND_NEEDS_KEY",
|
|
128
|
+
message: `已找到本机 QQ 库(主库 ${disc.primaryDb}),需解密密钥`,
|
|
129
|
+
discovered: disc,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
87
132
|
return {
|
|
88
133
|
ok: false,
|
|
89
|
-
reason: "
|
|
90
|
-
message: "qq-pc.authenticate:
|
|
134
|
+
reason: "APP_NOT_INSTALLED",
|
|
135
|
+
message: "qq-pc.authenticate: 未检测到本机 QQ NT 库,也未提供 dbPath / inputPath",
|
|
91
136
|
};
|
|
92
137
|
}
|
|
93
138
|
|
|
@@ -96,9 +141,18 @@ class QQPcAdapter {
|
|
|
96
141
|
}
|
|
97
142
|
|
|
98
143
|
async *sync(opts = {}) {
|
|
99
|
-
|
|
144
|
+
// Sidecar path: with a QQ NT passphrase (from qq-win-db-key), decrypt +
|
|
145
|
+
// parse the encrypted nt_msg.db in Python and yield readable messages.
|
|
146
|
+
const passphrase = opts.passphrase || this._passphrase || null;
|
|
147
|
+
if (passphrase || opts.mode === "sidecar") {
|
|
148
|
+
yield* this._syncViaSidecar(opts, passphrase);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const dbPath =
|
|
153
|
+
opts.dbPath || opts.inputPath || this._dbPath || this._resolveDiscoveredDbPath();
|
|
100
154
|
if (!dbPath) {
|
|
101
|
-
throw new Error("qq-pc.sync:
|
|
155
|
+
throw new Error("qq-pc.sync: 未找到本机 QQ NT 库且未提供 opts.dbPath / opts.inputPath(或提供 opts.passphrase 走 sidecar 解密)");
|
|
102
156
|
}
|
|
103
157
|
if (!this._deps.fs.existsSync(dbPath)) return;
|
|
104
158
|
|
|
@@ -139,6 +193,60 @@ class QQPcAdapter {
|
|
|
139
193
|
}
|
|
140
194
|
}
|
|
141
195
|
|
|
196
|
+
// Sidecar path: forensics-bridge qq_nt.collect decrypts nt_msg.db (with the
|
|
197
|
+
// qq-win-db-key passphrase) + parses c2c/group protobuf bodies → readable
|
|
198
|
+
// messages, which we map into the same payload normalizeMessage consumes.
|
|
199
|
+
async *_syncViaSidecar(opts = {}, passphrase) {
|
|
200
|
+
let collect = this._deps.qqCollector;
|
|
201
|
+
if (!collect) {
|
|
202
|
+
// eslint-disable-next-line global-require
|
|
203
|
+
collect = require("./qqnt-sidecar").collectQqNt;
|
|
204
|
+
}
|
|
205
|
+
const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : undefined;
|
|
206
|
+
const result = await collect({
|
|
207
|
+
passphrase,
|
|
208
|
+
key: opts.key || this._key || undefined,
|
|
209
|
+
dbPath: opts.dbPath || this._dbPath || this._resolveDiscoveredDbPath() || undefined,
|
|
210
|
+
limit,
|
|
211
|
+
pythonExe: opts.pythonExe,
|
|
212
|
+
bridgeDir: opts.bridgeDir,
|
|
213
|
+
timeoutMs: opts.timeoutMs,
|
|
214
|
+
onProgress:
|
|
215
|
+
typeof opts.onProgress === "function"
|
|
216
|
+
? (m) => { try { opts.onProgress({ phase: "qq-nt", adapter: NAME, ...m }); } catch (_e) { /* best-effort */ } }
|
|
217
|
+
: undefined,
|
|
218
|
+
_supervisorFactory: opts._supervisorFactory,
|
|
219
|
+
});
|
|
220
|
+
const messages = (result && Array.isArray(result.messages)) ? result.messages : [];
|
|
221
|
+
const fallbackCapturedAt = Date.now();
|
|
222
|
+
let emitted = 0;
|
|
223
|
+
for (const m of messages) {
|
|
224
|
+
if (!m || typeof m !== "object") continue;
|
|
225
|
+
const isGroup = m.kind === "group";
|
|
226
|
+
const createdTimeMs =
|
|
227
|
+
typeof m.createTime === "number" && m.createTime > 0 ? m.createTime * 1000 : null;
|
|
228
|
+
const payload = {
|
|
229
|
+
kind: KIND_MESSAGE,
|
|
230
|
+
text: typeof m.text === "string" ? m.text : "",
|
|
231
|
+
peerUin: m.peer != null ? String(m.peer) : null,
|
|
232
|
+
peerName: m.conversationName || null, // group name / c2c peer nickname (best-effort)
|
|
233
|
+
senderUin: m.senderUin != null ? String(m.senderUin) : null,
|
|
234
|
+
senderName: m.senderName || null,
|
|
235
|
+
isGroup,
|
|
236
|
+
type: typeof m.type === "number" ? m.type : null,
|
|
237
|
+
createdTimeMs,
|
|
238
|
+
};
|
|
239
|
+
yield {
|
|
240
|
+
adapter: NAME,
|
|
241
|
+
kind: KIND_MESSAGE,
|
|
242
|
+
originalId: m.originalId || stableOriginalId(`${m.peer}-${createdTimeMs}-${emitted}`),
|
|
243
|
+
capturedAt: createdTimeMs || fallbackCapturedAt,
|
|
244
|
+
payload,
|
|
245
|
+
};
|
|
246
|
+
emitted += 1;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
142
250
|
normalize(raw) {
|
|
143
251
|
if (!raw || !raw.payload) {
|
|
144
252
|
throw new Error("QQPcAdapter.normalize: payload missing");
|
|
@@ -176,7 +284,9 @@ class QQPcAdapter {
|
|
|
176
284
|
platform: "qq",
|
|
177
285
|
source: "pc-nt",
|
|
178
286
|
peerUin: p.peerUin || null,
|
|
287
|
+
...(p.peerName ? { peerName: p.peerName } : {}),
|
|
179
288
|
senderUin: p.senderUin || null,
|
|
289
|
+
...(p.senderName ? { senderName: p.senderName } : {}),
|
|
180
290
|
isGroup: !!p.isGroup,
|
|
181
291
|
qqMsgType: typeof p.type === "number" ? p.type : null,
|
|
182
292
|
// Full raw row preserved — protobuf bodies + unknown columns — so a
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* QQ NT collection bridge — invokes the forensics-bridge Python sidecar's
|
|
5
|
+
* `qq_nt.collect` method (skip 1024-byte preamble + SQLCipher-4 decrypt with
|
|
6
|
+
* the qq-win-db-key passphrase + parse c2c/group protobuf message bodies) and
|
|
7
|
+
* returns the decrypted, readable messages to the node adapter.
|
|
8
|
+
*
|
|
9
|
+
* The key is the QQ NT passphrase (a 16-char ASCII string like "5{sww#,6aq=)8=A@"
|
|
10
|
+
* extracted by qq-win-db-key). Pass it as opts.passphrase. Decryption + protobuf
|
|
11
|
+
* text extraction run in Python (cryptography), sidestepping the host-node
|
|
12
|
+
* bs3mc ABI problem (node never opens the encrypted DB).
|
|
13
|
+
*
|
|
14
|
+
* Resolution (overridable for tests / packaging):
|
|
15
|
+
* - python exe: opts.pythonExe → env CC_PDH_PYTHON → "python" / "python3"
|
|
16
|
+
* - bridge dir: opts.bridgeDir → env CC_PDH_BRIDGE_DIR → sibling package
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const path = require("node:path");
|
|
20
|
+
const { existsSync } = require("node:fs");
|
|
21
|
+
|
|
22
|
+
function resolveBridgeDir(explicit) {
|
|
23
|
+
if (explicit) return explicit;
|
|
24
|
+
if (process.env.CC_PDH_BRIDGE_DIR) return process.env.CC_PDH_BRIDGE_DIR;
|
|
25
|
+
// lib/adapters/qq-pc → up to packages/, then sibling bridge package.
|
|
26
|
+
return path.resolve(__dirname, "../../../../personal-data-hub-bridge");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function pythonCandidates(explicit) {
|
|
30
|
+
const list = [];
|
|
31
|
+
if (explicit) list.push(explicit);
|
|
32
|
+
if (process.env.CC_PDH_PYTHON) list.push(process.env.CC_PDH_PYTHON);
|
|
33
|
+
list.push(process.platform === "win32" ? "python" : "python3");
|
|
34
|
+
list.push(process.platform === "win32" ? "python3" : "python");
|
|
35
|
+
return [...new Set(list)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* @param {object} [opts]
|
|
40
|
+
* @param {string} [opts.passphrase] QQ NT key (ASCII passphrase from qq-win-db-key)
|
|
41
|
+
* @param {string} [opts.key] alternatively a hex key
|
|
42
|
+
* @param {string} [opts.dbPath] nt_msg.db path (sidecar auto-discovers if omitted)
|
|
43
|
+
* @param {number} [opts.limit]
|
|
44
|
+
* @param {string} [opts.pythonExe]
|
|
45
|
+
* @param {string} [opts.bridgeDir]
|
|
46
|
+
* @param {number} [opts.timeoutMs]
|
|
47
|
+
* @param {(msg:object)=>void} [opts.onProgress]
|
|
48
|
+
* @param {object} [opts._supervisorFactory] test seam
|
|
49
|
+
* @returns {Promise<{account:string,messageCount:number,c2c:number,group:number,messages:object[]}>}
|
|
50
|
+
*/
|
|
51
|
+
async function collectQqNt(opts = {}) {
|
|
52
|
+
const bridgeDir = resolveBridgeDir(opts.bridgeDir);
|
|
53
|
+
const makeSupervisor =
|
|
54
|
+
opts._supervisorFactory ||
|
|
55
|
+
((command, cwd) => {
|
|
56
|
+
// eslint-disable-next-line global-require
|
|
57
|
+
const { SidecarSupervisor } = require("../../sidecar");
|
|
58
|
+
return new SidecarSupervisor({
|
|
59
|
+
command,
|
|
60
|
+
cwd,
|
|
61
|
+
defaultTimeoutMs: opts.timeoutMs || 120_000,
|
|
62
|
+
healthCheckIntervalMs: 0,
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
if (!opts._supervisorFactory && !existsSync(bridgeDir)) {
|
|
67
|
+
const e = new Error(
|
|
68
|
+
`qq-pc: forensics-bridge not found at ${bridgeDir} (set CC_PDH_BRIDGE_DIR)`,
|
|
69
|
+
);
|
|
70
|
+
e.code = "BRIDGE_NOT_FOUND";
|
|
71
|
+
throw e;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const params = {};
|
|
75
|
+
if (Number.isInteger(opts.limit) && opts.limit > 0) params.limit = opts.limit;
|
|
76
|
+
if (opts.passphrase) params.passphrase = opts.passphrase;
|
|
77
|
+
else if (opts.key) params.key = opts.key;
|
|
78
|
+
if (opts.dbPath) params.db_path = opts.dbPath;
|
|
79
|
+
|
|
80
|
+
let lastErr = null;
|
|
81
|
+
for (const py of pythonCandidates(opts.pythonExe)) {
|
|
82
|
+
const sup = makeSupervisor([py, "-m", "forensics_bridge.ipc_server"], bridgeDir);
|
|
83
|
+
try {
|
|
84
|
+
await sup.start({ readyTimeoutMs: opts.readyTimeoutMs || 15_000 });
|
|
85
|
+
const result = await sup.invoke("qq_nt.collect", params, {
|
|
86
|
+
timeoutMs: opts.timeoutMs || 120_000,
|
|
87
|
+
onProgress: opts.onProgress,
|
|
88
|
+
});
|
|
89
|
+
try { await sup.stop(); } catch (_e) { /* best-effort */ }
|
|
90
|
+
return result;
|
|
91
|
+
} catch (err) {
|
|
92
|
+
lastErr = err;
|
|
93
|
+
try { await sup.stop(); } catch (_e) { /* best-effort */ }
|
|
94
|
+
const msg = (err && err.message) || "";
|
|
95
|
+
// Real QQ-side failures (key/db) surface immediately; sidecar-availability
|
|
96
|
+
// problems (missing python / cryptography / spawn death) → try next python.
|
|
97
|
+
const isDataError = /KEY_REQUIRED|KEY_VERIFY|APP_NOT|DB_TOO|BAD_LAYOUT/i.test(msg);
|
|
98
|
+
if (isDataError) throw err;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const e = new Error(
|
|
102
|
+
`qq-pc: could not run forensics-bridge sidecar (tried ${pythonCandidates(opts.pythonExe).join(", ")}). ` +
|
|
103
|
+
`Install Python 3.11+ with 'cryptography', or set CC_PDH_PYTHON. Last error: ${lastErr && lastErr.message}`,
|
|
104
|
+
);
|
|
105
|
+
e.code = "SIDECAR_UNAVAILABLE";
|
|
106
|
+
throw e;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { collectQqNt, _internals: { resolveBridgeDir, pythonCandidates } };
|