@chainlesschain/personal-data-hub 0.2.2 → 0.2.4
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/social-toutiao-kuaishou-scaffold.test.js +58 -16
- package/__tests__/analysis.test.js +1 -1
- package/__tests__/longtail-adapters.test.js +67 -16
- package/__tests__/messaging-qq-snapshot.test.js +294 -0
- package/__tests__/shopping-pinduoduo-snapshot.test.js +302 -0
- package/__tests__/shopping-snapshot.test.js +438 -0
- package/__tests__/social-adapters.test.js +28 -3
- package/__tests__/social-douyin-snapshot.test.js +253 -0
- package/__tests__/social-kuaishou-snapshot.test.js +309 -0
- package/__tests__/social-toutiao-snapshot.test.js +314 -0
- package/__tests__/social-weibo-snapshot.test.js +234 -0
- package/__tests__/social-xiaohongshu-snapshot.test.js +232 -0
- package/__tests__/travel-maps-snapshot.test.js +426 -0
- package/__tests__/vault-driver-error.test.js +74 -0
- package/lib/adapters/messaging-qq/index.js +498 -92
- package/lib/adapters/shopping-jd/index.js +228 -25
- package/lib/adapters/shopping-meituan/index.js +222 -26
- package/lib/adapters/shopping-pinduoduo/index.js +275 -0
- package/lib/adapters/social-douyin/index.js +454 -63
- package/lib/adapters/social-kuaishou/index.js +379 -127
- package/lib/adapters/social-toutiao/index.js +400 -130
- package/lib/adapters/social-weibo/index.js +393 -95
- package/lib/adapters/social-xiaohongshu/index.js +389 -49
- package/lib/adapters/travel-baidu-map/index.js +286 -26
- package/lib/adapters/travel-tencent-map/index.js +414 -0
- package/lib/index.js +5 -1
- package/lib/vault.js +60 -8
- package/package.json +2 -1
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* §A8 v0.2 — Toutiao 今日头条 + Kuaishou 快手 sqlite-mode tests.
|
|
3
|
+
*
|
|
4
|
+
* Originally Phase 13.8+13.9 v0.1 scaffold tests; promoted in §A8 v0.2 to
|
|
5
|
+
* cover the dual-mode (snapshot + sqlite) adapter. Snapshot-mode coverage
|
|
6
|
+
* lives in `../social-{toutiao,kuaishou}-snapshot.test.js`; this file
|
|
7
|
+
* focuses on the legacy sqlite/device-pull path that desktop wiring still
|
|
8
|
+
* uses for PCs running AndroidExtractor.
|
|
3
9
|
*
|
|
4
|
-
* Tests are intentionally focused on scaffold-quality guarantees:
|
|
5
10
|
* - Adapter contract conformance (assertAdapter ok)
|
|
6
|
-
* - Account validation (rejects missing uid)
|
|
7
11
|
* - sync() yields raw rows per `kind` from mocked SQLite driver
|
|
8
12
|
* - normalize() produces valid UnifiedSchema events with correct subtype
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
13
|
+
* - Account validation lazy-checked at sync() time (v0.2 changed:
|
|
14
|
+
* account.uid is now OPTIONAL at construction so snapshot-mode-only
|
|
15
|
+
* callers can omit it).
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
"use strict";
|
|
@@ -50,18 +54,37 @@ function withFakeDb(fn) {
|
|
|
50
54
|
|
|
51
55
|
// ─── ToutiaoAdapter ─────────────────────────────────────────────────────
|
|
52
56
|
|
|
53
|
-
describe("ToutiaoAdapter —
|
|
57
|
+
describe("ToutiaoAdapter — §A8 v0.2 sqlite mode", () => {
|
|
54
58
|
it("contract conformance + sensitivity high (news reading reveals political/medical interest)", () => {
|
|
55
59
|
const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
|
|
56
60
|
expect(assertAdapter(a).ok).toBe(true);
|
|
57
61
|
expect(a.name).toBe("social-toutiao");
|
|
58
62
|
expect(a.extractMode).toBe("device-pull");
|
|
59
63
|
expect(a.dataDisclosure.sensitivity).toBe("high");
|
|
64
|
+
// v0.2 dual-mode capabilities — adapter accepts both snapshot and sqlite.
|
|
65
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
66
|
+
expect(a.capabilities).toContain("sync:sqlite");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("v0.2: account OPTIONAL at construction (snapshot mode is stateless)", () => {
|
|
70
|
+
// Used to throw in v0.1 — now legal. Sqlite-mode sync() will lazy-throw.
|
|
71
|
+
expect(() => new ToutiaoAdapter()).not.toThrow();
|
|
72
|
+
expect(() => new ToutiaoAdapter({})).not.toThrow();
|
|
73
|
+
expect(() => new ToutiaoAdapter({ account: {} })).not.toThrow();
|
|
60
74
|
});
|
|
61
75
|
|
|
62
|
-
it("
|
|
63
|
-
|
|
64
|
-
|
|
76
|
+
it("sqlite mode lazy-throws when account.uid missing at sync time", async () => {
|
|
77
|
+
const a = new ToutiaoAdapter({ dbPath: "/no/such/path.db" });
|
|
78
|
+
let threw = null;
|
|
79
|
+
try {
|
|
80
|
+
for await (const _r of a.sync()) {
|
|
81
|
+
/* drain */
|
|
82
|
+
}
|
|
83
|
+
} catch (err) {
|
|
84
|
+
threw = err;
|
|
85
|
+
}
|
|
86
|
+
expect(threw).toBeTruthy();
|
|
87
|
+
expect(String(threw.message)).toMatch(/account\.uid required/);
|
|
65
88
|
});
|
|
66
89
|
|
|
67
90
|
it("sync yields read + collection + search raws via mocked driver", async () => {
|
|
@@ -130,9 +153,11 @@ describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
|
|
|
130
153
|
}
|
|
131
154
|
});
|
|
132
155
|
|
|
133
|
-
it("normalize throws on missing payload
|
|
156
|
+
it("normalize throws on missing payload row + no snapshot fields (validator-friendly)", () => {
|
|
134
157
|
const a = new ToutiaoAdapter({ account: { uid: "u-1" } });
|
|
135
|
-
|
|
158
|
+
// v0.2: row-missing check moved into per-kind normalizers (snapshot
|
|
159
|
+
// payloads have no `row` but carry fields directly).
|
|
160
|
+
expect(() => a.normalize({ payload: { kind: "read" } })).toThrow(/row missing/);
|
|
136
161
|
});
|
|
137
162
|
|
|
138
163
|
it("search keyword preserved verbatim in content.title + extra.keyword", () => {
|
|
@@ -158,18 +183,35 @@ describe("ToutiaoAdapter — Phase 13.8(+) v0.1 scaffold", () => {
|
|
|
158
183
|
|
|
159
184
|
// ─── KuaishouAdapter ────────────────────────────────────────────────────
|
|
160
185
|
|
|
161
|
-
describe("KuaishouAdapter —
|
|
186
|
+
describe("KuaishouAdapter — §A8 v0.2 sqlite mode", () => {
|
|
162
187
|
it("contract conformance + sensitivity medium (entertainment preference)", () => {
|
|
163
188
|
const a = new KuaishouAdapter({ account: { uid: "u-2" } });
|
|
164
189
|
expect(assertAdapter(a).ok).toBe(true);
|
|
165
190
|
expect(a.name).toBe("social-kuaishou");
|
|
166
191
|
expect(a.extractMode).toBe("device-pull");
|
|
167
192
|
expect(a.dataDisclosure.sensitivity).toBe("medium");
|
|
193
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
194
|
+
expect(a.capabilities).toContain("sync:sqlite");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it("v0.2: account OPTIONAL at construction (snapshot mode is stateless)", () => {
|
|
198
|
+
expect(() => new KuaishouAdapter()).not.toThrow();
|
|
199
|
+
expect(() => new KuaishouAdapter({})).not.toThrow();
|
|
200
|
+
expect(() => new KuaishouAdapter({ account: {} })).not.toThrow();
|
|
168
201
|
});
|
|
169
202
|
|
|
170
|
-
it("
|
|
171
|
-
|
|
172
|
-
|
|
203
|
+
it("sqlite mode lazy-throws when account.uid missing at sync time", async () => {
|
|
204
|
+
const a = new KuaishouAdapter({ dbPath: "/no/such/path.db" });
|
|
205
|
+
let threw = null;
|
|
206
|
+
try {
|
|
207
|
+
for await (const _r of a.sync()) {
|
|
208
|
+
/* drain */
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
threw = err;
|
|
212
|
+
}
|
|
213
|
+
expect(threw).toBeTruthy();
|
|
214
|
+
expect(String(threw.message)).toMatch(/account\.uid required/);
|
|
173
215
|
});
|
|
174
216
|
|
|
175
217
|
it("sync yields watch + collect + search raws via mocked driver", async () => {
|
|
@@ -320,7 +320,7 @@ describe("AnalysisEngine emits TOTALS preamble", () => {
|
|
|
320
320
|
expect(userMsg).toContain('"persons": 512');
|
|
321
321
|
expect(userMsg).toContain('"items": 89');
|
|
322
322
|
// System prompt tells LLM to trust TOTALS for counts.
|
|
323
|
-
expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/);
|
|
323
|
+
expect(chatCalls[0][0].content).toMatch(/TOTALS.*authoritative/i);
|
|
324
324
|
});
|
|
325
325
|
|
|
326
326
|
it("intent=count for '几个联系人' and '几个 app' and '多少个 X'", () => {
|
|
@@ -53,8 +53,13 @@ describe("DouyinAdapter", () => {
|
|
|
53
53
|
expect(assertAdapter(a).ok).toBe(true);
|
|
54
54
|
});
|
|
55
55
|
|
|
56
|
-
it("
|
|
57
|
-
|
|
56
|
+
it("snapshot mode constructs without account.uid (stateless)", () => {
|
|
57
|
+
// §A8 v0.2: constructor loosened — snapshot mode pulls account from the
|
|
58
|
+
// snapshot file. Sqlite mode still requires account.uid, checked at sync
|
|
59
|
+
// time not construction (see _syncViaSqlite throw at runtime).
|
|
60
|
+
const a = new DouyinAdapter({});
|
|
61
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
62
|
+
expect(a.account).toBeNull();
|
|
58
63
|
});
|
|
59
64
|
|
|
60
65
|
it("sync yields history + favourite + search", async () => {
|
|
@@ -84,10 +89,17 @@ describe("XiaohongshuAdapter", () => {
|
|
|
84
89
|
it("contract conformance", () => {
|
|
85
90
|
const a = new XiaohongshuAdapter({ account: { uid: "u-1" } });
|
|
86
91
|
expect(assertAdapter(a).ok).toBe(true);
|
|
92
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
93
|
+
expect(a.capabilities).toContain("sync:sqlite");
|
|
87
94
|
});
|
|
88
95
|
|
|
89
|
-
it("
|
|
90
|
-
|
|
96
|
+
it("snapshot mode constructs without account.uid (stateless)", () => {
|
|
97
|
+
// §A8 v0.2: constructor loosened — snapshot mode pulls account from the
|
|
98
|
+
// snapshot file. Sqlite mode still requires account.uid, checked at sync
|
|
99
|
+
// time not construction.
|
|
100
|
+
const a = new XiaohongshuAdapter({});
|
|
101
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
102
|
+
expect(a.account).toBeNull();
|
|
91
103
|
});
|
|
92
104
|
|
|
93
105
|
it("sync yields history + likes + favourites", async () => {
|
|
@@ -118,17 +130,39 @@ describe("QQAdapter", () => {
|
|
|
118
130
|
const a = new QQAdapter({ account: { qq: "12345" } });
|
|
119
131
|
expect(assertAdapter(a).ok).toBe(true);
|
|
120
132
|
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
133
|
+
expect(a.capabilities).toContain("sync:snapshot");
|
|
134
|
+
expect(a.capabilities).toContain("sync:sqlite");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("snapshot mode constructs without account.qq (stateless)", () => {
|
|
138
|
+
// §Phase 13.5 v0.2: constructor loosened — snapshot mode pulls account
|
|
139
|
+
// from the snapshot file. Sqlite mode still requires account.qq, checked
|
|
140
|
+
// at sync time not construction. Mirror of weibo / bilibili A8 pattern.
|
|
141
|
+
const a = new QQAdapter({});
|
|
142
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
143
|
+
expect(a.account).toBeNull();
|
|
121
144
|
});
|
|
122
145
|
|
|
123
|
-
it("
|
|
124
|
-
|
|
146
|
+
it("sqlite mode throws at sync time when account.qq missing", async () => {
|
|
147
|
+
const { dir, dbPath } = tmpDb();
|
|
148
|
+
try {
|
|
149
|
+
const a = new QQAdapter({ dbPath, keyProvider: { getKey: async () => "k" } });
|
|
150
|
+
let threw = null;
|
|
151
|
+
try {
|
|
152
|
+
for await (const _r of a.sync()) { /* drain */ }
|
|
153
|
+
} catch (err) {
|
|
154
|
+
threw = err;
|
|
155
|
+
}
|
|
156
|
+
expect(threw).toBeTruthy();
|
|
157
|
+
expect(String(threw.message)).toMatch(/account\.qq/);
|
|
158
|
+
} finally { cleanup(dir); }
|
|
125
159
|
});
|
|
126
160
|
|
|
127
|
-
it("authenticate
|
|
161
|
+
it("authenticate({}) without inputPath nor dbPath returns NO_INPUT", async () => {
|
|
128
162
|
const a = new QQAdapter({ account: { qq: "12345" }, keyProvider: { getKey: async () => "k" } });
|
|
129
163
|
const r = await a.authenticate();
|
|
130
164
|
expect(r.ok).toBe(false);
|
|
131
|
-
expect(r.reason).toBe("
|
|
165
|
+
expect(r.reason).toBe("NO_INPUT");
|
|
132
166
|
});
|
|
133
167
|
|
|
134
168
|
it("authenticate fails without keyProvider", async () => {
|
|
@@ -143,27 +177,44 @@ describe("QQAdapter", () => {
|
|
|
143
177
|
it("sync yields contact + group + message types", async () => {
|
|
144
178
|
const { dir, dbPath } = tmpDb();
|
|
145
179
|
try {
|
|
180
|
+
// §Phase 13.5 v0.2 mock — new SQL targets:
|
|
181
|
+
// - Friends / friends / tb_recent_contact (probe order)
|
|
182
|
+
// - TroopInfoV2
|
|
183
|
+
// - sqlite_master LIKE 'mr_friend_%_New'
|
|
184
|
+
// - mr_friend_<MD5(peer).upper()>_New
|
|
146
185
|
const mockDriver = makeMockDriver([
|
|
147
|
-
["FROM
|
|
148
|
-
["FROM
|
|
149
|
-
["FROM sqlite_master", [{ name: "
|
|
150
|
-
[
|
|
186
|
+
["FROM Friends", [{ uin: "999", nickname: "好友A", remark: "" }]],
|
|
187
|
+
["FROM TroopInfoV2", [{ troop_uin: "888", troop_name: "测试群", member_count: 5, owner_uin: "777" }]],
|
|
188
|
+
["FROM sqlite_master", [{ name: "mr_friend_ABCDEF1234_New" }]],
|
|
189
|
+
[
|
|
190
|
+
"FROM mr_friend_ABCDEF1234_New",
|
|
191
|
+
[{
|
|
192
|
+
msgId: "m1", msgtype: -1000, senderuin: "999", time: 1700000000,
|
|
193
|
+
// msgData is XOR-encrypted bytes; with imei="123456789012345" key,
|
|
194
|
+
// "hi" encrypts to bytes [0x51, 0x5d] (0x68^0x31=0x59 — actually
|
|
195
|
+
// depends on imei[0] = '1' = 0x31). Use Buffer for cross-platform.
|
|
196
|
+
msgData: Buffer.from([0x68 ^ 0x31, 0x69 ^ 0x32]),
|
|
197
|
+
issend: 0, frienduin: "999", troopuin: null,
|
|
198
|
+
}],
|
|
199
|
+
],
|
|
151
200
|
]);
|
|
152
201
|
const a = new QQAdapter({
|
|
153
202
|
account: { qq: "12345" },
|
|
154
203
|
dbPath,
|
|
155
|
-
keyProvider: { getKey: async () => "
|
|
204
|
+
keyProvider: { getKey: async () => "123456789012345" },
|
|
156
205
|
dbDriverFactory: () => mockDriver,
|
|
157
206
|
});
|
|
158
207
|
const raws = [];
|
|
159
208
|
for await (const r of a.sync()) raws.push(r);
|
|
160
209
|
expect(raws.length).toBe(3); // contact + group + message
|
|
161
|
-
const contact = raws.find((r) => r.
|
|
162
|
-
const group = raws.find((r) => r.
|
|
163
|
-
const message = raws.find((r) => r.
|
|
210
|
+
const contact = raws.find((r) => r.kind === "contact");
|
|
211
|
+
const group = raws.find((r) => r.kind === "group");
|
|
212
|
+
const message = raws.find((r) => r.kind === "message");
|
|
164
213
|
expect(contact).toBeDefined();
|
|
165
214
|
expect(group).toBeDefined();
|
|
166
215
|
expect(message).toBeDefined();
|
|
216
|
+
// XOR-decrypt round-trip: bytes(0x68^0x31, 0x69^0x32) XOR "12" = "hi"
|
|
217
|
+
expect(message.payload.text).toBe("hi");
|
|
167
218
|
for (const r of raws) {
|
|
168
219
|
expect(validateBatch(a.normalize(r)).valid).toBe(true);
|
|
169
220
|
}
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach } 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
|
+
QQAdapter,
|
|
11
|
+
SNAPSHOT_SCHEMA_VERSION,
|
|
12
|
+
VALID_SNAPSHOT_KINDS,
|
|
13
|
+
xorDecrypt,
|
|
14
|
+
} = require("../lib/adapters/messaging-qq");
|
|
15
|
+
const { validateBatch } = require("../lib/batch");
|
|
16
|
+
|
|
17
|
+
// §Phase 13.5 v0.2 (2026-05-22) — snapshot-mode tests, mirror of
|
|
18
|
+
// social-weibo-snapshot.test.js.
|
|
19
|
+
//
|
|
20
|
+
// Snapshot mode is in-APK Android cc reading JSON written by QQLocalCollector
|
|
21
|
+
// (su cp /data/data/com.tencent.mobileqq/databases/<uin>.db + plain SQLite
|
|
22
|
+
// open + per-row XOR-with-IMEI decrypt of msgData). Sqlite/device-pull tests
|
|
23
|
+
// stay in longtail-adapters.test.js. Both paths share normalize().
|
|
24
|
+
|
|
25
|
+
function writeSnapshot(dir, snapshot) {
|
|
26
|
+
const p = path.join(dir, "messaging-qq.json");
|
|
27
|
+
fs.writeFileSync(p, JSON.stringify(snapshot), "utf-8");
|
|
28
|
+
return p;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("QQAdapter snapshot mode", () => {
|
|
32
|
+
let tmpDir;
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "qq-snap-"));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("exports SNAPSHOT_SCHEMA_VERSION = 1 + 3 VALID_SNAPSHOT_KINDS", () => {
|
|
38
|
+
expect(SNAPSHOT_SCHEMA_VERSION).toBe(1);
|
|
39
|
+
expect(VALID_SNAPSHOT_KINDS).toEqual(["contact", "group", "message"]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("authenticate(inputPath) ok when readable", async () => {
|
|
43
|
+
const p = writeSnapshot(tmpDir, {
|
|
44
|
+
schemaVersion: 1,
|
|
45
|
+
snapshottedAt: Date.now(),
|
|
46
|
+
events: [],
|
|
47
|
+
});
|
|
48
|
+
const a = new QQAdapter();
|
|
49
|
+
const res = await a.authenticate({ inputPath: p });
|
|
50
|
+
expect(res.ok).toBe(true);
|
|
51
|
+
expect(res.mode).toBe("snapshot-file");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it("authenticate(inputPath) fails when path unreadable", async () => {
|
|
55
|
+
const a = new QQAdapter();
|
|
56
|
+
const res = await a.authenticate({ inputPath: path.join(tmpDir, "missing.json") });
|
|
57
|
+
expect(res.ok).toBe(false);
|
|
58
|
+
expect(res.reason).toBe("INPUT_PATH_UNREADABLE");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("authenticate() with neither inputPath nor dbPath returns NO_INPUT", async () => {
|
|
62
|
+
const a = new QQAdapter();
|
|
63
|
+
const res = await a.authenticate({});
|
|
64
|
+
expect(res.ok).toBe(false);
|
|
65
|
+
expect(res.reason).toBe("NO_INPUT");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("rejects schemaVersion mismatch", async () => {
|
|
69
|
+
const p = writeSnapshot(tmpDir, {
|
|
70
|
+
schemaVersion: 99,
|
|
71
|
+
snapshottedAt: Date.now(),
|
|
72
|
+
events: [],
|
|
73
|
+
});
|
|
74
|
+
const a = new QQAdapter();
|
|
75
|
+
let threw = null;
|
|
76
|
+
try {
|
|
77
|
+
for await (const _r of a.sync({ inputPath: p })) { /* drain */ }
|
|
78
|
+
} catch (err) {
|
|
79
|
+
threw = err;
|
|
80
|
+
}
|
|
81
|
+
expect(threw).toBeTruthy();
|
|
82
|
+
expect(String(threw.message)).toMatch(/schemaVersion mismatch/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("empty events array yields nothing (no crash)", async () => {
|
|
86
|
+
const p = writeSnapshot(tmpDir, {
|
|
87
|
+
schemaVersion: 1,
|
|
88
|
+
snapshottedAt: Date.now(),
|
|
89
|
+
events: [],
|
|
90
|
+
});
|
|
91
|
+
const a = new QQAdapter();
|
|
92
|
+
const raws = [];
|
|
93
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
94
|
+
expect(raws.length).toBe(0);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("contact + group + message round-trip normalize cleanly", async () => {
|
|
98
|
+
const now = Date.now();
|
|
99
|
+
const p = writeSnapshot(tmpDir, {
|
|
100
|
+
schemaVersion: 1,
|
|
101
|
+
snapshottedAt: now,
|
|
102
|
+
account: { qq: "12345", displayName: "alice" },
|
|
103
|
+
events: [
|
|
104
|
+
{
|
|
105
|
+
kind: "contact",
|
|
106
|
+
id: "contact-99999",
|
|
107
|
+
capturedAt: now - 1000,
|
|
108
|
+
uin: "99999",
|
|
109
|
+
nickname: "好友A",
|
|
110
|
+
remark: "工作组",
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
kind: "group",
|
|
114
|
+
id: "group-88888",
|
|
115
|
+
capturedAt: now - 2000,
|
|
116
|
+
troopUin: "88888",
|
|
117
|
+
troopName: "测试群",
|
|
118
|
+
memberCount: 30,
|
|
119
|
+
ownerUin: "77777",
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
kind: "message",
|
|
123
|
+
id: "msg-m1",
|
|
124
|
+
capturedAt: now - 3000,
|
|
125
|
+
msgId: "m1",
|
|
126
|
+
msgType: -1000,
|
|
127
|
+
senderUin: "99999",
|
|
128
|
+
peerUin: "12345",
|
|
129
|
+
isGroup: false,
|
|
130
|
+
isSend: false,
|
|
131
|
+
text: "你好",
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
const a = new QQAdapter();
|
|
136
|
+
const raws = [];
|
|
137
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
138
|
+
expect(raws.length).toBe(3);
|
|
139
|
+
|
|
140
|
+
const kinds = raws.map((r) => r.kind);
|
|
141
|
+
expect(kinds).toEqual(["contact", "group", "message"]);
|
|
142
|
+
|
|
143
|
+
// Each originalId namespaced under qq:<kind>:<id>
|
|
144
|
+
expect(raws[0].originalId).toMatch(/^qq:contact:/);
|
|
145
|
+
expect(raws[1].originalId).toMatch(/^qq:group:/);
|
|
146
|
+
expect(raws[2].originalId).toMatch(/^qq:message:/);
|
|
147
|
+
|
|
148
|
+
// Normalize each + validate
|
|
149
|
+
for (const raw of raws) {
|
|
150
|
+
const batch = a.normalize(raw);
|
|
151
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const contactBatch = a.normalize(raws[0]);
|
|
155
|
+
expect(contactBatch.events.length).toBe(0);
|
|
156
|
+
expect(contactBatch.persons.length).toBe(1);
|
|
157
|
+
// remark + nickname + uin all surface as names (priority order)
|
|
158
|
+
expect(contactBatch.persons[0].names).toEqual(["工作组", "好友A", "99999"]);
|
|
159
|
+
expect(contactBatch.persons[0].identifiers["qq-uin"]).toEqual(["99999"]);
|
|
160
|
+
|
|
161
|
+
const groupBatch = a.normalize(raws[1]);
|
|
162
|
+
expect(groupBatch.events.length).toBe(0);
|
|
163
|
+
expect(groupBatch.topics.length).toBe(1);
|
|
164
|
+
expect(groupBatch.topics[0].name).toBe("测试群");
|
|
165
|
+
expect(groupBatch.topics[0].extra.troopUin).toBe("88888");
|
|
166
|
+
expect(groupBatch.topics[0].extra.memberCount).toBe(30);
|
|
167
|
+
|
|
168
|
+
const msgBatch = a.normalize(raws[2]);
|
|
169
|
+
expect(msgBatch.events.length).toBe(1);
|
|
170
|
+
expect(msgBatch.events[0].subtype).toBe("message");
|
|
171
|
+
expect(msgBatch.events[0].extra.peerUin).toBe("12345");
|
|
172
|
+
expect(msgBatch.events[0].extra.senderUin).toBe("99999");
|
|
173
|
+
expect(msgBatch.events[0].extra.isGroup).toBe(false);
|
|
174
|
+
expect(msgBatch.events[0].extra.isSend).toBe(false);
|
|
175
|
+
expect(msgBatch.events[0].content.text).toBe("你好");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("respects per-kind include opt-out", async () => {
|
|
179
|
+
const now = Date.now();
|
|
180
|
+
const p = writeSnapshot(tmpDir, {
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
snapshottedAt: now,
|
|
183
|
+
events: [
|
|
184
|
+
{ kind: "contact", id: "c1", capturedAt: now, uin: "100", nickname: "A" },
|
|
185
|
+
{ kind: "group", id: "g1", capturedAt: now, troopUin: "200", troopName: "G" },
|
|
186
|
+
{ kind: "message", id: "m1", capturedAt: now, msgId: "m1", text: "hi" },
|
|
187
|
+
],
|
|
188
|
+
});
|
|
189
|
+
const a = new QQAdapter();
|
|
190
|
+
const raws = [];
|
|
191
|
+
for await (const r of a.sync({ inputPath: p, include: { message: false } })) {
|
|
192
|
+
raws.push(r);
|
|
193
|
+
}
|
|
194
|
+
const kinds = raws.map((r) => r.kind);
|
|
195
|
+
expect(kinds).toEqual(["contact", "group"]);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("respects opts.limit", async () => {
|
|
199
|
+
const now = Date.now();
|
|
200
|
+
const events = Array.from({ length: 5 }, (_, i) => ({
|
|
201
|
+
kind: "message",
|
|
202
|
+
id: `m${i}`,
|
|
203
|
+
capturedAt: now - i * 100,
|
|
204
|
+
msgId: `m${i}`,
|
|
205
|
+
text: `t${i}`,
|
|
206
|
+
}));
|
|
207
|
+
const p = writeSnapshot(tmpDir, { schemaVersion: 1, snapshottedAt: now, events });
|
|
208
|
+
const a = new QQAdapter();
|
|
209
|
+
const raws = [];
|
|
210
|
+
for await (const r of a.sync({ inputPath: p, limit: 2 })) raws.push(r);
|
|
211
|
+
expect(raws.length).toBe(2);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("filters out unknown kinds (forward compat)", async () => {
|
|
215
|
+
const now = Date.now();
|
|
216
|
+
const p = writeSnapshot(tmpDir, {
|
|
217
|
+
schemaVersion: 1,
|
|
218
|
+
snapshottedAt: now,
|
|
219
|
+
events: [
|
|
220
|
+
{ kind: "contact", id: "c1", capturedAt: now, uin: "100", nickname: "A" },
|
|
221
|
+
{ kind: "future-kind", id: "x", capturedAt: now },
|
|
222
|
+
{ kind: "search", id: "s", capturedAt: now }, // not a QQ snapshot kind
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
const a = new QQAdapter();
|
|
226
|
+
const raws = [];
|
|
227
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
228
|
+
expect(raws.length).toBe(1);
|
|
229
|
+
expect(raws[0].kind).toBe("contact");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("snapshottedAt fallback when event capturedAt missing", async () => {
|
|
233
|
+
const ts = 1700000000000;
|
|
234
|
+
const p = writeSnapshot(tmpDir, {
|
|
235
|
+
schemaVersion: 1,
|
|
236
|
+
snapshottedAt: ts,
|
|
237
|
+
events: [
|
|
238
|
+
{ kind: "contact", id: "c1", uin: "100", nickname: "A" },
|
|
239
|
+
],
|
|
240
|
+
});
|
|
241
|
+
const a = new QQAdapter();
|
|
242
|
+
const raws = [];
|
|
243
|
+
for await (const r of a.sync({ inputPath: p })) raws.push(r);
|
|
244
|
+
expect(raws[0].capturedAt).toBe(ts);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("normalize handles missing identifiers gracefully (forward compat)", () => {
|
|
248
|
+
const a = new QQAdapter();
|
|
249
|
+
// Contact with empty payload — uin/nickname/remark all absent
|
|
250
|
+
const raw = {
|
|
251
|
+
adapter: "messaging-qq",
|
|
252
|
+
kind: "contact",
|
|
253
|
+
originalId: "qq:contact:unknown",
|
|
254
|
+
capturedAt: Date.now(),
|
|
255
|
+
payload: { kind: "contact" },
|
|
256
|
+
};
|
|
257
|
+
const batch = a.normalize(raw);
|
|
258
|
+
expect(batch.persons.length).toBe(1);
|
|
259
|
+
expect(batch.persons[0].names).toEqual(["(unnamed)"]);
|
|
260
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("xorDecrypt: empty input or empty key returns empty string", () => {
|
|
264
|
+
expect(xorDecrypt(null, Buffer.from("123"))).toBe("");
|
|
265
|
+
expect(xorDecrypt(Buffer.from(""), Buffer.from("123"))).toBe("");
|
|
266
|
+
expect(xorDecrypt(Buffer.from("hi"), Buffer.from(""))).toBe("");
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("xorDecrypt: ASCII round-trip via IMEI XOR (sjqz qq.py parity)", () => {
|
|
270
|
+
// Algorithm: out[i] = data[i] XOR imeiBytes[i % imeiLen]. Encryption is
|
|
271
|
+
// its own inverse: XOR-decrypt the encrypted form ⇒ original plaintext.
|
|
272
|
+
const imei = "123456789012345";
|
|
273
|
+
const plaintext = "hello";
|
|
274
|
+
const imeiBytes = Buffer.from(imei, "utf-8");
|
|
275
|
+
const ptBytes = Buffer.from(plaintext, "utf-8");
|
|
276
|
+
const encrypted = Buffer.alloc(ptBytes.length);
|
|
277
|
+
for (let i = 0; i < ptBytes.length; i++) {
|
|
278
|
+
encrypted[i] = ptBytes[i] ^ imeiBytes[i % imeiBytes.length];
|
|
279
|
+
}
|
|
280
|
+
expect(xorDecrypt(encrypted, imeiBytes)).toBe(plaintext);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("xorDecrypt: UTF-8 multibyte (Chinese) round-trip", () => {
|
|
284
|
+
const imei = "999000111222333";
|
|
285
|
+
const plaintext = "你好世界";
|
|
286
|
+
const imeiBytes = Buffer.from(imei, "utf-8");
|
|
287
|
+
const ptBytes = Buffer.from(plaintext, "utf-8");
|
|
288
|
+
const encrypted = Buffer.alloc(ptBytes.length);
|
|
289
|
+
for (let i = 0; i < ptBytes.length; i++) {
|
|
290
|
+
encrypted[i] = ptBytes[i] ^ imeiBytes[i % imeiBytes.length];
|
|
291
|
+
}
|
|
292
|
+
expect(xorDecrypt(encrypted, imeiBytes)).toBe(plaintext);
|
|
293
|
+
});
|
|
294
|
+
});
|