@chainlesschain/personal-data-hub 0.3.9 → 0.4.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.
Files changed (57) hide show
  1. package/__tests__/adapters/apple-health.test.js +95 -0
  2. package/__tests__/adapters/email-templates.test.js +123 -0
  3. package/__tests__/adapters/family-23-collectors-scaffold.test.js +178 -0
  4. package/__tests__/adapters/game-genshin-scaffold.test.js +107 -0
  5. package/__tests__/adapters/git-activity.test.js +7 -1
  6. package/__tests__/adapters/local-im-pc.test.js +149 -0
  7. package/__tests__/adapters/netease-music.test.js +74 -0
  8. package/__tests__/adapters/qq-pc-direct-read.test.js +186 -0
  9. package/__tests__/adapters/system-data-adapter.test.js +4 -1
  10. package/__tests__/adapters/wechat-pc-direct-read.test.js +207 -0
  11. package/__tests__/adapters/weread.test.js +123 -0
  12. package/__tests__/analysis.test.js +120 -15
  13. package/__tests__/mobile-extractor-encrypted.test.js +460 -0
  14. package/__tests__/prompt-builder.test.js +25 -0
  15. package/__tests__/registry-readiness.test.js +233 -0
  16. package/__tests__/social-douyin-im-direct-read.test.js +311 -0
  17. package/__tests__/social-douyin-snapshot.test.js +5 -2
  18. package/__tests__/vault.test.js +99 -0
  19. package/lib/adapter-guide.js +520 -0
  20. package/lib/adapter-readiness.js +257 -0
  21. package/lib/adapters/_local-im-db-reader.js +218 -0
  22. package/lib/adapters/_local-im-pc-adapter.js +162 -0
  23. package/lib/adapters/apple-health/index.js +329 -0
  24. package/lib/adapters/dingtalk-pc/index.js +29 -0
  25. package/lib/adapters/edu-huawei-learning/api-client.js +47 -0
  26. package/lib/adapters/edu-huawei-learning/index.js +255 -0
  27. package/lib/adapters/edu-zuoyebang/api-client.js +48 -0
  28. package/lib/adapters/edu-zuoyebang/index.js +259 -0
  29. package/lib/adapters/email-imap/email-adapter.js +16 -0
  30. package/lib/adapters/email-imap/templates/bill.js +174 -18
  31. package/lib/adapters/feishu-pc/index.js +29 -0
  32. package/lib/adapters/finance-alipay/api-client.js +48 -0
  33. package/lib/adapters/finance-alipay/index.js +257 -0
  34. package/lib/adapters/game-genshin/api-client.js +59 -0
  35. package/lib/adapters/game-genshin/index.js +274 -0
  36. package/lib/adapters/game-honor-of-kings/api-client.js +54 -0
  37. package/lib/adapters/game-honor-of-kings/index.js +259 -0
  38. package/lib/adapters/netease-music/index.js +227 -0
  39. package/lib/adapters/qq-pc/index.js +200 -0
  40. package/lib/adapters/qq-pc/nt-db-reader.js +210 -0
  41. package/lib/adapters/social-douyin/index.js +194 -1
  42. package/lib/adapters/wechat/wechat-adapter.js +7 -1
  43. package/lib/adapters/wechat-pc/index.js +335 -0
  44. package/lib/adapters/wechat-pc/pc-db-reader.js +327 -0
  45. package/lib/adapters/weread/api-client.js +128 -0
  46. package/lib/adapters/weread/index.js +337 -0
  47. package/lib/analysis.js +65 -0
  48. package/lib/index.js +39 -0
  49. package/lib/mobile-extractor/bplist.js +233 -0
  50. package/lib/mobile-extractor/ios-backup-crypto.js +315 -0
  51. package/lib/mobile-extractor/ios.js +131 -16
  52. package/lib/prompt-builder.js +11 -1
  53. package/lib/registry.js +170 -0
  54. package/lib/vault.js +105 -0
  55. package/package.json +1 -1
  56. package/scripts/run-native-tests-sandbox.sh +2 -0
  57. package/vitest.config.js +79 -1
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * WeChat **desktop (PC)** adapter — 本地直读样板 (ported from social-douyin's
5
+ * im.db direct-read). Reads PC WeChat's local SQLite DBs straight into the
6
+ * vault:
7
+ *
8
+ * - MSG0.db..MSGN.db → table MSG → 私信/群消息 (message events)
9
+ * - MicroMsg.db → table Contact → 联系人 (contact persons)
10
+ *
11
+ * Distinct from the Android `wechat` adapter (EnMicroMsg.db / frida key /
12
+ * message+rcontact schema). PC WeChat has its own schema + a 32-byte raw
13
+ * SQLCipher key. See pc-db-reader.js for the open/decrypt details.
14
+ *
15
+ * Modes (sync opts):
16
+ * 1. opts.dbPath / opts.inputPath — a PC WeChat DB file. If opts.key
17
+ * (64-hex) is supplied, the reader decrypts via SQLCipher; otherwise it
18
+ * expects an already-decrypted plaintext DB (the reliable, recommended
19
+ * path — decrypt once with PyWxDump/手动, then point here).
20
+ * 2. (snapshot mode reserved for a future Android-side PC-bridge; not v0.)
21
+ *
22
+ * The reader extracts whatever of {MSG, Contact} a given file has, so call
23
+ * sync once per file (MSG*.db for messages, MicroMsg.db for contacts).
24
+ *
25
+ * Person ids use the SAME `person-wechat-<wxid>` + `wechatId` identifier as
26
+ * the Android wechat adapter, so EntityResolver merges PC + Android contacts.
27
+ */
28
+
29
+ const fs = require("node:fs");
30
+ const { newId } = require("../../ids");
31
+ const {
32
+ ENTITY_TYPES,
33
+ PERSON_SUBTYPES,
34
+ EVENT_SUBTYPES,
35
+ CAPTURED_BY,
36
+ } = require("../../constants");
37
+
38
+ const NAME = "wechat-pc";
39
+ const VERSION = "0.1.0";
40
+
41
+ const KIND_MESSAGE = "message";
42
+ const KIND_CONTACT = "contact";
43
+
44
+ function stableOriginalId(kind, id) {
45
+ const safe =
46
+ (typeof id === "string" && id.length > 0 && id) ||
47
+ (typeof id === "number" && Number.isFinite(id) && String(id)) ||
48
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ return `wechat-pc:${kind}:${safe}`;
50
+ }
51
+
52
+ function wxidToPersonId(wxid) {
53
+ return wxid ? `person-wechat-${wxid}` : null;
54
+ }
55
+
56
+ class WeChatPcAdapter {
57
+ constructor(opts = {}) {
58
+ this._dbPath = opts.dbPath || null;
59
+ this._key = opts.key || null;
60
+
61
+ this.name = NAME;
62
+ this.version = VERSION;
63
+ this.capabilities = [
64
+ "sync:sqlite",
65
+ "decrypt:sqlcipher-pc",
66
+ "parse:wechat-pc-message",
67
+ "parse:wechat-pc-contact",
68
+ ];
69
+ this.extractMode = "device-pull";
70
+ this.rateLimits = {};
71
+ this.dataDisclosure = {
72
+ fields: [
73
+ "wechat-pc:messages (StrTalker / StrContent / CreateTime / IsSender from MSG*.db)",
74
+ "wechat-pc:contacts (UserName / NickName / Remark from MicroMsg.db)",
75
+ ],
76
+ sensitivity: "high",
77
+ legalGate: true, // chat history — first-use 法律 gate (mirrors wechat)
78
+ };
79
+
80
+ this._deps = {
81
+ fs,
82
+ // DI seam: tests inject a fake SQLite driver class via dbDriverFactory.
83
+ dbDriverFactory: opts.dbDriverFactory || null,
84
+ };
85
+ }
86
+
87
+ async authenticate(ctx = {}) {
88
+ // Cheap readiness probe — never opens / decrypts a DB.
89
+ if (ctx && ctx.readinessOnly) {
90
+ if (this._dbPath) return { ok: true, mode: "configured" };
91
+ return {
92
+ ok: false,
93
+ reason: "DB_NOT_PULLED",
94
+ message:
95
+ "wechat-pc: 需提供 PC 微信本地数据库路径(MSG*.db / MicroMsg.db),加密库需先解密或提供 key",
96
+ };
97
+ }
98
+ const dbPath = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath;
99
+ if (dbPath) {
100
+ try {
101
+ this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
102
+ } catch (err) {
103
+ return {
104
+ ok: false,
105
+ reason: "INPUT_PATH_UNREADABLE",
106
+ message: `wechat-pc: db not readable at ${dbPath}: ${err.message}`,
107
+ };
108
+ }
109
+ return { ok: true, mode: "sqlite" };
110
+ }
111
+ return {
112
+ ok: false,
113
+ reason: "DB_NOT_PULLED",
114
+ message: "wechat-pc.authenticate: needs opts.dbPath / inputPath (MSG*.db or MicroMsg.db)",
115
+ };
116
+ }
117
+
118
+ async healthCheck() {
119
+ return { ok: true, lastChecked: Date.now() };
120
+ }
121
+
122
+ async *sync(opts = {}) {
123
+ const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
124
+ if (!dbPath) {
125
+ throw new Error(
126
+ "wechat-pc.sync: needs opts.dbPath / opts.inputPath pointing to a PC WeChat DB (MSG*.db or MicroMsg.db)",
127
+ );
128
+ }
129
+ if (!this._deps.fs.existsSync(dbPath)) return;
130
+
131
+ // eslint-disable-next-line global-require
132
+ const { readPcWeChat } = require("./pc-db-reader");
133
+ const readOpts = { key: opts.key || this._key || null };
134
+ if (Number.isInteger(opts.limitMessages)) readOpts.limitMessages = opts.limitMessages;
135
+ if (Number.isInteger(opts.limitContacts)) readOpts.limitContacts = opts.limitContacts;
136
+ if (this._deps.dbDriverFactory) readOpts._databaseClass = this._deps.dbDriverFactory();
137
+
138
+ const { messages, contacts, diagnostic } = readPcWeChat(dbPath, readOpts);
139
+ if (typeof opts.onProgress === "function") {
140
+ try {
141
+ opts.onProgress({ phase: "pc-db-read", adapter: NAME, ...diagnostic });
142
+ } catch (_e) { /* progress best-effort */ }
143
+ }
144
+
145
+ const include = opts.include || {};
146
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
147
+ const fallbackCapturedAt = Date.now();
148
+ let emitted = 0;
149
+
150
+ if (include[KIND_MESSAGE] !== false) {
151
+ for (const m of messages) {
152
+ if (emitted >= limit) return;
153
+ if (!m || typeof m !== "object") continue;
154
+ const capturedAt =
155
+ typeof m.createdTimeMs === "number" && m.createdTimeMs > 0
156
+ ? m.createdTimeMs
157
+ : fallbackCapturedAt;
158
+ // Composite id: msgSvrId is globally unique; fallback to talker+time.
159
+ const idPart =
160
+ m.msgSvrId ||
161
+ (m.talker && m.createdTimeMs ? `${m.talker}-${m.createdTimeMs}` : `msg-${emitted}`);
162
+ yield {
163
+ adapter: NAME,
164
+ kind: KIND_MESSAGE,
165
+ originalId: stableOriginalId(KIND_MESSAGE, idPart),
166
+ capturedAt,
167
+ payload: { kind: KIND_MESSAGE, ...m },
168
+ };
169
+ emitted += 1;
170
+ }
171
+ }
172
+
173
+ if (include[KIND_CONTACT] !== false) {
174
+ for (const c of contacts) {
175
+ if (emitted >= limit) return;
176
+ if (!c || typeof c !== "object" || !c.wxid) continue;
177
+ yield {
178
+ adapter: NAME,
179
+ kind: KIND_CONTACT,
180
+ originalId: stableOriginalId(KIND_CONTACT, c.wxid),
181
+ capturedAt: fallbackCapturedAt,
182
+ payload: { kind: KIND_CONTACT, ...c },
183
+ };
184
+ emitted += 1;
185
+ }
186
+ }
187
+ }
188
+
189
+ normalize(raw) {
190
+ if (!raw || !raw.payload) {
191
+ throw new Error("WeChatPcAdapter.normalize: payload missing");
192
+ }
193
+ const kind = raw.kind || raw.payload.kind;
194
+ const ingestedAt = Date.now();
195
+ if (kind === KIND_MESSAGE) return normalizeMessage(raw.payload, raw, ingestedAt);
196
+ if (kind === KIND_CONTACT) return normalizeContact(raw.payload, raw, ingestedAt);
197
+ throw new Error(`WeChatPcAdapter.normalize: unknown kind ${kind}`);
198
+ }
199
+ }
200
+
201
+ function buildSource(raw, occurredAt) {
202
+ return {
203
+ adapter: NAME,
204
+ adapterVersion: VERSION,
205
+ originalId: raw.originalId,
206
+ capturedAt: raw.capturedAt || occurredAt,
207
+ capturedBy: CAPTURED_BY.SQLITE,
208
+ };
209
+ }
210
+
211
+ function normalizeMessage(p, raw, ingestedAt) {
212
+ const occurredAt =
213
+ (typeof p.createdTimeMs === "number" && p.createdTimeMs) || raw.capturedAt || ingestedAt;
214
+ const source = buildSource(raw, occurredAt);
215
+ const text = typeof p.text === "string" ? p.text : "";
216
+ const isSend = Number(p.isSend) === 1;
217
+ const isGroup = !!p.isGroup;
218
+ // actor: outbound = self; inbound 1-on-1 = talker; group = sender prefix.
219
+ const selfId = "person-wechat-self";
220
+ let actor;
221
+ if (isGroup) {
222
+ actor = p.senderWxid ? wxidToPersonId(p.senderWxid) : selfId;
223
+ } else {
224
+ actor = isSend ? selfId : (p.talker ? wxidToPersonId(p.talker) : selfId);
225
+ }
226
+
227
+ const persons = [];
228
+ if (!isGroup && p.talker && !p.talker.endsWith("@chatroom")) {
229
+ persons.push({
230
+ id: wxidToPersonId(p.talker),
231
+ type: ENTITY_TYPES.PERSON,
232
+ subtype: PERSON_SUBTYPES.CONTACT,
233
+ names: [p.talker],
234
+ ingestedAt,
235
+ source,
236
+ identifiers: { wechatId: p.talker },
237
+ extra: { platform: "wechat", source: "pc", wxid: p.talker },
238
+ });
239
+ }
240
+ if (isGroup && p.senderWxid) {
241
+ persons.push({
242
+ id: wxidToPersonId(p.senderWxid),
243
+ type: ENTITY_TYPES.PERSON,
244
+ subtype: PERSON_SUBTYPES.CONTACT,
245
+ names: [p.senderWxid],
246
+ ingestedAt,
247
+ source,
248
+ identifiers: { wechatId: p.senderWxid },
249
+ extra: { platform: "wechat", source: "pc", wxid: p.senderWxid },
250
+ });
251
+ }
252
+
253
+ const topics = [];
254
+ if (isGroup && p.talker) {
255
+ topics.push({
256
+ id: `topic-wechat-group-${p.talker}`,
257
+ type: ENTITY_TYPES.TOPIC,
258
+ name: p.talker.replace("@chatroom", ""),
259
+ ingestedAt,
260
+ source,
261
+ extra: { platform: "wechat", source: "pc", wxid: p.talker },
262
+ });
263
+ }
264
+
265
+ return {
266
+ events: [{
267
+ id: newId(),
268
+ type: ENTITY_TYPES.EVENT,
269
+ subtype: EVENT_SUBTYPES.MESSAGE,
270
+ occurredAt,
271
+ actor: actor || selfId,
272
+ content: {
273
+ title: text ? text.slice(0, 80) : "(非文本消息)",
274
+ text,
275
+ },
276
+ ingestedAt,
277
+ source,
278
+ extra: {
279
+ platform: "wechat",
280
+ source: "pc",
281
+ talker: p.talker || null,
282
+ isSend,
283
+ isGroup,
284
+ wechatType: typeof p.type === "number" ? p.type : null,
285
+ senderWxid: p.senderWxid || null,
286
+ contentBlob: typeof p.contentBlob === "string" ? p.contentBlob : null,
287
+ ...(topics.length ? { topicId: topics[0].id } : {}),
288
+ },
289
+ }],
290
+ persons,
291
+ places: [],
292
+ items: [],
293
+ topics,
294
+ };
295
+ }
296
+
297
+ function normalizeContact(p, raw, ingestedAt) {
298
+ const wxid = p.wxid ? String(p.wxid) : null;
299
+ const occurredAt = raw.capturedAt || ingestedAt;
300
+ const source = buildSource(raw, occurredAt);
301
+ const names = [p.remark, p.nickname, p.alias, wxid].filter(
302
+ (n) => typeof n === "string" && n.length > 0,
303
+ );
304
+ const subtype =
305
+ wxid && wxid.startsWith("gh_") ? PERSON_SUBTYPES.MERCHANT : PERSON_SUBTYPES.CONTACT;
306
+ return {
307
+ events: [],
308
+ persons: [{
309
+ id: wxidToPersonId(wxid) || `person-wechat-${newId()}`,
310
+ type: ENTITY_TYPES.PERSON,
311
+ subtype,
312
+ names: names.length ? names : ["(unnamed)"],
313
+ ingestedAt,
314
+ source,
315
+ identifiers: wxid ? { wechatId: wxid } : {},
316
+ extra: {
317
+ platform: "wechat",
318
+ source: "pc",
319
+ wxid,
320
+ alias: p.alias || null,
321
+ wechatType: typeof p.type === "number" ? p.type : null,
322
+ },
323
+ }],
324
+ places: [],
325
+ items: [],
326
+ topics: [],
327
+ };
328
+ }
329
+
330
+ module.exports = {
331
+ WeChatPcAdapter,
332
+ NAME,
333
+ VERSION,
334
+ wxidToPersonId,
335
+ };
@@ -0,0 +1,327 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * WeChat **desktop (PC)** local-DB direct reader — 本地直读样板, ported from
5
+ * the Douyin im.db sample (social-douyin-adb/im-db-parser.js) to PC WeChat.
6
+ *
7
+ * KEY DIFFERENCE from the Android `wechat/` adapter:
8
+ * - Android WeChat: ONE EnMicroMsg.db, tables `message` / `rcontact` /
9
+ * `chatroom`, key = MD5(IMEI+UIN) or frida.
10
+ * - PC WeChat 3.x: messages live in `MSG0.db`..`MSGN.db` (table `MSG`),
11
+ * contacts + groups live in `MicroMsg.db` (tables `Contact` / `ChatRoom`).
12
+ * Encrypted with SQLCipher using a 32-byte (64-hex) raw key extracted
13
+ * from the running WeChat.exe process memory.
14
+ *
15
+ * Two ways in (mirrors the user's "愿意本地解密" stance):
16
+ *
17
+ * 1. PLAINTEXT (recommended, fully reliable + testable): the user decrypts
18
+ * the PC DB to a plain SQLite file first (PyWxDump / 手动 SQLCipher
19
+ * decrypt) and points the adapter at it. WeChat PC's SQLCipher uses a
20
+ * non-standard per-page HMAC salt scheme, so "decrypt-to-plaintext
21
+ * then read" is the proven path — same shape as the Douyin plaintext
22
+ * im.db sample.
23
+ *
24
+ * 2. ENCRYPTED + raw key (best-effort): if a 64-hex key is supplied we try
25
+ * the documented PC SQLCipher PRAGMA profile. Some WeChat builds open
26
+ * cleanly this way; if not, fall back to method 1.
27
+ *
28
+ * This reader does NOT extract the key (that needs OS-specific process-memory
29
+ * scanning — out of scope here; the guide points at the manual step). It
30
+ * opens what it's given and reads whatever of {MSG, Contact, ChatRoom} the
31
+ * file contains, so the SAME reader serves both MSG*.db (messages) and
32
+ * MicroMsg.db (contacts) — point it at each in turn.
33
+ *
34
+ * Test seam: inject a synthetic `_databaseClass` to bypass the native
35
+ * dual-load (mirrors douyin im-db-parser + bilibili chromium-cookies-reader).
36
+ */
37
+
38
+ // PC WeChat 3.x SQLCipher params (documented by PyWxDump et al.). Applied
39
+ // only when a raw key is supplied. cipher_compatibility 3 ≈ WeChat's
40
+ // page_size 4096 / kdf_iter 64000 / HMAC-SHA1 layout. Tried in order.
41
+ const KNOWN_PC_PRAGMA_PROFILES = Object.freeze([
42
+ {
43
+ name: "wechat-pc-v3",
44
+ pragmas: [
45
+ "PRAGMA cipher_compatibility = 3",
46
+ "PRAGMA cipher_page_size = 4096",
47
+ "PRAGMA kdf_iter = 64000",
48
+ "PRAGMA cipher_hmac_algorithm = HMAC_SHA1",
49
+ "PRAGMA cipher_kdf_algorithm = PBKDF2_HMAC_SHA1",
50
+ ],
51
+ },
52
+ {
53
+ name: "wechat-pc-v4",
54
+ pragmas: [
55
+ "PRAGMA cipher_compatibility = 4",
56
+ "PRAGMA cipher_page_size = 4096",
57
+ ],
58
+ },
59
+ ]);
60
+
61
+ function loadDatabaseClass() {
62
+ for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
63
+ let cls;
64
+ try {
65
+ // eslint-disable-next-line global-require
66
+ cls = require(mod);
67
+ } catch (_e) {
68
+ continue;
69
+ }
70
+ try {
71
+ const probe = new cls(":memory:");
72
+ probe.close();
73
+ return cls;
74
+ } catch (_e) {
75
+ // ABI mismatch — try next
76
+ }
77
+ }
78
+ throw new Error(
79
+ "wechat-pc-db-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
80
+ );
81
+ }
82
+
83
+ function trySelect(db, sql) {
84
+ try {
85
+ return db.prepare(sql).all();
86
+ } catch (_e) {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ function pickCol(columns, candidates) {
92
+ for (const c of candidates) {
93
+ if (columns.has(c)) return c;
94
+ }
95
+ return null;
96
+ }
97
+
98
+ /**
99
+ * Normalize PC WeChat timestamps to ms. PC stores CreateTime in SECONDS
100
+ * (10-digit). Be defensive about ms/µs the same way Douyin is.
101
+ */
102
+ function normalizeEpochMs(v) {
103
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return null;
104
+ if (v > 1e15) return Math.floor(v / 1000); // µs
105
+ if (v > 1e12) return Math.floor(v); // ms
106
+ return Math.floor(v * 1000); // seconds
107
+ }
108
+
109
+ /**
110
+ * For Type=1 (plain text) messages StrContent IS the text. For group
111
+ * messages PC prefixes the sender wxid + ":\n" inside StrContent; strip it
112
+ * so the text is clean and expose the sender separately.
113
+ */
114
+ function parsePcContent(strContent, isGroup) {
115
+ if (typeof strContent !== "string" || strContent.length === 0) {
116
+ return { text: "", senderWxid: null };
117
+ }
118
+ if (isGroup) {
119
+ const idx = strContent.indexOf(":\n");
120
+ if (idx > 0 && idx < 80) {
121
+ return {
122
+ senderWxid: strContent.slice(0, idx),
123
+ text: strContent.slice(idx + 2),
124
+ };
125
+ }
126
+ }
127
+ return { text: strContent, senderWxid: null };
128
+ }
129
+
130
+ function isGroupTalker(talker) {
131
+ return typeof talker === "string" && talker.endsWith("@chatroom");
132
+ }
133
+
134
+ /**
135
+ * Open a PC WeChat DB (encrypted-with-key OR already-plaintext).
136
+ *
137
+ * @param {string} dbPath
138
+ * @param {{ key?: string, _databaseClass?: any }} [opts]
139
+ * @returns {{ db: object, mode: "plaintext"|"sqlcipher", profile: string|null }}
140
+ */
141
+ function openPcWeChatDb(dbPath, opts = {}) {
142
+ const Database = opts._databaseClass || loadDatabaseClass();
143
+ const key = typeof opts.key === "string" && opts.key.length > 0 ? opts.key : null;
144
+
145
+ if (!key) {
146
+ // Plaintext path — already-decrypted db. Probe sqlite_master.
147
+ const db = new Database(dbPath, { readonly: true });
148
+ try {
149
+ db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
150
+ return { db, mode: "plaintext", profile: null };
151
+ } catch (err) {
152
+ try { db.close(); } catch (_e) { /* ignore */ }
153
+ const e = new Error(
154
+ `wechat-pc-db-reader: db is not plaintext SQLite (supply a 64-hex key, or decrypt first): ${err.message}`,
155
+ );
156
+ e.code = "WECHAT_PC_NEEDS_KEY";
157
+ throw e;
158
+ }
159
+ }
160
+
161
+ // SQLCipher path — raw hex key. Try each known PC profile.
162
+ const keyExpr = /^[0-9a-fA-F]{64}$/.test(key) ? `"x'${key}'"` : `'${key}'`;
163
+ let lastError = null;
164
+ for (const profile of KNOWN_PC_PRAGMA_PROFILES) {
165
+ let db;
166
+ try {
167
+ db = new Database(dbPath, { readonly: true });
168
+ db.pragma(`key = ${keyExpr}`);
169
+ for (const p of profile.pragmas) db.exec(p);
170
+ const row = db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
171
+ if (row && Number.isFinite(row.n)) {
172
+ return { db, mode: "sqlcipher", profile: profile.name };
173
+ }
174
+ db.close();
175
+ } catch (err) {
176
+ lastError = err;
177
+ if (db) {
178
+ try { db.close(); } catch (_e) { /* ignore */ }
179
+ }
180
+ }
181
+ }
182
+ const e = new Error(
183
+ `wechat-pc-db-reader: failed to open with any SQLCipher profile — ` +
184
+ `key wrong, or WeChat build needs decrypt-to-plaintext first. ` +
185
+ `Last error: ${lastError && lastError.message}`,
186
+ );
187
+ e.code = "WECHAT_PC_DECRYPT_FAILED";
188
+ throw e;
189
+ }
190
+
191
+ /**
192
+ * Read messages + contacts out of a PC WeChat DB. Reads whatever of
193
+ * {MSG, Contact} the file contains (MSG*.db has MSG; MicroMsg.db has
194
+ * Contact), so the same call works for either file.
195
+ *
196
+ * Returns `{ messages, contacts, diagnostic }`:
197
+ * messages: {msgSvrId, talker, isSend, createdTimeMs, type, text,
198
+ * senderWxid, isGroup, contentBlob}
199
+ * contacts: {wxid, alias, nickname, remark, type}
200
+ * diagnostic: {messageCount, contactCount, hadMsgTable, hadContactTable,
201
+ * mode, profile}
202
+ *
203
+ * @param {string} dbPath
204
+ * @param {{key?, _databaseClass?, limitMessages?, limitContacts?}} [opts]
205
+ */
206
+ function readPcWeChat(dbPath, opts = {}) {
207
+ if (typeof dbPath !== "string" || dbPath.length === 0) {
208
+ throw new TypeError("readPcWeChat: dbPath must be a non-empty string");
209
+ }
210
+ const limitMessages =
211
+ Number.isInteger(opts.limitMessages) && opts.limitMessages > 0
212
+ ? opts.limitMessages
213
+ : 20_000;
214
+ const limitContacts =
215
+ Number.isInteger(opts.limitContacts) && opts.limitContacts > 0
216
+ ? opts.limitContacts
217
+ : 10_000;
218
+
219
+ const { db, mode, profile } = openPcWeChatDb(dbPath, opts);
220
+ const out = {
221
+ messages: [],
222
+ contacts: [],
223
+ diagnostic: {
224
+ messageCount: 0,
225
+ contactCount: 0,
226
+ hadMsgTable: false,
227
+ hadContactTable: false,
228
+ mode,
229
+ profile,
230
+ },
231
+ };
232
+ try {
233
+ // ─── MSG table (messages) ────────────────────────────────────────────
234
+ const msgInfo = trySelect(db, "PRAGMA table_info(MSG)");
235
+ if (Array.isArray(msgInfo) && msgInfo.length > 0) {
236
+ out.diagnostic.hadMsgTable = true;
237
+ const cols = new Set(msgInfo.map((r) => r.name));
238
+ const svrCol = pickCol(cols, ["MsgSvrID", "msgSvrId", "MsgSvrId"]);
239
+ const talkerCol = pickCol(cols, ["StrTalker", "strTalker", "Talker", "talker"]);
240
+ const sendCol = pickCol(cols, ["IsSender", "isSend", "IsSend"]);
241
+ const timeCol = pickCol(cols, ["CreateTime", "createTime", "create_time"]);
242
+ const typeCol = pickCol(cols, ["Type", "type", "MsgType"]);
243
+ const contentCol = pickCol(cols, ["StrContent", "strContent", "Content", "content"]);
244
+ const localIdCol = pickCol(cols, ["localId", "MsgId", "msgId"]);
245
+ if (timeCol && contentCol) {
246
+ const fields = [];
247
+ if (svrCol) fields.push(`${svrCol} AS msgSvrId`);
248
+ if (localIdCol) fields.push(`${localIdCol} AS localId`);
249
+ if (talkerCol) fields.push(`${talkerCol} AS talker`);
250
+ if (sendCol) fields.push(`${sendCol} AS isSend`);
251
+ if (typeCol) fields.push(`${typeCol} AS type`);
252
+ fields.push(`${timeCol} AS createTime`);
253
+ fields.push(`${contentCol} AS content`);
254
+ const sql =
255
+ `SELECT ${fields.join(", ")} FROM MSG ORDER BY ${timeCol} DESC LIMIT ${limitMessages}`;
256
+ const rows = trySelect(db, sql) || [];
257
+ for (const r of rows) {
258
+ const isGroup = isGroupTalker(r.talker);
259
+ const { text, senderWxid } = parsePcContent(r.content, isGroup);
260
+ out.messages.push({
261
+ msgSvrId: r.msgSvrId != null ? String(r.msgSvrId) : (r.localId != null ? `local-${r.localId}` : null),
262
+ talker: r.talker ? String(r.talker) : null,
263
+ isSend: typeof r.isSend === "number" ? r.isSend : null,
264
+ createdTimeMs: normalizeEpochMs(r.createTime),
265
+ type: typeof r.type === "number" ? r.type : null,
266
+ text,
267
+ senderWxid,
268
+ isGroup,
269
+ contentBlob: typeof r.content === "string" ? r.content : null,
270
+ });
271
+ }
272
+ out.diagnostic.messageCount = out.messages.length;
273
+ }
274
+ }
275
+
276
+ // ─── Contact table (contacts) ────────────────────────────────────────
277
+ const contactInfo = trySelect(db, "PRAGMA table_info(Contact)");
278
+ if (Array.isArray(contactInfo) && contactInfo.length > 0) {
279
+ out.diagnostic.hadContactTable = true;
280
+ const cols = new Set(contactInfo.map((r) => r.name));
281
+ const wxidCol = pickCol(cols, ["UserName", "userName", "Username", "username"]);
282
+ const aliasCol = pickCol(cols, ["Alias", "alias"]);
283
+ const nickCol = pickCol(cols, ["NickName", "nickName", "nickname"]);
284
+ const remarkCol = pickCol(cols, ["Remark", "remark", "ConRemark", "conRemark"]);
285
+ const typeCol = pickCol(cols, ["Type", "type"]);
286
+ if (wxidCol) {
287
+ const fields = [`${wxidCol} AS wxid`];
288
+ if (aliasCol) fields.push(`${aliasCol} AS alias`);
289
+ if (nickCol) fields.push(`${nickCol} AS nickname`);
290
+ if (remarkCol) fields.push(`${remarkCol} AS remark`);
291
+ if (typeCol) fields.push(`${typeCol} AS type`);
292
+ const sql = `SELECT ${fields.join(", ")} FROM Contact LIMIT ${limitContacts}`;
293
+ const rows = trySelect(db, sql) || [];
294
+ for (const r of rows) {
295
+ if (!r.wxid) continue;
296
+ const wxid = String(r.wxid);
297
+ // Skip WeChat internal placeholders + chatrooms (not Persons).
298
+ if (wxid.endsWith("@chatroom")) continue;
299
+ out.contacts.push({
300
+ wxid,
301
+ alias: r.alias || null,
302
+ nickname: r.nickname || null,
303
+ remark: r.remark || null,
304
+ type: typeof r.type === "number" ? r.type : null,
305
+ });
306
+ }
307
+ out.diagnostic.contactCount = out.contacts.length;
308
+ }
309
+ }
310
+ } finally {
311
+ try { db.close(); } catch (_e) { /* ignore */ }
312
+ }
313
+ return out;
314
+ }
315
+
316
+ module.exports = {
317
+ readPcWeChat,
318
+ openPcWeChatDb,
319
+ KNOWN_PC_PRAGMA_PROFILES,
320
+ _internals: {
321
+ loadDatabaseClass,
322
+ normalizeEpochMs,
323
+ parsePcContent,
324
+ isGroupTalker,
325
+ pickCol,
326
+ },
327
+ };