@chainlesschain/personal-data-hub 0.4.2 → 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.
@@ -189,3 +189,39 @@ describe("QQPcAdapter — edge cases", () => {
189
189
  expect(() => a.normalize({ kind: "x", payload: { kind: "x" } })).toThrow(/unknown kind/);
190
190
  });
191
191
  });
192
+
193
+ describe("QQPcAdapter — QQ NT sidecar path (passphrase)", () => {
194
+ const fakeCollector = (result) => async (_opts) => result;
195
+
196
+ it("opts.passphrase routes through the sidecar collector and yields messages", async () => {
197
+ const a = new QQPcAdapter({
198
+ qqCollector: fakeCollector({
199
+ account: "896075341",
200
+ messageCount: 2,
201
+ c2c: 1,
202
+ group: 1,
203
+ messages: [
204
+ { kind: "group", peer: 88966001, peerUid: "u_x", senderUin: 38181604, senderName: "疯子", type: 0, createTime: 1780941580, text: "保持高贵的沉默。", originalId: "qq-pc:group:88966001:1" },
205
+ { kind: "c2c", peer: 2747277822, peerUid: "u_y", senderUin: 12345, senderName: "张三", type: 0, createTime: 1780900000, text: "在吗", originalId: "qq-pc:c2c:2747277822:2" },
206
+ ],
207
+ }),
208
+ });
209
+ const raws = await collect(a.sync({ passphrase: "5{sww#,6aq=)8=A@" }));
210
+ expect(raws).toHaveLength(2);
211
+ expect(raws[0].payload.text).toBe("保持高贵的沉默。");
212
+ expect(raws[0].payload.isGroup).toBe(true);
213
+ expect(raws[0].payload.senderName).toBe("疯子");
214
+ expect(raws[1].payload.isGroup).toBe(false);
215
+
216
+ const merged = { events: [], persons: [], places: [], items: [], topics: [] };
217
+ for (const r of raws) {
218
+ const n = a.normalize(r);
219
+ for (const k of Object.keys(merged)) if (Array.isArray(n[k])) merged[k].push(...n[k]);
220
+ }
221
+ const { valid } = partitionBatch(merged);
222
+ expect(valid.events.length).toBe(2);
223
+ const texts = valid.events.map((e) => e.content && e.content.text);
224
+ expect(texts).toContain("保持高贵的沉默。");
225
+ expect(valid.events.find((e) => e.content.text === "保持高贵的沉默。").extra.senderName).toBe("疯子");
226
+ });
227
+ });
@@ -346,22 +346,24 @@ const ADAPTER_OVERRIDES = Object.freeze({
346
346
 
347
347
  "qq-pc": {
348
348
  summary:
349
- "采集电脑版 QQ(NT 新版)的聊天记录(来自本地 nt_msg.db)。⚠️ v0.1 实验性:QQ NT 用数字化列名 + protobuf 消息体且 schema 随版本变化,文本解析为尽力而为,原始行会完整保留以便后续解析。",
349
+ "采集电脑版 QQ(NT 新版)的聊天记录(来自本地 nt_msg.db)。中台已支持自动解密 + 解析:取一次密钥后,自动解密 SQLCipher 库、解析 c2c/群消息的 protobuf 正文为可读文本(含发送者昵称、群号)。",
350
350
  methods: [
351
351
  {
352
- label: "方式一:解密 nt_msg.db 后本地直读(推荐)",
352
+ label: "方式一:取密钥后一键采集(推荐)",
353
353
  recommended: true,
354
354
  steps: [
355
- "登录电脑版 QQ,定位数据目录(默认 文档\\Tencent Files 或 %APPDATA%\\Tencent\\QQ\\nt_qq_*\\nt_db\\)。",
356
- "用工具(如 QQNTDecrypt / PyQQ 等)解密 nt_msg.db 为明文 SQLite。",
357
- "执行 `cc hub sync-adapter qq-pc --input <解密后的 nt_msg.db>`。",
358
- "中台读取 c2c_msg_table / group_msg_table 入库(文本尽力解析,原始数据保留)。",
355
+ "在电脑上打开并登录 QQ(NT 新版,数据在 文档\\Tencent Files\\<QQ号>\\nt_qq\\nt_db\\nt_msg.db)。",
356
+ "下载并运行 qq-win-db-key(github.com/QQBackup/qq-win-db-key windows_ntqq_get_key.ps1)。它会全关 QQ → 以调试器启动 QQ → 你登录后自动抓出 16 位密钥(形如 5{sww#,6aq=)8=A@)。",
357
+ "回到中台执行 `cc hub sync-adapter qq-pc --passphrase \"<那串密钥>\"`(或点该行「一键采集」并粘贴密钥)。",
358
+ "中台自动解密 + 解析 c2c_msg_table / group_msg_table → 可读消息入库(私聊 + 群聊,含昵称/群号)。",
359
359
  ],
360
- note: "QQ NT 消息正文为 protobuf BLOB,部分类型文本可能需后续在真机上微调列/解码。诊断会显示找到了哪些表/列。",
360
+ note: "QQ 每次重启密钥会变,重采时重新跑 qq-win-db-key 取一次即可。纯个人使用、全程本地;首次会要求法律确认。依赖随中台分发的 Python(含 cryptography)。",
361
361
  },
362
362
  {
363
- label: "方式二:提供密钥让中台尝试直接解密(试验性)",
364
- steps: ["附带 `--key <hex>`,中台用 SQLCipher 尝试打开;失败则回退方式一。"],
363
+ label: "方式二:已解密为明文库则直接导入",
364
+ steps: [
365
+ "若已用工具把 nt_msg.db 解密为明文 SQLite,执行 `cc hub sync-adapter qq-pc --input <明文 nt_msg.db>`。",
366
+ ],
365
367
  },
366
368
  ],
367
369
  },
@@ -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,6 +61,10 @@ 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
 
@@ -134,10 +141,18 @@ class QQPcAdapter {
134
141
  }
135
142
 
136
143
  async *sync(opts = {}) {
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
+
137
152
  const dbPath =
138
153
  opts.dbPath || opts.inputPath || this._dbPath || this._resolveDiscoveredDbPath();
139
154
  if (!dbPath) {
140
- throw new Error("qq-pc.sync: 未找到本机 QQ NT 库且未提供 opts.dbPath / opts.inputPath");
155
+ throw new Error("qq-pc.sync: 未找到本机 QQ NT 库且未提供 opts.dbPath / opts.inputPath(或提供 opts.passphrase 走 sidecar 解密)");
141
156
  }
142
157
  if (!this._deps.fs.existsSync(dbPath)) return;
143
158
 
@@ -178,6 +193,60 @@ class QQPcAdapter {
178
193
  }
179
194
  }
180
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
+
181
250
  normalize(raw) {
182
251
  if (!raw || !raw.payload) {
183
252
  throw new Error("QQPcAdapter.normalize: payload missing");
@@ -215,7 +284,9 @@ class QQPcAdapter {
215
284
  platform: "qq",
216
285
  source: "pc-nt",
217
286
  peerUin: p.peerUin || null,
287
+ ...(p.peerName ? { peerName: p.peerName } : {}),
218
288
  senderUin: p.senderUin || null,
289
+ ...(p.senderName ? { senderName: p.senderName } : {}),
219
290
  isGroup: !!p.isGroup,
220
291
  qqMsgType: typeof p.type === "number" ? p.type : null,
221
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 } };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chainlesschain/personal-data-hub",
3
- "version": "0.4.2",
3
+ "version": "0.4.3",
4
4
  "description": "Personal Data Hub — UnifiedSchema + validators + KG ingest helpers for the data-back-to-the-individual middleware",
5
5
  "type": "commonjs",
6
6
  "main": "lib/index.js",