@chainlesschain/personal-data-hub 0.2.0 → 0.2.2

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 (59) hide show
  1. package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
  2. package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
  3. package/__tests__/adapters/ai-chat-history.test.js +8 -7
  4. package/__tests__/adapters/ai-chat-vendors.test.js +149 -8
  5. package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
  6. package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
  7. package/__tests__/adapters/system-data-android.test.js +387 -0
  8. package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
  9. package/__tests__/adapters/wechat-env-probe.test.js +162 -0
  10. package/__tests__/adapters/wechat-frida-agent.test.js +322 -0
  11. package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
  12. package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
  13. package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
  14. package/__tests__/analysis-skills.test.js +147 -0
  15. package/__tests__/analysis.test.js +329 -1
  16. package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
  17. package/__tests__/e2e/full-user-journey.test.js +188 -0
  18. package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
  19. package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
  20. package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
  21. package/__tests__/integration/social-bilibili-pipeline.test.js +261 -0
  22. package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
  23. package/__tests__/registry.test.js +4 -2
  24. package/__tests__/social-adapters.test.js +63 -14
  25. package/__tests__/social-bilibili-snapshot.test.js +278 -0
  26. package/__tests__/wechat-adapter.test.js +118 -0
  27. package/lib/adapters/ai-chat-history/ai-chat-adapter.js +55 -16
  28. package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
  29. package/lib/adapters/ai-chat-history/health-checker.js +210 -0
  30. package/lib/adapters/ai-chat-history/schema-map.js +42 -5
  31. package/lib/adapters/ai-chat-history/vendor-spec.js +1 -0
  32. package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
  33. package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
  34. package/lib/adapters/alipay-bill/alipay-bill-adapter.js +4 -0
  35. package/lib/adapters/social-bilibili/adapter.js +500 -0
  36. package/lib/adapters/social-bilibili/index.js +21 -169
  37. package/lib/adapters/social-kuaishou/index.js +237 -0
  38. package/lib/adapters/social-toutiao/index.js +236 -0
  39. package/lib/adapters/system-data-android/adapter.js +348 -0
  40. package/lib/adapters/system-data-android/index.js +76 -0
  41. package/lib/adapters/wechat/bootstrap.js +146 -0
  42. package/lib/adapters/wechat/content-parser.js +11 -2
  43. package/lib/adapters/wechat/db-reader.js +88 -10
  44. package/lib/adapters/wechat/env-probe.js +218 -0
  45. package/lib/adapters/wechat/frida-agent/loader.js +74 -0
  46. package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +248 -0
  47. package/lib/adapters/wechat/index.js +9 -0
  48. package/lib/adapters/wechat/key-providers/frida-key-provider.js +252 -0
  49. package/lib/adapters/wechat/key-providers/index.js +22 -0
  50. package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
  51. package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
  52. package/lib/adapters/wechat/normalize.js +12 -3
  53. package/lib/analysis-skills/spending.js +4 -1
  54. package/lib/analysis.js +191 -2
  55. package/lib/index.js +16 -0
  56. package/lib/prompt-builder.js +11 -1
  57. package/lib/query-parser.js +7 -1
  58. package/lib/vault.js +77 -0
  59. package/package.json +8 -1
