@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.
@@ -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: "DB_NOT_PULLED",
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 = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._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: "DB_NOT_PULLED",
90
- message: "qq-pc.authenticate: needs opts.dbPath / inputPath (nt_msg.db)",
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
- const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
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: needs opts.dbPath / opts.inputPath (nt_msg.db)");
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 } };