@chainlesschain/personal-data-hub 0.1.0 → 0.2.1
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.
- package/__tests__/adapters/ai-chat-cookie-capture-spec.test.js +211 -0
- package/__tests__/adapters/ai-chat-health-checker.test.js +262 -0
- package/__tests__/adapters/ai-chat-history.test.js +396 -0
- package/__tests__/adapters/ai-chat-http-client.test.js +242 -0
- package/__tests__/adapters/ai-chat-vendors.test.js +874 -0
- package/__tests__/adapters/alipay-bill-adapter.test.js +538 -0
- package/__tests__/adapters/email-adapter.test.js +138 -1
- package/__tests__/adapters/email-classifier.test.js +347 -0
- package/__tests__/adapters/email-pdf-extractor.test.js +529 -0
- package/__tests__/adapters/email-retry-progress.test.js +294 -0
- package/__tests__/adapters/email-templates.test.js +699 -0
- package/__tests__/adapters/social-toutiao-kuaishou-scaffold.test.js +269 -0
- package/__tests__/adapters/system-data-adapter.test.js +440 -0
- package/__tests__/adapters/system-data-android-ingest.test.js +144 -0
- package/__tests__/adapters/system-data-android.test.js +387 -0
- package/__tests__/adapters/system-data-disclosure.test.js +153 -0
- package/__tests__/adapters/wechat-bootstrap.test.js +240 -0
- package/__tests__/adapters/wechat-env-probe.test.js +162 -0
- package/__tests__/adapters/wechat-frida-agent.test.js +191 -0
- package/__tests__/adapters/wechat-frida-integration.test.js +149 -0
- package/__tests__/adapters/wechat-frida-key-provider.test.js +188 -0
- package/__tests__/adapters/wechat-md5-key-provider.test.js +101 -0
- package/__tests__/analysis-skills.test.js +556 -0
- package/__tests__/analysis.test.js +329 -1
- package/__tests__/e2e/ai-chat-cross-source-journey.test.js +213 -0
- package/__tests__/e2e/full-user-journey.test.js +188 -0
- package/__tests__/entity-resolver-ingest-hook.test.js +177 -0
- package/__tests__/entity-resolver-stages.test.js +411 -0
- package/__tests__/entity-resolver-vault.test.js +246 -0
- package/__tests__/entity-resolver.test.js +526 -0
- package/__tests__/fixtures/entity-resolver-200-mock.json +96 -0
- package/__tests__/integration/ai-chat-history-registry.test.js +228 -0
- package/__tests__/integration/aichat-wizard-end-to-end.test.js +282 -0
- package/__tests__/integration/cross-adapter-pipelines.test.js +396 -0
- package/__tests__/integration/wechat-bootstrap-end-to-end.test.js +390 -0
- package/__tests__/longtail-adapters.test.js +217 -0
- package/__tests__/mobile-extractor.test.js +288 -0
- package/__tests__/registry.test.js +4 -2
- package/__tests__/shopping-adapters.test.js +296 -0
- package/__tests__/sidecar-contacts-cross-validate.test.js +163 -0
- package/__tests__/sidecar-supervisor.test.js +120 -0
- package/__tests__/social-adapters.test.js +206 -0
- package/__tests__/travel-adapters.test.js +325 -0
- package/__tests__/vault.test.js +3 -3
- package/__tests__/wechat-adapter.test.js +476 -0
- package/__tests__/whatsapp-adapter.test.js +135 -0
- package/lib/adapter-spec.js +12 -0
- package/lib/adapters/_python-sidecar-base.js +207 -0
- package/lib/adapters/ai-chat-history/ai-chat-adapter.js +374 -0
- package/lib/adapters/ai-chat-history/cookie-auth.js +109 -0
- package/lib/adapters/ai-chat-history/cookie-capture-spec.js +331 -0
- package/lib/adapters/ai-chat-history/health-checker.js +210 -0
- package/lib/adapters/ai-chat-history/http-client.js +211 -0
- package/lib/adapters/ai-chat-history/index.js +28 -0
- package/lib/adapters/ai-chat-history/schema-map.js +258 -0
- package/lib/adapters/ai-chat-history/vendor-spec.js +86 -0
- package/lib/adapters/ai-chat-history/vendors/coze.js +179 -0
- package/lib/adapters/ai-chat-history/vendors/deepseek.js +199 -0
- package/lib/adapters/ai-chat-history/vendors/doubao.js +255 -0
- package/lib/adapters/ai-chat-history/vendors/dreamina.js +174 -0
- package/lib/adapters/ai-chat-history/vendors/hunyuan.js +176 -0
- package/lib/adapters/ai-chat-history/vendors/kimi.js +182 -0
- package/lib/adapters/ai-chat-history/vendors/qianfan.js +160 -0
- package/lib/adapters/ai-chat-history/vendors/tongyi.js +193 -0
- package/lib/adapters/ai-chat-history/vendors/zhipu.js +202 -0
- package/lib/adapters/ai-chat-history/wizard-controller.js +473 -0
- package/lib/adapters/alipay-bill/alipay-bill-adapter.js +311 -0
- package/lib/adapters/alipay-bill/counterparty.js +129 -0
- package/lib/adapters/alipay-bill/csv-parser.js +217 -0
- package/lib/adapters/alipay-bill/index.js +41 -0
- package/lib/adapters/alipay-bill/zip-decryptor.js +111 -0
- package/lib/adapters/email-imap/classifier.js +495 -0
- package/lib/adapters/email-imap/email-adapter.js +419 -8
- package/lib/adapters/email-imap/index.js +42 -0
- package/lib/adapters/email-imap/pdf-extractor.js +192 -0
- package/lib/adapters/email-imap/templates/bill.js +232 -0
- package/lib/adapters/email-imap/templates/government.js +120 -0
- package/lib/adapters/email-imap/templates/index.js +78 -0
- package/lib/adapters/email-imap/templates/order.js +186 -0
- package/lib/adapters/email-imap/templates/other.js +114 -0
- package/lib/adapters/email-imap/templates/register.js +113 -0
- package/lib/adapters/email-imap/templates/travel.js +157 -0
- package/lib/adapters/email-imap/templates/utils.js +275 -0
- package/lib/adapters/email-imap/transactions.js +234 -0
- package/lib/adapters/messaging-qq/index.js +158 -0
- package/lib/adapters/messaging-telegram/index.js +142 -0
- package/lib/adapters/messaging-whatsapp/index.js +189 -0
- package/lib/adapters/shopping-base/index.js +208 -0
- package/lib/adapters/shopping-jd/index.js +150 -0
- package/lib/adapters/shopping-meituan/index.js +154 -0
- package/lib/adapters/shopping-taobao/index.js +176 -0
- package/lib/adapters/social-bilibili/index.js +171 -0
- package/lib/adapters/social-douyin/index.js +116 -0
- package/lib/adapters/social-kuaishou/index.js +237 -0
- package/lib/adapters/social-toutiao/index.js +236 -0
- package/lib/adapters/social-weibo/index.js +164 -0
- package/lib/adapters/social-xiaohongshu/index.js +96 -0
- package/lib/adapters/system-data/disclosure.js +166 -0
- package/lib/adapters/system-data/index.js +34 -0
- package/lib/adapters/system-data/system-data-adapter.js +344 -0
- package/lib/adapters/system-data-android/adapter.js +348 -0
- package/lib/adapters/system-data-android/index.js +76 -0
- package/lib/adapters/travel-12306/index.js +151 -0
- package/lib/adapters/travel-amap/index.js +164 -0
- package/lib/adapters/travel-baidu-map/index.js +162 -0
- package/lib/adapters/travel-base/index.js +240 -0
- package/lib/adapters/travel-ctrip/index.js +151 -0
- package/lib/adapters/wechat/bootstrap.js +146 -0
- package/lib/adapters/wechat/content-parser.js +326 -0
- package/lib/adapters/wechat/db-reader.js +209 -0
- package/lib/adapters/wechat/env-probe.js +218 -0
- package/lib/adapters/wechat/frida-agent/loader.js +67 -0
- package/lib/adapters/wechat/frida-agent/wechat-key-hook.js +126 -0
- package/lib/adapters/wechat/index.js +37 -0
- package/lib/adapters/wechat/key-extractor.js +158 -0
- package/lib/adapters/wechat/key-providers/frida-key-provider.js +244 -0
- package/lib/adapters/wechat/key-providers/index.js +22 -0
- package/lib/adapters/wechat/key-providers/key-provider-base.js +44 -0
- package/lib/adapters/wechat/key-providers/md5-key-provider.js +81 -0
- package/lib/adapters/wechat/normalize.js +220 -0
- package/lib/adapters/wechat/wechat-adapter.js +205 -0
- package/lib/analysis-skills/base.js +113 -0
- package/lib/analysis-skills/footprint.js +167 -0
- package/lib/analysis-skills/index.js +58 -0
- package/lib/analysis-skills/interests.js +161 -0
- package/lib/analysis-skills/relations.js +226 -0
- package/lib/analysis-skills/spending.js +219 -0
- package/lib/analysis-skills/timeline.js +167 -0
- package/lib/analysis.js +191 -2
- package/lib/entity-resolver/embedding-stage.js +198 -0
- package/lib/entity-resolver/entity-resolver.js +384 -0
- package/lib/entity-resolver/index.js +42 -0
- package/lib/entity-resolver/llm-stage.js +191 -0
- package/lib/entity-resolver/rule-stage.js +208 -0
- package/lib/entity-resolver/worker.js +149 -0
- package/lib/index.js +131 -0
- package/lib/migrations.js +73 -0
- package/lib/mobile-extractor/android.js +193 -0
- package/lib/mobile-extractor/index.js +9 -0
- package/lib/mobile-extractor/ios.js +223 -0
- package/lib/prompt-builder.js +11 -1
- package/lib/query-parser.js +7 -1
- package/lib/registry.js +42 -0
- package/lib/sidecar/index.js +15 -0
- package/lib/sidecar/supervisor.js +359 -0
- package/lib/vault.js +343 -0
- package/package.json +36 -3
- package/scripts/_make-fixture-all.js +126 -0
- package/scripts/_make-fixture-contacts.js +84 -0
- package/scripts/evaluate-entity-resolver.js +213 -0
- package/scripts/smoke-phase-5-5.js +196 -0
- package/scripts/smoke-phase-5-7.js +181 -0
- package/scripts/smoke-system-data-contacts.js +309 -0
- package/scripts/smoke-system-data.js +312 -0
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WeChat Phase 12.6.7-10 end-to-end integration test.
|
|
3
|
+
*
|
|
4
|
+
* Exercises the full chain WITHOUT any real adb / Frida / device:
|
|
5
|
+
*
|
|
6
|
+
* env-probe (injected facts)
|
|
7
|
+
* ↓
|
|
8
|
+
* bootstrap.js (KeyProvider choice + adapter ctor)
|
|
9
|
+
* ↓
|
|
10
|
+
* AdapterRegistry (real, in-memory)
|
|
11
|
+
* ↓
|
|
12
|
+
* wechat-accounts.json persistence (real fs, temp dir)
|
|
13
|
+
* ↓
|
|
14
|
+
* list / unregister flow
|
|
15
|
+
*
|
|
16
|
+
* Three scenarios:
|
|
17
|
+
* A. md5 happy path — pre-WeChat-8 device:
|
|
18
|
+
* probe="md5" → wechatDataPath provided → register OK →
|
|
19
|
+
* registry has "wechat" adapter → persisted row chosenKeyProvider="md5"
|
|
20
|
+
* → unregister → row removed + registry empty
|
|
21
|
+
* B. frida happy path — rooted 8.0+ device:
|
|
22
|
+
* probe="frida" + root + frida-server up → register OK →
|
|
23
|
+
* chosenKeyProvider="frida" → persisted row reflects choice
|
|
24
|
+
* C. unsupported path — 8.0+ without root:
|
|
25
|
+
* probe="unsupported" → bootstrap rejects → no registry change,
|
|
26
|
+
* no row written, ok:false with reasons surfaced
|
|
27
|
+
* D. idempotent re-register — same uin twice:
|
|
28
|
+
* first registration with wechatDataPath A, second with B →
|
|
29
|
+
* single row remains, wechatDataPath=B (replaces, doesn't dupe)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
"use strict";
|
|
33
|
+
|
|
34
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
35
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
36
|
+
import { tmpdir } from "node:os";
|
|
37
|
+
import { join } from "node:path";
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
bootstrapWechatAdapter,
|
|
41
|
+
} = require("../../lib/adapters/wechat/bootstrap");
|
|
42
|
+
const { AdapterRegistry } = require("../../lib/registry");
|
|
43
|
+
const { LocalVault } = require("../../lib/vault");
|
|
44
|
+
const { InMemoryKeyProvider } = require("../../lib/key-providers");
|
|
45
|
+
const { generateKeyHex } = require("../../lib/key-providers");
|
|
46
|
+
|
|
47
|
+
// Mirror of the hub-side store helpers so the integration test exercises
|
|
48
|
+
// the same persistence shape both desktop + cli wirings use.
|
|
49
|
+
const { readFileSync, writeFileSync, existsSync } = require("node:fs");
|
|
50
|
+
function loadWechatAccounts(filePath) {
|
|
51
|
+
try {
|
|
52
|
+
if (!existsSync(filePath)) return [];
|
|
53
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
54
|
+
const parsed = JSON.parse(raw);
|
|
55
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
56
|
+
} catch (_e) {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function saveWechatAccounts(filePath, accounts) {
|
|
61
|
+
writeFileSync(filePath, JSON.stringify(accounts, null, 2), {
|
|
62
|
+
encoding: "utf-8",
|
|
63
|
+
mode: 0o600,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function mkProbe(overrides = {}) {
|
|
68
|
+
return {
|
|
69
|
+
ok: true,
|
|
70
|
+
suggestedKeyProvider: "md5",
|
|
71
|
+
reasons: ["WeChat 7.0.22 (< 8.0) — legacy MD5(IMEI+UIN) path supported"],
|
|
72
|
+
device: { reachable: true, serial: "INTEG_TEST", abi: "arm64-v8a" },
|
|
73
|
+
root: { detected: false, magiskInstalled: false },
|
|
74
|
+
frida: { serverRunning: false, port: null },
|
|
75
|
+
wechat: { installed: true, versionName: "7.0.22", majorVersion: 7 },
|
|
76
|
+
warnings: [],
|
|
77
|
+
...overrides,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Replay the wiring's registerWechatAdapter() inline — mirroring the
|
|
82
|
+
// closure on the real hub object — so the integration test exercises
|
|
83
|
+
// the exact code path the IPC/WS layer drives in production.
|
|
84
|
+
async function registerWechatViaHub({ registry, hubDir, opts }) {
|
|
85
|
+
const r = await bootstrapWechatAdapter(opts);
|
|
86
|
+
if (!r.ok) return r;
|
|
87
|
+
|
|
88
|
+
if (registry.has(r.adapter.name)) registry.unregister(r.adapter.name);
|
|
89
|
+
registry.register(r.adapter);
|
|
90
|
+
|
|
91
|
+
const accountsPath = join(hubDir, "wechat-accounts.json");
|
|
92
|
+
const accounts = loadWechatAccounts(accountsPath);
|
|
93
|
+
const next = accounts.filter(
|
|
94
|
+
(c) => !(c.account && c.account.uin === opts.account.uin),
|
|
95
|
+
);
|
|
96
|
+
next.push({
|
|
97
|
+
account: { uin: opts.account.uin },
|
|
98
|
+
dbPath: opts.dbPath || null,
|
|
99
|
+
wechatDataPath: opts.wechatDataPath || null,
|
|
100
|
+
chosenKeyProvider: r.keyProvider && r.keyProvider.name,
|
|
101
|
+
registeredAt: Date.now(),
|
|
102
|
+
lastSyncAt: null,
|
|
103
|
+
});
|
|
104
|
+
saveWechatAccounts(accountsPath, next);
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
name: r.adapter.name,
|
|
109
|
+
chosenKeyProvider: r.keyProvider.name,
|
|
110
|
+
probe: r.probe,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function unregisterWechatViaHub({ registry, hubDir, uin }) {
|
|
115
|
+
const accountsPath = join(hubDir, "wechat-accounts.json");
|
|
116
|
+
const accounts = loadWechatAccounts(accountsPath);
|
|
117
|
+
const target = accounts.find((c) => c.account && c.account.uin === uin);
|
|
118
|
+
const next = accounts.filter(
|
|
119
|
+
(c) => !(c.account && c.account.uin === uin),
|
|
120
|
+
);
|
|
121
|
+
saveWechatAccounts(accountsPath, next);
|
|
122
|
+
if (target && registry.has("wechat")) registry.unregister("wechat");
|
|
123
|
+
return { ok: true, removed: !!target, uin };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function listWechatViaHub({ hubDir }) {
|
|
127
|
+
return loadWechatAccounts(join(hubDir, "wechat-accounts.json")).map((row) => ({
|
|
128
|
+
uin: row.account ? row.account.uin : null,
|
|
129
|
+
dbPath: row.dbPath || null,
|
|
130
|
+
hasWechatDataPath: !!row.wechatDataPath,
|
|
131
|
+
chosenKeyProvider: row.chosenKeyProvider || null,
|
|
132
|
+
registeredAt: row.registeredAt || null,
|
|
133
|
+
}));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
describe("WeChat Phase 12.6.7-10 — end-to-end integration", () => {
|
|
137
|
+
let hubDir;
|
|
138
|
+
let dataDir;
|
|
139
|
+
let registry;
|
|
140
|
+
|
|
141
|
+
beforeEach(() => {
|
|
142
|
+
hubDir = mkdtempSync(join(tmpdir(), "pdh-wechat-integ-"));
|
|
143
|
+
dataDir = mkdtempSync(join(tmpdir(), "pdh-wechat-data-"));
|
|
144
|
+
|
|
145
|
+
// A real registry without vault/sinks — we don't sync, just
|
|
146
|
+
// register/unregister. Phase 12.6.7 boundary: bootstrap doesn't
|
|
147
|
+
// touch the registry, the wiring does (replicated above).
|
|
148
|
+
const vault = new LocalVault({
|
|
149
|
+
path: join(hubDir, "vault.db"),
|
|
150
|
+
key: generateKeyHex(),
|
|
151
|
+
});
|
|
152
|
+
vault.open();
|
|
153
|
+
registry = new AdapterRegistry({ vault });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
try { rmSync(hubDir, { recursive: true, force: true }); } catch (_e) {}
|
|
158
|
+
try { rmSync(dataDir, { recursive: true, force: true }); } catch (_e) {}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("A. md5 happy path — pre-WeChat-8 device", () => {
|
|
162
|
+
it("probe → register → adapter in registry + row persisted with md5 provider", async () => {
|
|
163
|
+
const r = await registerWechatViaHub({
|
|
164
|
+
registry,
|
|
165
|
+
hubDir,
|
|
166
|
+
opts: {
|
|
167
|
+
account: { uin: "1234567890" },
|
|
168
|
+
wechatDataPath: dataDir,
|
|
169
|
+
_probe: mkProbe(),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Bootstrap chain succeeded
|
|
174
|
+
expect(r.ok).toBe(true);
|
|
175
|
+
expect(r.chosenKeyProvider).toBe("md5");
|
|
176
|
+
expect(r.name).toBe("wechat");
|
|
177
|
+
expect(r.probe.suggestedKeyProvider).toBe("md5");
|
|
178
|
+
|
|
179
|
+
// Registry picked up the adapter
|
|
180
|
+
expect(registry.has("wechat")).toBe(true);
|
|
181
|
+
const adapter = registry.get("wechat");
|
|
182
|
+
expect(adapter.account.uin).toBe("1234567890");
|
|
183
|
+
|
|
184
|
+
// Persistence reflects choice
|
|
185
|
+
const list = listWechatViaHub({ hubDir });
|
|
186
|
+
expect(list).toHaveLength(1);
|
|
187
|
+
expect(list[0]).toMatchObject({
|
|
188
|
+
uin: "1234567890",
|
|
189
|
+
chosenKeyProvider: "md5",
|
|
190
|
+
hasWechatDataPath: true,
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("unregister removes row + drops registry entry", async () => {
|
|
195
|
+
await registerWechatViaHub({
|
|
196
|
+
registry,
|
|
197
|
+
hubDir,
|
|
198
|
+
opts: {
|
|
199
|
+
account: { uin: "1234567890" },
|
|
200
|
+
wechatDataPath: dataDir,
|
|
201
|
+
_probe: mkProbe(),
|
|
202
|
+
},
|
|
203
|
+
});
|
|
204
|
+
expect(registry.has("wechat")).toBe(true);
|
|
205
|
+
|
|
206
|
+
const ur = await unregisterWechatViaHub({
|
|
207
|
+
registry,
|
|
208
|
+
hubDir,
|
|
209
|
+
uin: "1234567890",
|
|
210
|
+
});
|
|
211
|
+
expect(ur).toMatchObject({ ok: true, removed: true });
|
|
212
|
+
expect(registry.has("wechat")).toBe(false);
|
|
213
|
+
expect(listWechatViaHub({ hubDir })).toEqual([]);
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("B. frida happy path — rooted 8.0+ device", () => {
|
|
218
|
+
it("probe='frida' + root yields FridaKeyProvider in persisted row", async () => {
|
|
219
|
+
const r = await registerWechatViaHub({
|
|
220
|
+
registry,
|
|
221
|
+
hubDir,
|
|
222
|
+
opts: {
|
|
223
|
+
account: { uin: "wxid_alice" },
|
|
224
|
+
_probe: mkProbe({
|
|
225
|
+
suggestedKeyProvider: "frida",
|
|
226
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
227
|
+
root: { detected: true, magiskInstalled: true },
|
|
228
|
+
frida: { serverRunning: true, port: 27042 },
|
|
229
|
+
reasons: ["WeChat 8.0.50 — Frida hook on libwcdb.so"],
|
|
230
|
+
}),
|
|
231
|
+
},
|
|
232
|
+
});
|
|
233
|
+
expect(r.ok).toBe(true);
|
|
234
|
+
expect(r.chosenKeyProvider).toBe("frida");
|
|
235
|
+
expect(registry.has("wechat")).toBe(true);
|
|
236
|
+
|
|
237
|
+
const list = listWechatViaHub({ hubDir });
|
|
238
|
+
expect(list[0].chosenKeyProvider).toBe("frida");
|
|
239
|
+
// Frida path doesn't require wechatDataPath
|
|
240
|
+
expect(list[0].hasWechatDataPath).toBe(false);
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("C. unsupported path", () => {
|
|
245
|
+
it("8.0+ without root → no registry change, no row, ok:false with reasons", async () => {
|
|
246
|
+
const r = await registerWechatViaHub({
|
|
247
|
+
registry,
|
|
248
|
+
hubDir,
|
|
249
|
+
opts: {
|
|
250
|
+
account: { uin: "wxid_bob" },
|
|
251
|
+
_probe: mkProbe({
|
|
252
|
+
ok: false,
|
|
253
|
+
suggestedKeyProvider: "unsupported",
|
|
254
|
+
reasons: [
|
|
255
|
+
"WeChat 8.0.50 requires root for SQLCipher key extraction",
|
|
256
|
+
],
|
|
257
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
258
|
+
root: { detected: false, magiskInstalled: false },
|
|
259
|
+
}),
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
expect(r.ok).toBe(false);
|
|
263
|
+
expect(r.reason).toBe("ENV_UNSUPPORTED");
|
|
264
|
+
expect(r.probe.reasons.join(" ")).toMatch(/requires root/);
|
|
265
|
+
|
|
266
|
+
expect(registry.has("wechat")).toBe(false);
|
|
267
|
+
expect(listWechatViaHub({ hubDir })).toEqual([]);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("md5 path missing wechatDataPath → ok:false MD5_NEEDS_WECHAT_DATA_PATH", async () => {
|
|
271
|
+
const r = await registerWechatViaHub({
|
|
272
|
+
registry,
|
|
273
|
+
hubDir,
|
|
274
|
+
opts: {
|
|
275
|
+
account: { uin: "1234567890" },
|
|
276
|
+
_probe: mkProbe(),
|
|
277
|
+
// wechatDataPath intentionally omitted
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
expect(r.ok).toBe(false);
|
|
281
|
+
expect(r.reason).toBe("MD5_NEEDS_WECHAT_DATA_PATH");
|
|
282
|
+
expect(registry.has("wechat")).toBe(false);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("D. idempotent re-register", () => {
|
|
287
|
+
it("same uin twice → single row, latest wechatDataPath wins", async () => {
|
|
288
|
+
const dataA = mkdtempSync(join(tmpdir(), "pdh-wechat-dataA-"));
|
|
289
|
+
const dataB = mkdtempSync(join(tmpdir(), "pdh-wechat-dataB-"));
|
|
290
|
+
try {
|
|
291
|
+
await registerWechatViaHub({
|
|
292
|
+
registry,
|
|
293
|
+
hubDir,
|
|
294
|
+
opts: {
|
|
295
|
+
account: { uin: "1234567890" },
|
|
296
|
+
wechatDataPath: dataA,
|
|
297
|
+
_probe: mkProbe(),
|
|
298
|
+
},
|
|
299
|
+
});
|
|
300
|
+
await registerWechatViaHub({
|
|
301
|
+
registry,
|
|
302
|
+
hubDir,
|
|
303
|
+
opts: {
|
|
304
|
+
account: { uin: "1234567890" },
|
|
305
|
+
wechatDataPath: dataB,
|
|
306
|
+
_probe: mkProbe(),
|
|
307
|
+
},
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
const list = listWechatViaHub({ hubDir });
|
|
311
|
+
expect(list).toHaveLength(1);
|
|
312
|
+
expect(list[0].uin).toBe("1234567890");
|
|
313
|
+
expect(registry.has("wechat")).toBe(true);
|
|
314
|
+
|
|
315
|
+
// Adapter's _dbPath is null in both calls (we didn't pass dbPath),
|
|
316
|
+
// but the persisted row uses the latest wechatDataPath.
|
|
317
|
+
const raw = readFileSync(
|
|
318
|
+
join(hubDir, "wechat-accounts.json"),
|
|
319
|
+
"utf-8",
|
|
320
|
+
);
|
|
321
|
+
const persisted = JSON.parse(raw);
|
|
322
|
+
expect(persisted[0].wechatDataPath).toBe(dataB);
|
|
323
|
+
} finally {
|
|
324
|
+
try { rmSync(dataA, { recursive: true, force: true }); } catch (_e) {}
|
|
325
|
+
try { rmSync(dataB, { recursive: true, force: true }); } catch (_e) {}
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("two distinct uins coexist as separate rows", async () => {
|
|
330
|
+
await registerWechatViaHub({
|
|
331
|
+
registry,
|
|
332
|
+
hubDir,
|
|
333
|
+
opts: { account: { uin: "alice" }, wechatDataPath: dataDir, _probe: mkProbe() },
|
|
334
|
+
});
|
|
335
|
+
await registerWechatViaHub({
|
|
336
|
+
registry,
|
|
337
|
+
hubDir,
|
|
338
|
+
opts: { account: { uin: "bob" }, wechatDataPath: dataDir, _probe: mkProbe() },
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const list = listWechatViaHub({ hubDir });
|
|
342
|
+
expect(list.map((r) => r.uin).sort()).toEqual(["alice", "bob"]);
|
|
343
|
+
// Single registry slot named "wechat" — second register replaces first
|
|
344
|
+
// adapter instance, but registry still has exactly one entry. This is
|
|
345
|
+
// the v0.5 limit: the registry namespaces by adapter.name not by uin.
|
|
346
|
+
// The persisted accounts file is the source of truth for "which uins
|
|
347
|
+
// can sync"; bootstrap re-runs at sync time per account.
|
|
348
|
+
expect(registry.has("wechat")).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("override semantics (Phase 12.6.7 §18.10)", () => {
|
|
353
|
+
it("keyProviderOverride='frida' wins over probe='md5'", async () => {
|
|
354
|
+
const r = await registerWechatViaHub({
|
|
355
|
+
registry,
|
|
356
|
+
hubDir,
|
|
357
|
+
opts: {
|
|
358
|
+
account: { uin: "wxid_force" },
|
|
359
|
+
keyProviderOverride: "frida",
|
|
360
|
+
_probe: mkProbe(), // suggests md5
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
expect(r.ok).toBe(true);
|
|
364
|
+
expect(r.chosenKeyProvider).toBe("frida");
|
|
365
|
+
// Probe transparency: original suggestion still surfaces unchanged
|
|
366
|
+
expect(r.probe.suggestedKeyProvider).toBe("md5");
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("keyProviderOverride='md5' wins over probe='frida'", async () => {
|
|
370
|
+
const r = await registerWechatViaHub({
|
|
371
|
+
registry,
|
|
372
|
+
hubDir,
|
|
373
|
+
opts: {
|
|
374
|
+
account: { uin: "1234567890" },
|
|
375
|
+
wechatDataPath: dataDir,
|
|
376
|
+
keyProviderOverride: "md5",
|
|
377
|
+
_probe: mkProbe({
|
|
378
|
+
suggestedKeyProvider: "frida",
|
|
379
|
+
wechat: { installed: true, versionName: "8.0.50", majorVersion: 8 },
|
|
380
|
+
root: { detected: true, magiskInstalled: true },
|
|
381
|
+
frida: { serverRunning: true, port: 27042 },
|
|
382
|
+
}),
|
|
383
|
+
},
|
|
384
|
+
});
|
|
385
|
+
expect(r.ok).toBe(true);
|
|
386
|
+
expect(r.chosenKeyProvider).toBe("md5");
|
|
387
|
+
expect(r.probe.suggestedKeyProvider).toBe("frida");
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect } from "vitest";
|
|
4
|
+
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
|
|
9
|
+
const {
|
|
10
|
+
DouyinAdapter,
|
|
11
|
+
XiaohongshuAdapter,
|
|
12
|
+
QQAdapter,
|
|
13
|
+
TelegramAdapter,
|
|
14
|
+
} = require("../lib");
|
|
15
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
16
|
+
const { validateBatch } = require("../lib/batch");
|
|
17
|
+
|
|
18
|
+
function makeMockDriver(scriptedRows) {
|
|
19
|
+
return function () {
|
|
20
|
+
return {
|
|
21
|
+
prepare(sql) {
|
|
22
|
+
return {
|
|
23
|
+
all() {
|
|
24
|
+
for (const [matchSubstr, rows] of scriptedRows) {
|
|
25
|
+
if (sql.includes(matchSubstr)) return rows;
|
|
26
|
+
}
|
|
27
|
+
throw new Error("no such table");
|
|
28
|
+
},
|
|
29
|
+
};
|
|
30
|
+
},
|
|
31
|
+
pragma() {},
|
|
32
|
+
close() {},
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function tmpDb() {
|
|
38
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "longtail-"));
|
|
39
|
+
const dbPath = path.join(dir, "fake.db");
|
|
40
|
+
fs.writeFileSync(dbPath, "fake");
|
|
41
|
+
return { dir, dbPath };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cleanup(dir) {
|
|
45
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── DouyinAdapter ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe("DouyinAdapter", () => {
|
|
51
|
+
it("contract conformance", () => {
|
|
52
|
+
const a = new DouyinAdapter({ account: { uid: "u-1" } });
|
|
53
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("rejects missing account.uid", () => {
|
|
57
|
+
expect(() => new DouyinAdapter({ account: {} })).toThrow(/uid/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("sync yields history + favourite + search", async () => {
|
|
61
|
+
const { dir, dbPath } = tmpDb();
|
|
62
|
+
try {
|
|
63
|
+
const mockDriver = makeMockDriver([
|
|
64
|
+
["FROM video_history", [{ id: 1, aweme_id: "v1", title: "Cat", view_time: 1700000000, author: "@cat", duration: 30 }]],
|
|
65
|
+
["FROM history", []],
|
|
66
|
+
["FROM user_favorite", [{ id: 1, aweme_id: "v2", title: "Saved", create_time: 1700001000 }]],
|
|
67
|
+
["FROM favourite", []],
|
|
68
|
+
["FROM search_history", [{ id: 1, keyword: "music", time: 1700002000 }]],
|
|
69
|
+
]);
|
|
70
|
+
const a = new DouyinAdapter({ account: { uid: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
|
|
71
|
+
const raws = [];
|
|
72
|
+
for await (const r of a.sync()) raws.push(r);
|
|
73
|
+
expect(raws.length).toBe(3);
|
|
74
|
+
for (const r of raws) {
|
|
75
|
+
expect(validateBatch(a.normalize(r)).valid).toBe(true);
|
|
76
|
+
}
|
|
77
|
+
} finally { cleanup(dir); }
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// ─── XiaohongshuAdapter ─────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
describe("XiaohongshuAdapter", () => {
|
|
84
|
+
it("contract conformance", () => {
|
|
85
|
+
const a = new XiaohongshuAdapter({ account: { uid: "u-1" } });
|
|
86
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects missing account.uid", () => {
|
|
90
|
+
expect(() => new XiaohongshuAdapter({ account: {} })).toThrow(/uid/);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("sync yields history + likes + favourites", async () => {
|
|
94
|
+
const { dir, dbPath } = tmpDb();
|
|
95
|
+
try {
|
|
96
|
+
const mockDriver = makeMockDriver([
|
|
97
|
+
["FROM browse_history", [{ id: 1, note_id: "n1", title: "Recipe", view_time: 1700000000, author: "chef" }]],
|
|
98
|
+
["FROM note", []],
|
|
99
|
+
["FROM liked_note", [{ id: 1, note_id: "n2", title: "Liked", like_time: 1700001000 }]],
|
|
100
|
+
["FROM favourite", [{ id: 1, note_id: "n3", title: "Saved", save_time: 1700002000 }]],
|
|
101
|
+
]);
|
|
102
|
+
const a = new XiaohongshuAdapter({ account: { uid: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
|
|
103
|
+
const raws = [];
|
|
104
|
+
for await (const r of a.sync()) raws.push(r);
|
|
105
|
+
expect(raws.length).toBe(3);
|
|
106
|
+
for (const r of raws) {
|
|
107
|
+
const batch = a.normalize(r);
|
|
108
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
109
|
+
}
|
|
110
|
+
} finally { cleanup(dir); }
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ─── QQAdapter ──────────────────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("QQAdapter", () => {
|
|
117
|
+
it("contract conformance", () => {
|
|
118
|
+
const a = new QQAdapter({ account: { qq: "12345" } });
|
|
119
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
120
|
+
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("rejects missing account.qq", () => {
|
|
124
|
+
expect(() => new QQAdapter({ account: {} })).toThrow(/qq/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("authenticate fails without DB", async () => {
|
|
128
|
+
const a = new QQAdapter({ account: { qq: "12345" }, keyProvider: { getKey: async () => "k" } });
|
|
129
|
+
const r = await a.authenticate();
|
|
130
|
+
expect(r.ok).toBe(false);
|
|
131
|
+
expect(r.reason).toBe("DB_NOT_PULLED");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("authenticate fails without keyProvider", async () => {
|
|
135
|
+
const { dir, dbPath } = tmpDb();
|
|
136
|
+
try {
|
|
137
|
+
const a = new QQAdapter({ account: { qq: "12345" }, dbPath });
|
|
138
|
+
const r = await a.authenticate();
|
|
139
|
+
expect(r.reason).toBe("NO_KEY_PROVIDER");
|
|
140
|
+
} finally { cleanup(dir); }
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("sync yields contact + group + message types", async () => {
|
|
144
|
+
const { dir, dbPath } = tmpDb();
|
|
145
|
+
try {
|
|
146
|
+
const mockDriver = makeMockDriver([
|
|
147
|
+
["FROM mr_friend", [{ uin: "999", nickname: "好友A", remark: "" }]],
|
|
148
|
+
["FROM mr_troop", [{ troop_uin: "888", troop_name: "测试群" }]],
|
|
149
|
+
["FROM sqlite_master", [{ name: "msgcsr_friend_999" }]],
|
|
150
|
+
["FROM msgcsr_friend_999", [{ msgid: "m1", msg: "你好", time: 1700000000, frienduin: "999", msgtype: 1 }]],
|
|
151
|
+
]);
|
|
152
|
+
const a = new QQAdapter({
|
|
153
|
+
account: { qq: "12345" },
|
|
154
|
+
dbPath,
|
|
155
|
+
keyProvider: { getKey: async () => "fakekey" },
|
|
156
|
+
dbDriverFactory: () => mockDriver,
|
|
157
|
+
});
|
|
158
|
+
const raws = [];
|
|
159
|
+
for await (const r of a.sync()) raws.push(r);
|
|
160
|
+
expect(raws.length).toBe(3); // contact + group + message
|
|
161
|
+
const contact = raws.find((r) => r.payload.kind === "contact");
|
|
162
|
+
const group = raws.find((r) => r.payload.kind === "group");
|
|
163
|
+
const message = raws.find((r) => r.payload.kind === "message");
|
|
164
|
+
expect(contact).toBeDefined();
|
|
165
|
+
expect(group).toBeDefined();
|
|
166
|
+
expect(message).toBeDefined();
|
|
167
|
+
for (const r of raws) {
|
|
168
|
+
expect(validateBatch(a.normalize(r)).valid).toBe(true);
|
|
169
|
+
}
|
|
170
|
+
} finally { cleanup(dir); }
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// ─── TelegramAdapter ────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
describe("TelegramAdapter", () => {
|
|
177
|
+
it("contract conformance", () => {
|
|
178
|
+
const a = new TelegramAdapter({ account: { userId: "u-1" } });
|
|
179
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
180
|
+
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects missing account.userId", () => {
|
|
184
|
+
expect(() => new TelegramAdapter({ account: {} })).toThrow(/userId/);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("sync yields user + chat + messages (no key needed)", async () => {
|
|
188
|
+
const { dir, dbPath } = tmpDb();
|
|
189
|
+
try {
|
|
190
|
+
const mockDriver = makeMockDriver([
|
|
191
|
+
["FROM users", [{ uid: "111", name: "Alice", username: "alice", phone: "13800001111" }]],
|
|
192
|
+
["FROM chats", [{ uid: "222", name: "Group A" }]],
|
|
193
|
+
["FROM messages_v2", [{ mid: "m1", uid: "111", message: "Hi", date: 1700000000, out: 0 }]],
|
|
194
|
+
["FROM messages", []],
|
|
195
|
+
]);
|
|
196
|
+
const a = new TelegramAdapter({ account: { userId: "u-1" }, dbPath, dbDriverFactory: () => mockDriver });
|
|
197
|
+
const raws = [];
|
|
198
|
+
for await (const r of a.sync()) raws.push(r);
|
|
199
|
+
expect(raws.length).toBe(3);
|
|
200
|
+
for (const r of raws) {
|
|
201
|
+
expect(validateBatch(a.normalize(r)).valid).toBe(true);
|
|
202
|
+
}
|
|
203
|
+
} finally { cleanup(dir); }
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it("normalize contact includes phone identifier", async () => {
|
|
207
|
+
const a = new TelegramAdapter({ account: { userId: "u-1" } });
|
|
208
|
+
const batch = a.normalize({
|
|
209
|
+
adapter: "messaging-telegram",
|
|
210
|
+
originalId: "user-111",
|
|
211
|
+
capturedAt: Date.now(),
|
|
212
|
+
payload: { kind: "contact", row: { uid: "111", name: "Bob", phone: "13800001111" } },
|
|
213
|
+
});
|
|
214
|
+
expect(batch.persons[0].identifiers.telegramId).toBe("111");
|
|
215
|
+
expect(batch.persons[0].identifiers.phone).toEqual(["13800001111"]);
|
|
216
|
+
});
|
|
217
|
+
});
|