@@ -0,0 +1,348 @@
1
+ "use strict";
2
+
3
+ // SystemDataAndroidAdapter — Plan A v0.1 (4-day slice, 2026-05-21).
4
+ //
5
+ // Reads a UI-produced JSON snapshot of the Android user's own ContentResolver
6
+ // (contacts) and PackageManager (installed apps) and normalises it into PDH
7
+ // entities. The snapshot is produced inside the Android app process (which
8
+ // owns the JVM and can call ContentResolver / PackageManager directly); the
9
+ // cc CLI subprocess then ingests that snapshot through this adapter.
10
+ //
11
+ // Why not extend PythonSidecarAdapter like the desktop `system-data`? Termux
12
+ // does not ship a forensics-bridge sidecar and the data we read here is the
13
+ // user's OWN device — no SQLite parsing or ADB pull is needed; ContentResolver
14
+ // returns clean records. Keep it pure JS, zero sidecar.
15
+ //
16
+ // Out of scope for v0.1 (deferred):
17
+ // - SMS / call_log (need READ_SMS / READ_CALL_LOG and stricter legal gates)
18
+ // - Wifi (no ContentResolver, would need SystemConfiguration JNI)
19
+ // - cc-driven pull (would need a BoundService + Unix socket; v0.1 is UI-pushed)
20
+
21
+ const { newId } = require("../../ids");
22
+ const {
23
+ ENTITY_TYPES,
24
+ PERSON_SUBTYPES,
25
+ ITEM_SUBTYPES,
26
+ CAPTURED_BY,
27
+ } = require("../../constants");
28
+
29
+ const NAME = "system-data-android";
30
+ const VERSION = "0.1.0";
31
+ const SNAPSHOT_SCHEMA_VERSION = 1;
32
+
33
+ // Stable per-source originalId — registry.putRawEvent rejects null originalId
34
+ // with a NOT NULL constraint, surfacing as invalidCount += rawCount on the
35
+ // SyncReport (real-device repro 2026-05-21: 1305 of 1305 raws "invalid"
36
+ // despite all entities being written). Re-deriving the same key on each
37
+ // sync also lets the raw_events store dedup naturally.
38
+ function contactOriginalId(c) {
39
+ const k =
40
+ (c && typeof c.lookupKey === "string" && c.lookupKey.length > 0 && c.lookupKey) ||
41
+ (c && typeof c.displayName === "string" && c.displayName) ||
42
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
43
+ return `android-contact:${k}`;
44
+ }
45
+ function appOriginalId(a) {
46
+ const k =
47
+ (a && typeof a.packageName === "string" && a.packageName) ||
48
+ `unknown-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
49
+ return `android-app:${k}`;
50
+ }
51
+
52
+ class SystemDataAndroidAdapter {
53
+ constructor(opts = {}) {
54
+ this.name = NAME;
55
+ this.version = VERSION;
56
+ this.capabilities = [
57
+ "sync:android-content-provider",
58
+ "sync:android-package-manager",
59
+ ];
60
+ this.extractMode = "device-pull";
61
+ this.rateLimits = { perDay: 24 };
62
+ this.dataDisclosure = {
63
+ fields: [
64
+ "contacts:displayName,phones,emails,starred,organization,photoUri",
65
+ "installed_apps:packageName,label,versionName,versionCode,firstInstallTime,lastUpdateTime,isSystem",
66
+ ],
67
+ sensitivity: "medium",
68
+ legalGate: false,
69
+ defaultInclude: { contacts: true, apps: true },
70
+ };
71
+
72
+ // _deps for test injection — mirrors the pattern in cli-dev.md so test
73
+ // harness can swap fs without resorting to vi.mock("fs") which doesn't
74
+ // intercept require() under inlined CJS. `bridgeProvider` is lazy because
75
+ // the cc-android-bridge module sits in `packages/cli` and is not always
76
+ // available in environments that load this adapter directly (e.g. desktop
77
+ // CLI building a snapshot ingest pipeline). Resolves to null when bridge
78
+ // is unreachable, in which case sync() falls back to inputPath mode.
79
+ this._deps = {
80
+ fs: require("node:fs"),
81
+ bridgeProvider: () => null,
82
+ };
83
+ }
84
+
85
+ // ─── PersonalDataAdapter contract ──────────────────────────────────────
86
+
87
+ async authenticate(ctx = {}) {
88
+ if (!ctx || typeof ctx.inputPath !== "string" || ctx.inputPath.length === 0) {
89
+ return {
90
+ ok: false,
91
+ reason: "INPUT_PATH_REQUIRED",
92
+ message:
93
+ "system-data-android requires opts.inputPath pointing to a snapshot JSON written by the Android app",
94
+ };
95
+ }
96
+ try {
97
+ this._deps.fs.accessSync(ctx.inputPath, this._deps.fs.constants.R_OK);
98
+ } catch (err) {
99
+ return {
100
+ ok: false,
101
+ reason: "INPUT_PATH_UNREADABLE",
102
+ message: `snapshot not readable at ${ctx.inputPath}: ${err.message}`,
103
+ };
104
+ }
105
+ return { ok: true, mode: "snapshot-file" };
106
+ }
107
+
108
+ async healthCheck() {
109
+ // The adapter itself is stateless — health is "always reachable" so long
110
+ // as a snapshot can be re-produced by the UI. Real device-status (whether
111
+ // the runtime permission was granted) lives in the Android-side UI.
112
+ return { ok: true, lastChecked: Date.now() };
113
+ }
114
+
115
+ async *sync(opts = {}) {
116
+ // Two ingestion modes (mutually exclusive — pick whichever fits the host):
117
+ // 1. snapshot mode: opts.inputPath points to JSON the Android UI wrote
118
+ // (works on any host that can read the file — desktop or device).
119
+ // 2. bridge mode: opts.useBridge === true, _deps.bridgeProvider() returns
120
+ // a live cc-android-bridge. Used inside in-APK cc when A6/A7 lands.
121
+ // If neither inputPath nor useBridge is set, bridge auto-engages when
122
+ // available (which only happens on Android with the JNI binding loaded,
123
+ // OR under CC_ANDROID_BRIDGE_OVERRIDE=1 in tests).
124
+ const wantBridge = opts.useBridge === true || (!opts.inputPath && this._bridgeAvailable());
125
+ if (wantBridge) {
126
+ yield* this._syncViaBridge(opts);
127
+ return;
128
+ }
129
+ if (!opts || typeof opts.inputPath !== "string") {
130
+ throw new Error(
131
+ "system-data-android.sync: needs opts.inputPath (snapshot mode) OR opts.useBridge=true (in-APK Android cc with cc-android-bridge.node loaded)"
132
+ );
133
+ }
134
+ yield* this._syncViaSnapshot(opts);
135
+ }
136
+
137
+ _bridgeAvailable() {
138
+ try {
139
+ const b = this._deps.bridgeProvider();
140
+ if (!b || typeof b.caps !== "function") return false;
141
+ const c = b.caps();
142
+ return c && c.available === true;
143
+ } catch (_e) {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ async *_syncViaBridge(opts) {
149
+ const bridge = this._deps.bridgeProvider();
150
+ if (!bridge || typeof bridge.invoke !== "function") {
151
+ throw new Error(
152
+ "system-data-android.sync: useBridge=true but cc-android-bridge is not loaded (run inside in-APK cc, or set CC_ANDROID_BRIDGE_OVERRIDE=1 for tests)"
153
+ );
154
+ }
155
+ const includeContacts = opts.include?.contacts !== false;
156
+ const includeApps = opts.include?.apps !== false;
157
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
158
+ const capturedAt = Date.now();
159
+ let emitted = 0;
160
+
161
+ if (includeContacts) {
162
+ const res = await bridge.invoke("contacts.query", {
163
+ since: Number.isInteger(opts.since) ? opts.since : undefined,
164
+ });
165
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.contacts) ? res.contacts : [];
166
+ for (const c of arr) {
167
+ if (emitted >= limit) return;
168
+ // originalId required by registry.putRawEvent (NOT NULL column); use
169
+ // the stable Android lookupKey when present, else displayName.
170
+ yield {
171
+ kind: "contact",
172
+ originalId: contactOriginalId(c),
173
+ capturedAt,
174
+ payload: c,
175
+ };
176
+ emitted += 1;
177
+ }
178
+ }
179
+
180
+ if (includeApps) {
181
+ const res = await bridge.invoke("app.list", { includeSystem: false });
182
+ const arr = Array.isArray(res) ? res : Array.isArray(res?.apps) ? res.apps : [];
183
+ for (const a of arr) {
184
+ if (emitted >= limit) return;
185
+ yield {
186
+ kind: "app",
187
+ originalId: appOriginalId(a),
188
+ capturedAt,
189
+ payload: a,
190
+ };
191
+ emitted += 1;
192
+ }
193
+ }
194
+ }
195
+
196
+ async *_syncViaSnapshot(opts) {
197
+ const raw = this._deps.fs.readFileSync(opts.inputPath, "utf-8");
198
+ const snapshot = JSON.parse(raw);
199
+ if (
200
+ !snapshot ||
201
+ typeof snapshot !== "object" ||
202
+ snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION
203
+ ) {
204
+ throw new Error(
205
+ `system-data-android.sync: snapshot schemaVersion mismatch (got ${snapshot && snapshot.schemaVersion}, expected ${SNAPSHOT_SCHEMA_VERSION})`
206
+ );
207
+ }
208
+ const capturedAt =
209
+ Number.isFinite(snapshot.snapshottedAt) && snapshot.snapshottedAt > 0
210
+ ? Math.floor(snapshot.snapshottedAt)
211
+ : Date.now();
212
+
213
+ const includeContacts = opts.include?.contacts !== false;
214
+ const includeApps = opts.include?.apps !== false;
215
+ const limit = Number.isInteger(opts.limit) && opts.limit > 0 ? opts.limit : Infinity;
216
+ let emitted = 0;
217
+
218
+ if (includeContacts && Array.isArray(snapshot.contacts)) {
219
+ for (const c of snapshot.contacts) {
220
+ if (emitted >= limit) return;
221
+ yield {
222
+ kind: "contact",
223
+ originalId: contactOriginalId(c),
224
+ capturedAt,
225
+ payload: c,
226
+ };
227
+ emitted += 1;
228
+ }
229
+ }
230
+
231
+ if (includeApps && Array.isArray(snapshot.apps)) {
232
+ for (const a of snapshot.apps) {
233
+ if (emitted >= limit) return;
234
+ yield {
235
+ kind: "app",
236
+ originalId: appOriginalId(a),
237
+ capturedAt,
238
+ payload: a,
239
+ };
240
+ emitted += 1;
241
+ }
242
+ }
243
+ }
244
+
245
+ normalize(raw) {
246
+ const ingestedAt = Date.now();
247
+ const source = (originalId) => ({
248
+ adapter: NAME,
249
+ adapterVersion: VERSION,
250
+ capturedAt: raw.capturedAt,
251
+ capturedBy: CAPTURED_BY.API,
252
+ originalId,
253
+ });
254
+
255
+ if (raw.kind === "contact") {
256
+ const p = raw.payload || {};
257
+ // lookupKey is Android's "stable across rename + edits" identifier; fall
258
+ // back to displayName only if missing, which lets future runs still dedup
259
+ // by name for the dataset where lookupKey is absent.
260
+ const stableKey =
261
+ (typeof p.lookupKey === "string" && p.lookupKey.length > 0 && p.lookupKey) ||
262
+ (typeof p.displayName === "string" && p.displayName) ||
263
+ `unknown-${raw.capturedAt}`;
264
+ const displayName =
265
+ typeof p.displayName === "string" && p.displayName.trim().length > 0
266
+ ? p.displayName.trim()
267
+ : "(无名联系人)";
268
+ const identifiers = {};
269
+ if (Array.isArray(p.phones) && p.phones.length > 0) {
270
+ identifiers.phone = p.phones.filter((x) => typeof x === "string" && x.length > 0);
271
+ }
272
+ if (Array.isArray(p.emails) && p.emails.length > 0) {
273
+ identifiers.email = p.emails.filter((x) => typeof x === "string" && x.length > 0);
274
+ }
275
+
276
+ const person = {
277
+ id: `person-android-${stableKey}`,
278
+ type: ENTITY_TYPES.PERSON,
279
+ subtype: PERSON_SUBTYPES.CONTACT,
280
+ names: [displayName],
281
+ ingestedAt,
282
+ source: source(`android-contact:${stableKey}`),
283
+ };
284
+ if (Object.keys(identifiers).length > 0) person.identifiers = identifiers;
285
+ if (typeof p.organization === "string" && p.organization.trim().length > 0) {
286
+ person.relation = p.organization.trim();
287
+ }
288
+ const extra = {};
289
+ if (typeof p.starred === "boolean") extra.starred = p.starred;
290
+ if (typeof p.photoUri === "string" && p.photoUri.length > 0) extra.photoUri = p.photoUri;
291
+ if (Object.keys(extra).length > 0) person.extra = extra;
292
+
293
+ return {
294
+ events: [],
295
+ persons: [person],
296
+ places: [],
297
+ items: [],
298
+ topics: [],
299
+ };
300
+ }
301
+
302
+ if (raw.kind === "app") {
303
+ const a = raw.payload || {};
304
+ const pkgName =
305
+ (typeof a.packageName === "string" && a.packageName) || `unknown.${newId()}`;
306
+ const label =
307
+ typeof a.label === "string" && a.label.trim().length > 0
308
+ ? a.label.trim()
309
+ : pkgName;
310
+
311
+ const item = {
312
+ id: `item-android-app-${pkgName}`,
313
+ type: ENTITY_TYPES.ITEM,
314
+ subtype: ITEM_SUBTYPES.OTHER,
315
+ name: label,
316
+ category: a.isSystem === true ? "system-app" : "user-app",
317
+ ingestedAt,
318
+ source: source(`android-app:${pkgName}`),
319
+ extra: {
320
+ kind: "installed_app",
321
+ packageName: pkgName,
322
+ versionName: typeof a.versionName === "string" ? a.versionName : null,
323
+ versionCode: Number.isInteger(a.versionCode) ? a.versionCode : null,
324
+ firstInstallTime: Number.isInteger(a.firstInstallTime) ? a.firstInstallTime : null,
325
+ lastUpdateTime: Number.isInteger(a.lastUpdateTime) ? a.lastUpdateTime : null,
326
+ isSystem: a.isSystem === true,
327
+ },
328
+ };
329
+
330
+ return {
331
+ events: [],
332
+ persons: [],
333
+ places: [],
334
+ items: [item],
335
+ topics: [],
336
+ };
337
+ }
338
+
339
+ throw new Error(`system-data-android.normalize: unknown raw.kind=${raw.kind}`);
340
+ }
341
+ }
342
+
343
+ module.exports = {
344
+ SystemDataAndroidAdapter,
345
+ SYSTEM_DATA_ANDROID_NAME: NAME,
346
+ SYSTEM_DATA_ANDROID_VERSION: VERSION,
347
+ SNAPSHOT_SCHEMA_VERSION,
348
+ };
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+
3
+ const fs = require("node:fs");
4
+ const path = require("node:path");
5
+ const {
6
+ SystemDataAndroidAdapter,
7
+ SYSTEM_DATA_ANDROID_NAME,
8
+ SYSTEM_DATA_ANDROID_VERSION,
9
+ SNAPSHOT_SCHEMA_VERSION,
10
+ } = require("./adapter");
11
+
12
+ /**
13
+ * Path C — desktop-side helper. Given a hub instance + an inline snapshot
14
+ * payload from a mobile / browser client, write the snapshot to a staging
15
+ * file, run the adapter's snapshot-mode sync, then clean the staging file up.
16
+ *
17
+ * Centralizing here keeps the schemaVersion check + staging path convention
18
+ * + cleanup discipline in one place — IPC, WS, and P2P-mobile dispatch
19
+ * (3 separate transport adapters) all just call into this.
20
+ *
21
+ * @param {object} hub — hub-wiring output (must have `hubDir` + `registry.syncAdapter`)
22
+ * @param {object} snapshot — payload matching adapter.SNAPSHOT_SCHEMA_VERSION
23
+ * @param {object} [opts]
24
+ * @param {object} [opts.fs] — fs module override for tests (must expose
25
+ * mkdirSync, writeFileSync, existsSync, unlinkSync)
26
+ * @returns {Promise<object>} SyncReport from registry.syncAdapter
27
+ */
28
+ async function ingestSystemDataAndroidSnapshot(hub, snapshot, opts = {}) {
29
+ if (!hub || !hub.hubDir || !hub.registry) {
30
+ throw new Error(
31
+ "ingestSystemDataAndroidSnapshot: hub must expose hubDir + registry"
32
+ );
33
+ }
34
+ if (!snapshot || typeof snapshot !== "object") {
35
+ throw new Error(
36
+ "ingestSystemDataAndroidSnapshot: snapshot payload required"
37
+ );
38
+ }
39
+ if (snapshot.schemaVersion !== SNAPSHOT_SCHEMA_VERSION) {
40
+ throw new Error(
41
+ `ingestSystemDataAndroidSnapshot: schemaVersion ${snapshot.schemaVersion} != expected ${SNAPSHOT_SCHEMA_VERSION}`
42
+ );
43
+ }
44
+
45
+ const fsImpl = opts.fs || fs;
46
+ const stagingDir = path.join(hub.hubDir, "staging");
47
+ fsImpl.mkdirSync(stagingDir, { recursive: true });
48
+ const stagingPath = path.join(
49
+ stagingDir,
50
+ `system-data-android-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.json`
51
+ );
52
+ fsImpl.writeFileSync(stagingPath, JSON.stringify(snapshot), "utf-8");
53
+
54
+ try {
55
+ return await hub.registry.syncAdapter(SYSTEM_DATA_ANDROID_NAME, {
56
+ inputPath: stagingPath,
57
+ });
58
+ } finally {
59
+ // best-effort cleanup; failures shouldn't shadow the (possibly successful) sync
60
+ try {
61
+ if (fsImpl.existsSync(stagingPath)) {
62
+ fsImpl.unlinkSync(stagingPath);
63
+ }
64
+ } catch (_e) {
65
+ /* ignore */
66
+ }
67
+ }
68
+ }
69
+
70
+ module.exports = {
71
+ SystemDataAndroidAdapter,
72
+ SYSTEM_DATA_ANDROID_NAME,
73
+ SYSTEM_DATA_ANDROID_VERSION,
74
+ SNAPSHOT_SCHEMA_VERSION,
75
+ ingestSystemDataAndroidSnapshot,
76
+ };
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Phase 12.6.7 — WeChat adapter bootstrap helper.
3
+ *
4
+ * Glues env-probe (12.6.4) → KeyProvider choice (12.6.1) → WechatAdapter
5
+ * instantiation (12.6.5) into one entry point so the IPC / WS / CLI
6
+ * layers don't each have to recreate the wiring.
7
+ *
8
+ * Decision matrix (mirrors `env-probe.decide`):
9
+ * - probe.suggestedKeyProvider === "md5" → MD5KeyProvider
10
+ * - probe.suggestedKeyProvider === "frida" → FridaKeyProvider
11
+ * - probe.suggestedKeyProvider === "unsupported" → no adapter created;
12
+ * caller gets `{ ok: false, probe, reason }` and is expected to surface
13
+ * `probe.reasons[]` to the user.
14
+ *
15
+ * Caller may force a specific provider via `opts.keyProviderOverride`
16
+ * (e.g. `"md5"` on a real device that env-probe misclassified, useful for
17
+ * the rare 8.0+ install where the user has the MD5 path working). The
18
+ * override skips the suggestion but the probe still runs and is returned
19
+ * for transparency.
20
+ *
21
+ * Returns shape (also see __tests__/adapters/wechat-bootstrap.test.js):
22
+ *
23
+ * { ok: true, adapter, keyProvider, probe }
24
+ * { ok: false, reason: "ENV_UNSUPPORTED" | "MD5_NEEDS_WECHAT_DATA_PATH"
25
+ * | "FRIDA_NEEDS_WXID" | "ADAPTER_CTOR_FAILED",
26
+ * probe, message? }
27
+ *
28
+ * Test seams:
29
+ * - opts._probe inject pre-computed probe (skip exec)
30
+ * - opts._md5Provider inject pre-built MD5KeyProvider instance
31
+ * - opts._fridaProvider inject pre-built FridaKeyProvider instance
32
+ * - opts._WechatAdapter swap the adapter constructor (default: real)
33
+ */
34
+ "use strict";
35
+
36
+ const { WechatAdapter } = require("./wechat-adapter");
37
+ const { MD5KeyProvider } = require("./key-providers/md5-key-provider");
38
+ const { FridaKeyProvider } = require("./key-providers/frida-key-provider");
39
+ const { probe: realProbe } = require("./env-probe");
40
+
41
+ /**
42
+ * @param {object} opts
43
+ * @param {object} opts.account `{ uin, wxid? }` — adapter sees uin
44
+ * @param {string} [opts.dbPath] local path to pulled EnMicroMsg.db
45
+ * @param {string} [opts.wechatDataPath] local pulled /data/data/com.tencent.mm
46
+ * (required when MD5KeyProvider is chosen)
47
+ * @param {object} [opts.fridaOpts] forwarded to FridaKeyProvider ctor
48
+ * (deviceId / packageName / timeoutMs)
49
+ * @param {string} [opts.keyProviderOverride] "md5" | "frida" — force selection
50
+ * @param {Function} [opts.exec] exec seam forwarded to env-probe
51
+ * @param {object} [opts._probe] pre-computed probe (test seam)
52
+ * @param {object} [opts._md5Provider] (test seam)
53
+ * @param {object} [opts._fridaProvider] (test seam)
54
+ * @param {Function} [opts._WechatAdapter] (test seam)
55
+ * @returns {Promise<object>}
56
+ */
57
+ async function bootstrapWechatAdapter(opts = {}) {
58
+ if (!opts || typeof opts !== "object") {
59
+ throw new Error("bootstrapWechatAdapter: opts required");
60
+ }
61
+ if (!opts.account || !opts.account.uin) {
62
+ throw new Error("bootstrapWechatAdapter: opts.account.uin required");
63
+ }
64
+
65
+ const probe = opts._probe || (await realProbe({ exec: opts.exec }));
66
+ const chosen = opts.keyProviderOverride || probe.suggestedKeyProvider;
67
+
68
+ if (chosen === "unsupported") {
69
+ return {
70
+ ok: false,
71
+ reason: "ENV_UNSUPPORTED",
72
+ message: (probe.reasons || []).join("; ") || "env-probe could not pick a viable KeyProvider",
73
+ probe,
74
+ };
75
+ }
76
+
77
+ // Pick / build KeyProvider
78
+ let keyProvider;
79
+ if (chosen === "md5") {
80
+ if (opts._md5Provider) {
81
+ keyProvider = opts._md5Provider;
82
+ } else {
83
+ if (!opts.wechatDataPath) {
84
+ return {
85
+ ok: false,
86
+ reason: "MD5_NEEDS_WECHAT_DATA_PATH",
87
+ message: "MD5KeyProvider requires opts.wechatDataPath (pulled /data/data/com.tencent.mm/)",
88
+ probe,
89
+ };
90
+ }
91
+ keyProvider = new MD5KeyProvider({
92
+ wechatDataPath: opts.wechatDataPath,
93
+ uin: opts.account.uin,
94
+ });
95
+ }
96
+ } else if (chosen === "frida") {
97
+ if (opts._fridaProvider) {
98
+ keyProvider = opts._fridaProvider;
99
+ } else {
100
+ // FridaKeyProvider doesn't strictly need wxid, but we surface a
101
+ // clear error here when the wire-level account looks incomplete.
102
+ if (!opts.account.uin) {
103
+ return {
104
+ ok: false,
105
+ reason: "FRIDA_NEEDS_WXID",
106
+ message: "FridaKeyProvider expects opts.account.uin for downstream adapter wiring",
107
+ probe,
108
+ };
109
+ }
110
+ keyProvider = new FridaKeyProvider({
111
+ deviceId: (opts.fridaOpts && opts.fridaOpts.deviceId) || probe.device.serial || null,
112
+ packageName: (opts.fridaOpts && opts.fridaOpts.packageName) || "com.tencent.mm",
113
+ timeoutMs: (opts.fridaOpts && opts.fridaOpts.timeoutMs) || 30_000,
114
+ });
115
+ }
116
+ } else {
117
+ return {
118
+ ok: false,
119
+ reason: "UNKNOWN_KEY_PROVIDER",
120
+ message: `Unknown keyProvider "${chosen}"`,
121
+ probe,
122
+ };
123
+ }
124
+
125
+ // Instantiate adapter
126
+ const AdapterCtor = opts._WechatAdapter || WechatAdapter;
127
+ let adapter;
128
+ try {
129
+ adapter = new AdapterCtor({
130
+ account: opts.account,
131
+ dbPath: opts.dbPath || null,
132
+ keyProvider,
133
+ });
134
+ } catch (err) {
135
+ return {
136
+ ok: false,
137
+ reason: "ADAPTER_CTOR_FAILED",
138
+ message: err && err.message ? err.message : String(err),
139
+ probe,
140
+ };
141
+ }
142
+
143
+ return { ok: true, adapter, keyProvider, probe };
144
+ }
145
+
146
+ module.exports = { bootstrapWechatAdapter };
@@ -54,6 +54,10 @@ const APPMSG_SUBTYPES = {
54
54
  33: "miniprogram",
55
55
  36: "miniprogram",
56
56
  51: "channel-video",
57
+ // sjqz docs reference these higher subtype codes on newer WeChat builds —
58
+ // accept both for forward compatibility (post-Phase 12.6 audit).
59
+ 2000: "transfer",
60
+ 2001: "redpacket",
57
61
  };
58
62
 
59
63
  /**
@@ -225,10 +229,15 @@ function parseAppMsg(body) {
225
229
  url: url || null,
226
230
  };
227
231
 
228
- // Redpacket-specific
229
- if (appType === 21) {
232
+ // Redpacket-specific (accept both 21 and 2001 — see APPMSG_SUBTYPES)
233
+ if (appType === 21 || appType === 2001) {
230
234
  structured.redPacketTitle = title;
231
235
  }
236
+ // Transfer-specific
237
+ if (appType === 2000) {
238
+ structured.transferAmount =
239
+ extractTag(body, "feedesc") || extractTag(body, "pay_memo");
240
+ }
232
241
  // File-specific
233
242
  if (appType === 6) {
234
243
  structured.fileName = title;