@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,257 @@
1
+ /**
2
+ * Adapter readiness — turn an adapter's `authenticate()` reason code into a
3
+ * human-facing "can I collect right now, and if not why" descriptor.
4
+ *
5
+ * Why this exists: `AdapterRegistry.list()` only reports static metadata
6
+ * (name / version / sensitivity). Every adapter's `healthCheck()` returns a
7
+ * lenient `{ ok: true }` (snapshot-mode adapters MUST stay healthy so the
8
+ * registry's pre-sync health gate doesn't block a legitimate
9
+ * `sync-adapter --input <path>` call — the inputPath only arrives at sync
10
+ * time, after the gate). The upshot: the UI showed "healthy" for adapters
11
+ * that in fact cannot collect a single row because no snapshot / cookie /
12
+ * device DB has been provided yet. Users saw "配置正常却采不到数据".
13
+ *
14
+ * The real readiness signal already lives in `authenticate()` — it returns
15
+ * `{ ok: false, reason: "NO_INPUT" | "DB_NOT_PULLED" | ... }`. This module
16
+ * is the lookup table that maps those reason codes to:
17
+ * - status: ready | needs_setup | unavailable | error
18
+ * - category: how this source is collected (local / snapshot / device / ...)
19
+ * - message: short Chinese explanation for the UI
20
+ * - actionHint: what the user should do next
21
+ *
22
+ * `AdapterRegistry.readiness()` calls `authenticate({ readinessOnly: true })`
23
+ * (a cheap, no-network probe — adapters with expensive auth, e.g. email IMAP
24
+ * login / WeChat frida key extraction, short-circuit on that flag) and feeds
25
+ * the reason through `describeReadiness()`.
26
+ */
27
+
28
+ "use strict";
29
+
30
+ // Collection-strategy categories (drives the UI grouping + what action the
31
+ // user must take). Distinct from extractMode, which is the adapter's
32
+ // internal "where do bytes come from" classifier.
33
+ const READINESS_CATEGORY = Object.freeze({
34
+ LOCAL: "local", // 本机直接读取(浏览器/VSCode/git/本地文件…)
35
+ SNAPSHOT: "snapshot", // 需手机端 App 内采集后回传快照
36
+ DEVICE: "device", // 需 root / 本地 DB 解密后拉取数据库
37
+ CREDENTIAL: "credential", // 需登录态 / 账号凭据
38
+ PLATFORM: "platform", // 平台或运行环境不支持
39
+ });
40
+
41
+ // status taxonomy
42
+ const READINESS_STATUS = Object.freeze({
43
+ READY: "ready",
44
+ NEEDS_SETUP: "needs_setup",
45
+ UNAVAILABLE: "unavailable",
46
+ ERROR: "error",
47
+ });
48
+
49
+ /**
50
+ * reason code → descriptor. `appendDetail: true` means the caller may append
51
+ * the adapter's own `message`/`error` string in parentheses for extra context.
52
+ */
53
+ const REASONS = Object.freeze({
54
+ // ── snapshot-mode (Android in-app capture) ──────────────────────────────
55
+ NO_INPUT: {
56
+ status: READINESS_STATUS.NEEDS_SETUP,
57
+ category: READINESS_CATEGORY.SNAPSHOT,
58
+ message: "尚无可采集的数据:需先在手机 App 内采集并回传快照",
59
+ actionHint: "在 Android 端打开对应平台采集页完成一次采集",
60
+ },
61
+ INPUT_PATH_UNREADABLE: {
62
+ status: READINESS_STATUS.ERROR,
63
+ category: READINESS_CATEGORY.SNAPSHOT,
64
+ message: "快照文件不可读(路径不存在或无权限)",
65
+ actionHint: "重新采集生成快照,或检查文件路径",
66
+ appendDetail: true,
67
+ },
68
+ INPUT_PATH_REQUIRED: {
69
+ status: READINESS_STATUS.NEEDS_SETUP,
70
+ category: READINESS_CATEGORY.SNAPSHOT,
71
+ message: "需要提供快照文件路径",
72
+ actionHint: "先在设备端采集生成快照",
73
+ },
74
+ NO_FILE: {
75
+ status: READINESS_STATUS.NEEDS_SETUP,
76
+ category: READINESS_CATEGORY.LOCAL,
77
+ message: "尚未选择文件:从来源导出数据后,选择文件即可采集",
78
+ actionHint: "点「选择文件采集」选中导出的文件",
79
+ },
80
+
81
+ // ── device-pull (root / local DB) ───────────────────────────────────────
82
+ DB_NOT_PULLED: {
83
+ status: READINESS_STATUS.NEEDS_SETUP,
84
+ category: READINESS_CATEGORY.DEVICE,
85
+ message: "尚未拉取到本地数据库:需 root 设备或本地解密后导入 DB",
86
+ actionHint: "通过 ADB / 本地 DB 解密导出数据库后再同步",
87
+ appendDetail: true,
88
+ },
89
+ NO_KEY_PROVIDER: {
90
+ status: READINESS_STATUS.NEEDS_SETUP,
91
+ category: READINESS_CATEGORY.DEVICE,
92
+ message: "缺少数据库解密密钥提供方",
93
+ actionHint: "配置密钥提取(frida / 本地密钥)后重试",
94
+ },
95
+ EMPTY_KEY: {
96
+ status: READINESS_STATUS.ERROR,
97
+ category: READINESS_CATEGORY.DEVICE,
98
+ message: "解密密钥为空,无法解密数据库",
99
+ actionHint: "重新提取数据库密钥",
100
+ },
101
+ KEY_PROVIDER_THREW: {
102
+ status: READINESS_STATUS.ERROR,
103
+ category: READINESS_CATEGORY.DEVICE,
104
+ message: "提取数据库密钥失败",
105
+ actionHint: "检查 frida / 设备连接 / 密钥来源",
106
+ appendDetail: true,
107
+ },
108
+ FRIDA_NEEDS_WXID: {
109
+ status: READINESS_STATUS.NEEDS_SETUP,
110
+ category: READINESS_CATEGORY.DEVICE,
111
+ message: "frida 模式需要 wxid 才能提取密钥",
112
+ actionHint: "提供登录账号的 wxid",
113
+ },
114
+
115
+ // ── credential (login / account config) ─────────────────────────────────
116
+ AUTH_FAILED: {
117
+ status: READINESS_STATUS.ERROR,
118
+ category: READINESS_CATEGORY.CREDENTIAL,
119
+ message: "登录认证失败(账号或授权码错误)",
120
+ actionHint: "重新填写账号 / 授权码",
121
+ appendDetail: true,
122
+ },
123
+ CONNECTION_FAILED: {
124
+ status: READINESS_STATUS.ERROR,
125
+ category: READINESS_CATEGORY.CREDENTIAL,
126
+ message: "连接服务器失败",
127
+ actionHint: "检查网络 / 服务器地址",
128
+ appendDetail: true,
129
+ },
130
+ INVALID_COOKIE: {
131
+ status: READINESS_STATUS.NEEDS_SETUP,
132
+ category: READINESS_CATEGORY.CREDENTIAL,
133
+ message: "登录态 Cookie 无效或已过期",
134
+ actionHint: "重新在 App / 网页登录抓取 Cookie",
135
+ },
136
+ NO_ACCOUNT_PIN: accountReason("缺少账号标识(pin)"),
137
+ NO_ACCOUNT_USER_ID: accountReason("缺少账号标识(user id)"),
138
+ NO_ACCOUNT_USERID: accountReason("缺少账号标识(userId)"),
139
+ NO_ACCOUNT_UID: accountReason("缺少账号标识(uid)"),
140
+ NO_ACCOUNT_QQ: accountReason("缺少 QQ 账号标识"),
141
+ NO_ACCOUNT_USERNAME: accountReason("缺少账号用户名"),
142
+ NO_ACCOUNT_DEVICE_ID: accountReason("缺少设备标识(device id)"),
143
+
144
+ // ── local (host filesystem present?) ────────────────────────────────────
145
+ NO_DATA_ROOTS: localMissing("未配置可扫描的数据目录"),
146
+ NO_CODE_ROOTS: localMissing("未配置可扫描的代码目录"),
147
+ NO_GIT_REPOS: localMissing("未发现 git 仓库"),
148
+ NO_HISTORY_SOURCES: localMissing("未发现命令行历史文件"),
149
+ PROFILE_NOT_FOUND: localMissing("未找到浏览器配置(未安装或从未登录)"),
150
+ PROFILE_PATH_UNRESOLVED: localError("无法解析浏览器配置路径"),
151
+ VSCODE_NOT_FOUND: localMissing("未找到 VSCode 数据"),
152
+ VSCODE_ROOT_UNRESOLVED: localError("无法解析 VSCode 路径"),
153
+ RECENT_DIR_NOT_FOUND: localMissing("未找到最近使用记录目录"),
154
+
155
+ // ── platform / environment ──────────────────────────────────────────────
156
+ PLATFORM_UNSUPPORTED: {
157
+ status: READINESS_STATUS.UNAVAILABLE,
158
+ category: READINESS_CATEGORY.PLATFORM,
159
+ message: "当前操作系统不支持此数据源",
160
+ actionHint: null,
161
+ },
162
+ ENV_UNSUPPORTED: {
163
+ status: READINESS_STATUS.UNAVAILABLE,
164
+ category: READINESS_CATEGORY.PLATFORM,
165
+ message: "当前运行环境不支持此数据源",
166
+ actionHint: null,
167
+ appendDetail: true,
168
+ },
169
+
170
+ // ── probe-level (set by the registry, not adapters) ─────────────────────
171
+ PROBE_TIMEOUT: {
172
+ status: READINESS_STATUS.ERROR,
173
+ category: READINESS_CATEGORY.CREDENTIAL,
174
+ message: "就绪检查超时(适配器可能已配置但探测无响应)",
175
+ actionHint: "稍后重试,或直接尝试一次同步",
176
+ },
177
+ PROBE_ERROR: {
178
+ status: READINESS_STATUS.ERROR,
179
+ category: READINESS_CATEGORY.CREDENTIAL,
180
+ message: "就绪检查出错",
181
+ actionHint: "查看日志 / 直接尝试一次同步",
182
+ appendDetail: true,
183
+ },
184
+ UNKNOWN: {
185
+ status: READINESS_STATUS.ERROR,
186
+ category: READINESS_CATEGORY.CREDENTIAL,
187
+ message: "未就绪(未知原因)",
188
+ actionHint: "查看 lastError / 尝试一次同步以获取详细错误",
189
+ appendDetail: true,
190
+ },
191
+ });
192
+
193
+ function accountReason(message) {
194
+ return {
195
+ status: READINESS_STATUS.NEEDS_SETUP,
196
+ category: READINESS_CATEGORY.CREDENTIAL,
197
+ message,
198
+ actionHint: "在数据源设置中补全账号信息",
199
+ };
200
+ }
201
+
202
+ function localMissing(message) {
203
+ return {
204
+ status: READINESS_STATUS.NEEDS_SETUP,
205
+ category: READINESS_CATEGORY.LOCAL,
206
+ message,
207
+ actionHint: "在设置中指定路径,或确认数据源已在本机存在",
208
+ };
209
+ }
210
+
211
+ function localError(message) {
212
+ return {
213
+ status: READINESS_STATUS.ERROR,
214
+ category: READINESS_CATEGORY.LOCAL,
215
+ message,
216
+ actionHint: "检查本机路径配置",
217
+ };
218
+ }
219
+
220
+ // extractMode → category fallback for the "ready" case (no failure reason).
221
+ const MODE_TO_CATEGORY = Object.freeze({
222
+ "file-import": READINESS_CATEGORY.LOCAL,
223
+ "device-pull": READINESS_CATEGORY.DEVICE,
224
+ "web-api": READINESS_CATEGORY.SNAPSHOT,
225
+ });
226
+
227
+ function categoryForMode(extractMode) {
228
+ return MODE_TO_CATEGORY[extractMode] || READINESS_CATEGORY.LOCAL;
229
+ }
230
+
231
+ /**
232
+ * Map an adapter `authenticate()` reason code to a UI descriptor.
233
+ * Unknown codes fall back to the UNKNOWN descriptor (so a new adapter
234
+ * reason never crashes the readiness report — it just shows generically).
235
+ *
236
+ * @param {string} reason reason code from authenticate()
237
+ * @returns {{status: string, category: string, message: string, actionHint: string|null, appendDetail: boolean}}
238
+ */
239
+ function describeReadiness(reason) {
240
+ const d = (reason && REASONS[reason]) || REASONS.UNKNOWN;
241
+ return {
242
+ status: d.status,
243
+ category: d.category,
244
+ message: d.message,
245
+ actionHint: d.actionHint || null,
246
+ appendDetail: !!d.appendDetail,
247
+ };
248
+ }
249
+
250
+ module.exports = {
251
+ READINESS_CATEGORY,
252
+ READINESS_STATUS,
253
+ describeReadiness,
254
+ categoryForMode,
255
+ // exposed for tests / introspection
256
+ READINESS_REASONS: REASONS,
257
+ };
@@ -0,0 +1,218 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Generic "honest" local IM-database reader — shared by device-pull desktop
5
+ * IM adapters whose schema we CAN'T verify without a real device (钉钉 /
6
+ * 飞书). Ported from the qq-pc honest pattern:
7
+ *
8
+ * - open plaintext OR SQLCipher-with-key
9
+ * - DISCOVER message-like tables via sqlite_master + a name pattern
10
+ * (proprietary clients shard / rename tables across versions, so we
11
+ * never hardcode one table name)
12
+ * - per table, resolve time/sender/peer/content via candidate lists
13
+ * (readable names first, then version-specific guesses)
14
+ * - extract text ONLY when the resolved content column is a real string;
15
+ * otherwise text=null but the FULL raw row is preserved in `rawRow`
16
+ * - LOUD diagnostic: which tables matched, which columns resolved, counts
17
+ * — so the user/UI sees what worked instead of silently getting 0 rows
18
+ *
19
+ * This is deliberately best-effort. The reliable part is "open + discover +
20
+ * preserve everything + report"; exact text extraction for encrypted /
21
+ * protobuf bodies is real-device tuning (extend colCandidates per platform).
22
+ *
23
+ * Test seam: inject `_databaseClass`.
24
+ */
25
+
26
+ function loadDatabaseClass() {
27
+ for (const mod of ["better-sqlite3-multiple-ciphers", "better-sqlite3"]) {
28
+ let cls;
29
+ try {
30
+ // eslint-disable-next-line global-require
31
+ cls = require(mod);
32
+ } catch (_e) {
33
+ continue;
34
+ }
35
+ try {
36
+ const probe = new cls(":memory:");
37
+ probe.close();
38
+ return cls;
39
+ } catch (_e) {
40
+ // ABI mismatch — try next
41
+ }
42
+ }
43
+ throw new Error(
44
+ "local-im-db-reader: neither better-sqlite3-multiple-ciphers nor better-sqlite3 loaded — both ABI-mismatched",
45
+ );
46
+ }
47
+
48
+ function trySelect(db, sql) {
49
+ try {
50
+ return db.prepare(sql).all();
51
+ } catch (_e) {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function pickCol(columns, candidates) {
57
+ for (const c of candidates) {
58
+ if (columns.has(c)) return c;
59
+ }
60
+ return null;
61
+ }
62
+
63
+ function normalizeEpochMs(v) {
64
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0) return null;
65
+ if (v > 1e15) return Math.floor(v / 1000); // µs
66
+ if (v > 1e12) return Math.floor(v); // ms
67
+ return Math.floor(v * 1000); // seconds
68
+ }
69
+
70
+ /**
71
+ * Open a local IM DB. Plaintext when no key; SQLCipher (try compat 4 then 3)
72
+ * when a key is supplied.
73
+ */
74
+ function openLocalDb(dbPath, opts = {}) {
75
+ const Database = opts._databaseClass || loadDatabaseClass();
76
+ const key = typeof opts.key === "string" && opts.key.length > 0 ? opts.key : null;
77
+ if (!key) {
78
+ const db = new Database(dbPath, { readonly: true });
79
+ try {
80
+ db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
81
+ return { db, mode: "plaintext" };
82
+ } catch (err) {
83
+ try { db.close(); } catch (_e) { /* ignore */ }
84
+ const e = new Error(
85
+ `local-im-db-reader: not plaintext SQLite (decrypt first, or pass key): ${err.message}`,
86
+ );
87
+ e.code = "IM_DB_NEEDS_KEY";
88
+ throw e;
89
+ }
90
+ }
91
+ const keyExpr = /^[0-9a-fA-F]{64}$/.test(key) ? `"x'${key}'"` : `'${key}'`;
92
+ for (const compat of [4, 3]) {
93
+ let db;
94
+ try {
95
+ db = new Database(dbPath, { readonly: true });
96
+ db.pragma(`key = ${keyExpr}`);
97
+ db.exec(`PRAGMA cipher_compatibility = ${compat}`);
98
+ db.prepare("SELECT count(*) AS n FROM sqlite_master").get();
99
+ return { db, mode: `sqlcipher-v${compat}` };
100
+ } catch (_err) {
101
+ if (db) { try { db.close(); } catch (_e) { /* ignore */ } }
102
+ }
103
+ }
104
+ const e = new Error("local-im-db-reader: SQLCipher open failed (key wrong, or decrypt first)");
105
+ e.code = "IM_DB_DECRYPT_FAILED";
106
+ throw e;
107
+ }
108
+
109
+ const DEFAULT_COL_CANDIDATES = Object.freeze({
110
+ msgId: ["msgId", "msg_id", "messageId", "message_id", "localId", "id", "_id"],
111
+ time: ["msgTime", "createTime", "create_time", "timestamp", "time", "sendTime", "send_time", "ctime"],
112
+ sender: ["senderId", "sender_id", "senderUid", "fromId", "from_id", "from", "sender", "uid"],
113
+ peer: ["peerId", "peer_id", "conversationId", "conversation_id", "chatId", "chat_id", "sessionId", "talker"],
114
+ content: ["content", "text", "msgContent", "message", "body", "summary"],
115
+ });
116
+
117
+ /**
118
+ * Discover + read message-like tables.
119
+ *
120
+ * @param {string} dbPath
121
+ * @param {object} opts
122
+ * @param {string} [opts.key]
123
+ * @param {any} [opts._databaseClass]
124
+ * @param {RegExp} [opts.tablePattern] matches candidate table names (default /msg|message|chat|conversation/i)
125
+ * @param {object} [opts.colCandidates] merged over DEFAULT_COL_CANDIDATES
126
+ * @param {number} [opts.limitMessages]
127
+ * @returns {{messages: Array, diagnostic: object}}
128
+ */
129
+ function readLocalImDb(dbPath, opts = {}) {
130
+ if (typeof dbPath !== "string" || dbPath.length === 0) {
131
+ throw new TypeError("readLocalImDb: dbPath must be a non-empty string");
132
+ }
133
+ const limit =
134
+ Number.isInteger(opts.limitMessages) && opts.limitMessages > 0 ? opts.limitMessages : 20_000;
135
+ const tablePattern = opts.tablePattern || /msg|message|chat|conversation/i;
136
+ const cand = { ...DEFAULT_COL_CANDIDATES };
137
+ if (opts.colCandidates) {
138
+ for (const k of Object.keys(opts.colCandidates)) {
139
+ // platform-specific candidates take priority, then the generic list
140
+ cand[k] = [...opts.colCandidates[k], ...(DEFAULT_COL_CANDIDATES[k] || [])];
141
+ }
142
+ }
143
+
144
+ const { db, mode } = openLocalDb(dbPath, opts);
145
+ const diagnostic = {
146
+ mode,
147
+ messageCount: 0,
148
+ textCount: 0,
149
+ tablesScanned: 0,
150
+ messageTables: [],
151
+ skippedTables: [],
152
+ resolvedColumns: {},
153
+ };
154
+ const messages = [];
155
+ try {
156
+ const allTables =
157
+ trySelect(db, "SELECT name FROM sqlite_master WHERE type='table'") || [];
158
+ diagnostic.tablesScanned = allTables.length;
159
+ const candidateTables = allTables
160
+ .map((r) => r.name)
161
+ .filter((n) => typeof n === "string" && tablePattern.test(n) && !n.startsWith("sqlite_"));
162
+
163
+ for (const tableName of candidateTables) {
164
+ if (messages.length >= limit) break;
165
+ const info = trySelect(db, `PRAGMA table_info("${tableName}")`);
166
+ if (!Array.isArray(info) || info.length === 0) continue;
167
+ const cols = new Set(info.map((r) => r.name));
168
+ const resolved = {
169
+ msgId: pickCol(cols, cand.msgId),
170
+ time: pickCol(cols, cand.time),
171
+ sender: pickCol(cols, cand.sender),
172
+ peer: pickCol(cols, cand.peer),
173
+ content: pickCol(cols, cand.content),
174
+ };
175
+ // Need at least a content or time column to consider it a real message
176
+ // table; otherwise it's likely metadata (record + report, don't ingest).
177
+ if (!resolved.content && !resolved.time) {
178
+ diagnostic.skippedTables.push(tableName);
179
+ continue;
180
+ }
181
+ diagnostic.messageTables.push(tableName);
182
+ diagnostic.resolvedColumns[tableName] = resolved;
183
+
184
+ const orderBy = resolved.time ? ` ORDER BY "${resolved.time}" DESC` : "";
185
+ const remaining = limit - messages.length;
186
+ const rows =
187
+ trySelect(db, `SELECT * FROM "${tableName}"${orderBy} LIMIT ${remaining}`) || [];
188
+ rows.forEach((row, idx) => {
189
+ const rawTime = resolved.time ? row[resolved.time] : null;
190
+ const contentVal = resolved.content ? row[resolved.content] : null;
191
+ const text = typeof contentVal === "string" ? contentVal : null;
192
+ if (text && text.length > 0) diagnostic.textCount += 1;
193
+ messages.push({
194
+ msgId:
195
+ (resolved.msgId && row[resolved.msgId] != null && String(row[resolved.msgId])) ||
196
+ `${tableName}-${idx}`,
197
+ table: tableName,
198
+ createdTimeMs: typeof rawTime === "number" ? normalizeEpochMs(rawTime) : null,
199
+ senderId: resolved.sender && row[resolved.sender] != null ? String(row[resolved.sender]) : null,
200
+ peerId: resolved.peer && row[resolved.peer] != null ? String(row[resolved.peer]) : null,
201
+ text,
202
+ rawRow: row,
203
+ });
204
+ });
205
+ }
206
+ diagnostic.messageCount = messages.length;
207
+ } finally {
208
+ try { db.close(); } catch (_e) { /* ignore */ }
209
+ }
210
+ return { messages, diagnostic };
211
+ }
212
+
213
+ module.exports = {
214
+ readLocalImDb,
215
+ openLocalDb,
216
+ DEFAULT_COL_CANDIDATES,
217
+ _internals: { loadDatabaseClass, pickCol, normalizeEpochMs },
218
+ };
@@ -0,0 +1,162 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Factory for "honest best-effort" desktop IM adapters (钉钉 / 飞书) built on
5
+ * the generic local-im-db-reader. Both platforms have proprietary, possibly
6
+ * encrypted, version-volatile local SQLite — so the adapter:
7
+ * - opens a (decrypted-or-keyed) local DB
8
+ * - discovers message tables + resolves columns defensively
9
+ * - emits MESSAGE events with the FULL raw row preserved + a loud
10
+ * diagnostic (textResolved flag tells the UI when bodies are still
11
+ * opaque / need real-device tuning)
12
+ *
13
+ * Same shape as qq-pc. Mirrors its honest contract so the UI / guide can
14
+ * treat all device-pull desktop IM sources uniformly.
15
+ */
16
+
17
+ const fs = require("node:fs");
18
+ const { newId } = require("../ids");
19
+ const { ENTITY_TYPES, EVENT_SUBTYPES, CAPTURED_BY } = require("../constants");
20
+
21
+ const KIND_MESSAGE = "message";
22
+
23
+ /**
24
+ * @param {object} cfg
25
+ * @param {string} cfg.name adapter name (e.g. "dingtalk-pc")
26
+ * @param {string} cfg.platform extra.platform tag (e.g. "dingtalk")
27
+ * @param {string} [cfg.version]
28
+ * @param {RegExp} [cfg.tablePattern] message-table name matcher
29
+ * @param {object} [cfg.colCandidates] platform-specific column candidates
30
+ * @param {string} cfg.needHint readiness/DB_NOT_PULLED message
31
+ */
32
+ function createLocalImPcAdapter(cfg) {
33
+ const NAME = cfg.name;
34
+ const VERSION = cfg.version || "0.1.0";
35
+ const PLATFORM = cfg.platform;
36
+
37
+ function stableOriginalId(id) {
38
+ const safe =
39
+ (typeof id === "string" && id.length > 0 && id) ||
40
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
41
+ return `${NAME}:message:${safe}`;
42
+ }
43
+
44
+ class LocalImPcAdapter {
45
+ constructor(opts = {}) {
46
+ this._dbPath = opts.dbPath || null;
47
+ this._key = opts.key || null;
48
+ this.name = NAME;
49
+ this.version = VERSION;
50
+ this.capabilities = ["sync:sqlite", "parse:local-im-message"];
51
+ this.extractMode = "device-pull";
52
+ this.rateLimits = {};
53
+ this.dataDisclosure = {
54
+ fields: [`${PLATFORM}:messages (time / sender / peer / best-effort text; raw row preserved)`],
55
+ sensitivity: "high",
56
+ legalGate: true,
57
+ };
58
+ this._deps = { fs, dbDriverFactory: opts.dbDriverFactory || null };
59
+ }
60
+
61
+ async authenticate(ctx = {}) {
62
+ if (ctx && ctx.readinessOnly) {
63
+ if (this._dbPath) return { ok: true, mode: "configured" };
64
+ return { ok: false, reason: "DB_NOT_PULLED", message: cfg.needHint };
65
+ }
66
+ const dbPath = (ctx && ctx.inputPath) || (ctx && ctx.dbPath) || this._dbPath;
67
+ if (dbPath) {
68
+ try {
69
+ this._deps.fs.accessSync(dbPath, this._deps.fs.constants.R_OK);
70
+ } catch (err) {
71
+ return { ok: false, reason: "INPUT_PATH_UNREADABLE", message: `${NAME}: db not readable at ${dbPath}: ${err.message}` };
72
+ }
73
+ return { ok: true, mode: "sqlite" };
74
+ }
75
+ return { ok: false, reason: "DB_NOT_PULLED", message: `${NAME}.authenticate: needs opts.dbPath / inputPath` };
76
+ }
77
+
78
+ async healthCheck() {
79
+ return { ok: true, lastChecked: Date.now() };
80
+ }
81
+
82
+ async *sync(opts = {}) {
83
+ const dbPath = opts.dbPath || opts.inputPath || this._dbPath;
84
+ if (!dbPath) throw new Error(`${NAME}.sync: needs opts.dbPath / opts.inputPath`);
85
+ if (!this._deps.fs.existsSync(dbPath)) return;
86
+
87
+ // eslint-disable-next-line global-require
88
+ const { readLocalImDb } = require("./_local-im-db-reader");
89
+ const readOpts = {
90
+ key: opts.key || this._key || null,
91
+ tablePattern: cfg.tablePattern,
92
+ colCandidates: cfg.colCandidates,
93
+ };
94
+ if (Number.isInteger(opts.limitMessages)) readOpts.limitMessages = opts.limitMessages;
95
+ if (this._deps.dbDriverFactory) readOpts._databaseClass = this._deps.dbDriverFactory();
96
+
97
+ const { messages, diagnostic } = readLocalImDb(dbPath, readOpts);
98
+ if (typeof opts.onProgress === "function") {
99
+ try { opts.onProgress({ phase: "local-im-read", adapter: NAME, ...diagnostic }); } catch (_e) { /* best-effort */ }
100
+ }
101
+
102
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
103
+ const fallback = Date.now();
104
+ let emitted = 0;
105
+ for (const m of messages) {
106
+ if (emitted >= limit) return;
107
+ if (!m || typeof m !== "object") continue;
108
+ yield {
109
+ adapter: NAME,
110
+ kind: KIND_MESSAGE,
111
+ originalId: stableOriginalId(m.msgId),
112
+ capturedAt: (typeof m.createdTimeMs === "number" && m.createdTimeMs > 0) ? m.createdTimeMs : fallback,
113
+ payload: { kind: KIND_MESSAGE, ...m },
114
+ };
115
+ emitted += 1;
116
+ }
117
+ }
118
+
119
+ normalize(raw) {
120
+ if (!raw || !raw.payload) throw new Error(`${NAME}.normalize: payload missing`);
121
+ const kind = raw.kind || raw.payload.kind;
122
+ if (kind !== KIND_MESSAGE) throw new Error(`${NAME}.normalize: unknown kind ${kind}`);
123
+ const p = raw.payload;
124
+ const ingestedAt = Date.now();
125
+ const occurredAt = (typeof p.createdTimeMs === "number" && p.createdTimeMs) || raw.capturedAt || ingestedAt;
126
+ const source = {
127
+ adapter: NAME,
128
+ adapterVersion: VERSION,
129
+ originalId: raw.originalId,
130
+ capturedAt: raw.capturedAt || occurredAt,
131
+ capturedBy: CAPTURED_BY.SQLITE,
132
+ };
133
+ const text = typeof p.text === "string" ? p.text : "";
134
+ return {
135
+ events: [{
136
+ id: newId(),
137
+ type: ENTITY_TYPES.EVENT,
138
+ subtype: EVENT_SUBTYPES.MESSAGE,
139
+ occurredAt,
140
+ actor: "person-self",
141
+ content: { title: text ? text.slice(0, 80) : "(待解析消息体)", text },
142
+ ingestedAt,
143
+ source,
144
+ extra: {
145
+ platform: PLATFORM,
146
+ source: "pc",
147
+ table: p.table || null,
148
+ senderId: p.senderId || null,
149
+ peerId: p.peerId || null,
150
+ rawRow: p.rawRow || null,
151
+ textResolved: typeof p.text === "string" && p.text.length > 0,
152
+ },
153
+ }],
154
+ persons: [], places: [], items: [], topics: [],
155
+ };
156
+ }
157
+ }
158
+
159
+ return LocalImPcAdapter;
160
+ }
161
+
162
+ module.exports = { createLocalImPcAdapter };