@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,476 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
4
|
+
|
|
5
|
+
const fs = require("node:fs");
|
|
6
|
+
const path = require("node:path");
|
|
7
|
+
const os = require("node:os");
|
|
8
|
+
const crypto = require("node:crypto");
|
|
9
|
+
|
|
10
|
+
const {
|
|
11
|
+
WechatAdapter,
|
|
12
|
+
parseWeChatContent,
|
|
13
|
+
extractWeChatKey,
|
|
14
|
+
deriveWeChatLegacyKey,
|
|
15
|
+
WeChatDBReader,
|
|
16
|
+
normalizeWeChatMessage,
|
|
17
|
+
normalizeWeChatContact,
|
|
18
|
+
wxidToWeChatPersonId,
|
|
19
|
+
isWeChatGroupTalker,
|
|
20
|
+
WECHAT_VERSION,
|
|
21
|
+
} = require("../lib/adapters/wechat");
|
|
22
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
23
|
+
const { validateBatch } = require("../lib/batch");
|
|
24
|
+
|
|
25
|
+
// ─── content-parser ──────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe("parseWeChatContent — type 1 text", () => {
|
|
28
|
+
it("plain text message", () => {
|
|
29
|
+
const r = parseWeChatContent({ type: 1, content: "你好", talker: "wxid_friend" });
|
|
30
|
+
expect(r.kind).toBe("text");
|
|
31
|
+
expect(r.text).toBe("你好");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("group message strips sender prefix", () => {
|
|
35
|
+
const r = parseWeChatContent({
|
|
36
|
+
type: 1,
|
|
37
|
+
content: "wxid_someone:\n大家好",
|
|
38
|
+
talker: "12345@chatroom",
|
|
39
|
+
});
|
|
40
|
+
expect(r.text).toBe("大家好");
|
|
41
|
+
expect(r.structured.senderWxid).toBe("wxid_someone");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("parseWeChatContent — media types", () => {
|
|
46
|
+
it("type 3 image", () => {
|
|
47
|
+
const xml = '<img cdnbigimgurl="https://x.cn/a" md5="abc123" length="12345" />';
|
|
48
|
+
const r = parseWeChatContent({ type: 3, content: xml, talker: "wxid_friend" });
|
|
49
|
+
expect(r.kind).toBe("image");
|
|
50
|
+
expect(r.text).toBe("[图片]");
|
|
51
|
+
expect(r.structured.cdnUrl).toBe("https://x.cn/a");
|
|
52
|
+
expect(r.structured.md5).toBe("abc123");
|
|
53
|
+
expect(r.structured.length).toBe(12345);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("type 34 voice", () => {
|
|
57
|
+
const xml = '<voicemsg voicelength="3000" clientmsgid="voice123" />';
|
|
58
|
+
const r = parseWeChatContent({ type: 34, content: xml, talker: "wxid_friend" });
|
|
59
|
+
expect(r.kind).toBe("voice");
|
|
60
|
+
expect(r.structured.voiceLength).toBe(3000);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it("type 48 location", () => {
|
|
64
|
+
const xml = '<location x="31.23" y="121.47" label="上海" poiname="外滩" />';
|
|
65
|
+
const r = parseWeChatContent({ type: 48, content: xml, talker: "wxid_friend" });
|
|
66
|
+
expect(r.kind).toBe("location");
|
|
67
|
+
expect(r.structured.x).toBe(31.23);
|
|
68
|
+
expect(r.structured.y).toBe(121.47);
|
|
69
|
+
expect(r.structured.label).toBe("上海");
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("parseWeChatContent — type 49 appmsg sub-types", () => {
|
|
74
|
+
it("sub 5 link", () => {
|
|
75
|
+
const xml = '<msg><appmsg type="5"><title>趣文一篇</title><des>简介</des><url>https://x.cn</url></appmsg></msg>';
|
|
76
|
+
const r = parseWeChatContent({ type: 49, content: xml, talker: "wxid_friend" });
|
|
77
|
+
expect(r.kind).toBe("link");
|
|
78
|
+
expect(r.structured.title).toBe("趣文一篇");
|
|
79
|
+
expect(r.structured.url).toBe("https://x.cn");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("sub 21 redpacket", () => {
|
|
83
|
+
const xml = '<msg><appmsg type="21"><title>恭喜发财</title></appmsg></msg>';
|
|
84
|
+
const r = parseWeChatContent({ type: 49, content: xml, talker: "wxid_friend" });
|
|
85
|
+
expect(r.kind).toBe("redpacket");
|
|
86
|
+
expect(r.structured.redPacketTitle).toBe("恭喜发财");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("sub 6 file", () => {
|
|
90
|
+
const xml = '<msg><appmsg type="6"><title>合同.pdf</title><totallen>523456</totallen></appmsg></msg>';
|
|
91
|
+
const r = parseWeChatContent({ type: 49, content: xml, talker: "wxid_friend" });
|
|
92
|
+
expect(r.kind).toBe("file");
|
|
93
|
+
expect(r.structured.fileName).toBe("合同.pdf");
|
|
94
|
+
expect(r.structured.fileSize).toBe("523456");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("parseWeChatContent — system + unknown", () => {
|
|
99
|
+
it("type 10000 system", () => {
|
|
100
|
+
const r = parseWeChatContent({ type: 10000, content: '<msg>"张三"加入了群聊</msg>', talker: "12345@chatroom" });
|
|
101
|
+
expect(r.kind).toBe("system");
|
|
102
|
+
expect(r.text).toContain("加入了群聊");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("unknown type falls through with kind=type-N", () => {
|
|
106
|
+
const r = parseWeChatContent({ type: 99999, content: "x", talker: "wxid_f" });
|
|
107
|
+
expect(r.kind).toBe("type-99999");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("invalid input is safe", () => {
|
|
111
|
+
const r = parseWeChatContent(null);
|
|
112
|
+
expect(r.kind).toBe("unknown");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ─── key-extractor ───────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
describe("deriveWeChatLegacyKey", () => {
|
|
119
|
+
it("matches MD5(IMEI+UIN)[:7]", () => {
|
|
120
|
+
const imei = "123456789012345";
|
|
121
|
+
const uin = "987654321";
|
|
122
|
+
const expected = crypto.createHash("md5").update(imei + uin, "utf-8").digest("hex").slice(0, 7);
|
|
123
|
+
expect(deriveWeChatLegacyKey(imei, uin)).toBe(expected);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("lowercase hex", () => {
|
|
127
|
+
const k = deriveWeChatLegacyKey("INFRA", "user123");
|
|
128
|
+
expect(k).toMatch(/^[0-9a-f]{7}$/);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("throws on missing inputs", () => {
|
|
132
|
+
expect(() => deriveWeChatLegacyKey("", "uin")).toThrow();
|
|
133
|
+
expect(() => deriveWeChatLegacyKey("imei", null)).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("extractWeChatKey", () => {
|
|
138
|
+
let dir;
|
|
139
|
+
beforeEach(() => {
|
|
140
|
+
dir = fs.mkdtempSync(path.join(os.tmpdir(), "wechat-key-"));
|
|
141
|
+
});
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_e) {}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function writeAuthXml(uin = "1234567890") {
|
|
147
|
+
fs.mkdirSync(path.join(dir, "shared_prefs"), { recursive: true });
|
|
148
|
+
fs.writeFileSync(
|
|
149
|
+
path.join(dir, "shared_prefs", "auth_info_key_prefs.xml"),
|
|
150
|
+
`<?xml version='1.0' encoding='utf-8'?><map><int name="_auth_uin" value="${uin}" /></map>`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function writeCompatibleInfo(imei = "123456789012345") {
|
|
155
|
+
fs.mkdirSync(path.join(dir, "MicroMsg"), { recursive: true });
|
|
156
|
+
// CompatibleInfo.cfg is a Java HashMap serialization; we just want
|
|
157
|
+
// to embed an IMEI in the binary so the regex finds it.
|
|
158
|
+
const padding = Buffer.from("padding-before-imei-\x00\x01\x02", "binary");
|
|
159
|
+
const imeiPart = Buffer.from(`\x00${imei}\x00more-after`, "binary");
|
|
160
|
+
fs.writeFileSync(
|
|
161
|
+
path.join(dir, "MicroMsg", "CompatibleInfo.cfg"),
|
|
162
|
+
Buffer.concat([padding, imeiPart]),
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
it("happy path: extracts UIN + IMEI + derives key", () => {
|
|
167
|
+
writeAuthXml("888888888");
|
|
168
|
+
writeCompatibleInfo("864000000000000");
|
|
169
|
+
const r = extractWeChatKey({ wechatDataPath: dir });
|
|
170
|
+
expect(r.uin).toBe("888888888");
|
|
171
|
+
expect(r.imei).toBe("864000000000000");
|
|
172
|
+
expect(r.key).toBeTruthy();
|
|
173
|
+
expect(r.key.length).toBe(7);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("missing UIN → key null + warning", () => {
|
|
177
|
+
writeCompatibleInfo("864000000000000");
|
|
178
|
+
const r = extractWeChatKey({ wechatDataPath: dir });
|
|
179
|
+
expect(r.key).toBeNull();
|
|
180
|
+
expect(r.warnings.some((w) => /UIN/.test(w))).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("manual override skips XML", () => {
|
|
184
|
+
const r = extractWeChatKey({
|
|
185
|
+
wechatDataPath: dir,
|
|
186
|
+
uin: "manual-uin",
|
|
187
|
+
imei: "manual-imei",
|
|
188
|
+
});
|
|
189
|
+
expect(r.uin).toBe("manual-uin");
|
|
190
|
+
expect(r.imei).toBe("manual-imei");
|
|
191
|
+
expect(r.key).toBeTruthy();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
it("requires wechatDataPath", () => {
|
|
195
|
+
expect(() => extractWeChatKey({})).toThrow();
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
// ─── isWeChatGroupTalker ────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
describe("isWeChatGroupTalker", () => {
|
|
202
|
+
it("@chatroom suffix is group", () => {
|
|
203
|
+
expect(isWeChatGroupTalker("12345678@chatroom")).toBe(true);
|
|
204
|
+
});
|
|
205
|
+
it("wxid_xxx is 1-on-1", () => {
|
|
206
|
+
expect(isWeChatGroupTalker("wxid_friend")).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
it("invalid input → false", () => {
|
|
209
|
+
expect(isWeChatGroupTalker(null)).toBe(false);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// ─── wxidToPersonId ──────────────────────────────────────────────────────
|
|
214
|
+
|
|
215
|
+
describe("wxidToWeChatPersonId", () => {
|
|
216
|
+
it("stable id format", () => {
|
|
217
|
+
expect(wxidToWeChatPersonId("wxid_friend")).toBe("person-wechat-wxid_friend");
|
|
218
|
+
});
|
|
219
|
+
it("null wxid → null", () => {
|
|
220
|
+
expect(wxidToWeChatPersonId(null)).toBeNull();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// ─── normalizeMessage ────────────────────────────────────────────────────
|
|
225
|
+
|
|
226
|
+
describe("normalizeWeChatMessage", () => {
|
|
227
|
+
it("1-on-1 text inbound", () => {
|
|
228
|
+
const row = {
|
|
229
|
+
msgId: 1, msgSvrId: 100, talker: "wxid_friend",
|
|
230
|
+
content: "你好", type: 1, createTime: Date.now(), isSend: 0,
|
|
231
|
+
};
|
|
232
|
+
const b = normalizeWeChatMessage(row, { accountUin: "self123" });
|
|
233
|
+
expect(b.events).toHaveLength(1);
|
|
234
|
+
expect(b.events[0].subtype).toBe("message");
|
|
235
|
+
expect(b.events[0].actor).toBe("person-wechat-wxid_friend");
|
|
236
|
+
expect(b.events[0].content.text).toBe("你好");
|
|
237
|
+
expect(b.persons).toHaveLength(1);
|
|
238
|
+
expect(b.persons[0].identifiers.wechatId).toBe("wxid_friend");
|
|
239
|
+
const v = validateBatch(b);
|
|
240
|
+
expect(v.valid).toBe(true);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("1-on-1 text outbound", () => {
|
|
244
|
+
const row = {
|
|
245
|
+
msgSvrId: 101, talker: "wxid_friend",
|
|
246
|
+
content: "你好", type: 1, createTime: Date.now(), isSend: 1,
|
|
247
|
+
};
|
|
248
|
+
const b = normalizeWeChatMessage(row, { accountUin: "self123" });
|
|
249
|
+
expect(b.events[0].actor).toBe("person-wechat-self123");
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it("group message produces Topic + isGroup extra", () => {
|
|
253
|
+
const row = {
|
|
254
|
+
msgSvrId: 102, talker: "1234@chatroom",
|
|
255
|
+
content: "wxid_friend:\n大家好", type: 1, createTime: Date.now(), isSend: 0,
|
|
256
|
+
};
|
|
257
|
+
const b = normalizeWeChatMessage(row, {
|
|
258
|
+
accountUin: "self123",
|
|
259
|
+
chatroomByName: { "1234@chatroom": "测试群" },
|
|
260
|
+
});
|
|
261
|
+
expect(b.events[0].extra.isGroup).toBe(true);
|
|
262
|
+
expect(b.topics).toHaveLength(1);
|
|
263
|
+
expect(b.topics[0].name).toBe("测试群");
|
|
264
|
+
expect(b.events[0].actor).toBe("person-wechat-wxid_friend");
|
|
265
|
+
// 2 persons: the peer chatroom (subtype:unknown — not really a person but kept for ref) + sender
|
|
266
|
+
expect(b.persons.length).toBeGreaterThanOrEqual(1);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("media types map to media subtype", () => {
|
|
270
|
+
const row = {
|
|
271
|
+
msgSvrId: 103, talker: "wxid_friend",
|
|
272
|
+
content: '<img cdnbigimgurl="https://x.cn/i" md5="x" />',
|
|
273
|
+
type: 3, createTime: Date.now(), isSend: 0,
|
|
274
|
+
};
|
|
275
|
+
const b = normalizeWeChatMessage(row, { accountUin: "self123" });
|
|
276
|
+
expect(b.events[0].subtype).toBe("media");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("redpacket appmsg maps to redenvelope", () => {
|
|
280
|
+
const row = {
|
|
281
|
+
msgSvrId: 104, talker: "wxid_friend",
|
|
282
|
+
content: '<msg><appmsg type="21"><title>红包</title></appmsg></msg>',
|
|
283
|
+
type: 49, createTime: Date.now(), isSend: 0,
|
|
284
|
+
};
|
|
285
|
+
const b = normalizeWeChatMessage(row, { accountUin: "self123" });
|
|
286
|
+
expect(b.events[0].subtype).toBe("redenvelope");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("system messages map to interaction subtype", () => {
|
|
290
|
+
const row = {
|
|
291
|
+
msgSvrId: 105, talker: "1234@chatroom",
|
|
292
|
+
content: '"张三"加入了群聊',
|
|
293
|
+
type: 10000, createTime: Date.now(), isSend: 0,
|
|
294
|
+
};
|
|
295
|
+
const b = normalizeWeChatMessage(row, { accountUin: "self123" });
|
|
296
|
+
expect(b.events[0].subtype).toBe("interaction");
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("rejects missing row", () => {
|
|
300
|
+
expect(() => normalizeWeChatMessage(null)).toThrow();
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ─── normalizeContact ───────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
describe("normalizeWeChatContact", () => {
|
|
307
|
+
it("produces Person with names from conRemark / nickname / alias", () => {
|
|
308
|
+
const b = normalizeWeChatContact({
|
|
309
|
+
username: "wxid_mom",
|
|
310
|
+
alias: "mom123",
|
|
311
|
+
nickname: "妈",
|
|
312
|
+
conRemark: "亲妈",
|
|
313
|
+
type: 1,
|
|
314
|
+
});
|
|
315
|
+
expect(b.persons).toHaveLength(1);
|
|
316
|
+
expect(b.persons[0].names).toContain("亲妈");
|
|
317
|
+
expect(b.persons[0].names).toContain("妈");
|
|
318
|
+
expect(b.persons[0].identifiers.wechatId).toBe("wxid_mom");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
it("chatroom username → subtype unknown (not a real Person)", () => {
|
|
322
|
+
const b = normalizeWeChatContact({
|
|
323
|
+
username: "12345@chatroom",
|
|
324
|
+
nickname: "群名",
|
|
325
|
+
type: 2,
|
|
326
|
+
});
|
|
327
|
+
expect(b.persons[0].subtype).toBe("unknown");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it("missing username → empty batch", () => {
|
|
331
|
+
const b = normalizeWeChatContact({});
|
|
332
|
+
expect(b.persons).toHaveLength(0);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
// ─── WechatAdapter contract + sync flow ──────────────────────────────────
|
|
337
|
+
|
|
338
|
+
describe("WechatAdapter contract", () => {
|
|
339
|
+
it("conforms to PersonalDataAdapter spec", () => {
|
|
340
|
+
const a = new WechatAdapter({ account: { uin: "test-uin" } });
|
|
341
|
+
const r = assertAdapter(a);
|
|
342
|
+
expect(r.ok).toBe(true);
|
|
343
|
+
if (!r.ok) console.log(r.errors);
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("name/version/extractMode/capabilities", () => {
|
|
347
|
+
const a = new WechatAdapter({ account: { uin: "test-uin" } });
|
|
348
|
+
expect(a.name).toBe("wechat");
|
|
349
|
+
expect(a.version).toBe(WECHAT_VERSION);
|
|
350
|
+
expect(a.extractMode).toBe("device-pull");
|
|
351
|
+
expect(a.capabilities).toContain("sync:sqlite");
|
|
352
|
+
expect(a.capabilities).toContain("decrypt:sqlcipher-v1");
|
|
353
|
+
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
it("rejects missing account", () => {
|
|
357
|
+
expect(() => new WechatAdapter()).toThrow();
|
|
358
|
+
expect(() => new WechatAdapter({})).toThrow(/account/);
|
|
359
|
+
expect(() => new WechatAdapter({ account: {} })).toThrow(/uin/);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
it("authenticate fails without DB", async () => {
|
|
363
|
+
const a = new WechatAdapter({
|
|
364
|
+
account: { uin: "test-uin" },
|
|
365
|
+
keyProvider: { getKey: async () => "fakekey" },
|
|
366
|
+
});
|
|
367
|
+
const r = await a.authenticate();
|
|
368
|
+
expect(r.ok).toBe(false);
|
|
369
|
+
expect(r.reason).toBe("DB_NOT_PULLED");
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("authenticate fails without keyProvider", async () => {
|
|
373
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wechat-"));
|
|
374
|
+
const dbPath = path.join(dir, "fake.db");
|
|
375
|
+
fs.writeFileSync(dbPath, "fake");
|
|
376
|
+
try {
|
|
377
|
+
const a = new WechatAdapter({ account: { uin: "test-uin" }, dbPath });
|
|
378
|
+
const r = await a.authenticate();
|
|
379
|
+
expect(r.ok).toBe(false);
|
|
380
|
+
expect(r.reason).toBe("NO_KEY_PROVIDER");
|
|
381
|
+
} finally {
|
|
382
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe("WechatAdapter.sync with mocked DB reader", () => {
|
|
388
|
+
it("yields contact + message raw events", async () => {
|
|
389
|
+
const fakeMessages = [
|
|
390
|
+
{ msgId: 1, msgSvrId: 100, talker: "wxid_friend", content: "你好", type: 1, createTime: 1700000000000, isSend: 0 },
|
|
391
|
+
{ msgId: 2, msgSvrId: 101, talker: "wxid_friend", content: "再见", type: 1, createTime: 1700000001000, isSend: 1 },
|
|
392
|
+
];
|
|
393
|
+
const fakeContacts = [
|
|
394
|
+
{ username: "wxid_friend", alias: "x", nickname: "好友", conRemark: "", type: 1 },
|
|
395
|
+
];
|
|
396
|
+
const fakeChatrooms = [];
|
|
397
|
+
|
|
398
|
+
let openCalled = false;
|
|
399
|
+
const dbReaderFactory = (opts) => ({
|
|
400
|
+
open: async () => { openCalled = true; return { profile: "wcdb-legacy", tables: 5 }; },
|
|
401
|
+
isEnMicroMsg: () => true,
|
|
402
|
+
listTables: () => ["message", "rcontact"],
|
|
403
|
+
fetchContacts: () => fakeContacts,
|
|
404
|
+
fetchChatrooms: () => fakeChatrooms,
|
|
405
|
+
fetchMessages: () => fakeMessages,
|
|
406
|
+
close: () => {},
|
|
407
|
+
profile: () => "wcdb-legacy",
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wechat-"));
|
|
411
|
+
const dbPath = path.join(dir, "EnMicroMsg.db");
|
|
412
|
+
fs.writeFileSync(dbPath, "fake-db-bytes");
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const events = [];
|
|
416
|
+
const a = new WechatAdapter({
|
|
417
|
+
account: { uin: "self123" },
|
|
418
|
+
dbPath,
|
|
419
|
+
keyProvider: { getKey: async () => "fakekey" },
|
|
420
|
+
dbReaderFactory,
|
|
421
|
+
});
|
|
422
|
+
const raws = [];
|
|
423
|
+
for await (const r of a.sync({ onProgress: (e) => events.push(e.phase) })) raws.push(r);
|
|
424
|
+
expect(openCalled).toBe(true);
|
|
425
|
+
// 1 contact + 2 messages
|
|
426
|
+
expect(raws).toHaveLength(3);
|
|
427
|
+
expect(raws[0].payload.kind).toBe("contact");
|
|
428
|
+
expect(raws[1].payload.kind).toBe("message");
|
|
429
|
+
expect(events).toContain("opening");
|
|
430
|
+
expect(events).toContain("opened");
|
|
431
|
+
expect(events).toContain("done");
|
|
432
|
+
|
|
433
|
+
// Now normalize each raw and verify they pass schema
|
|
434
|
+
for (const raw of raws) {
|
|
435
|
+
const batch = a.normalize(raw);
|
|
436
|
+
const v = validateBatch(batch);
|
|
437
|
+
expect(v.valid).toBe(true);
|
|
438
|
+
}
|
|
439
|
+
} finally {
|
|
440
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
441
|
+
}
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
it("idle no-op when DB path missing", async () => {
|
|
445
|
+
const a = new WechatAdapter({
|
|
446
|
+
account: { uin: "self123" },
|
|
447
|
+
keyProvider: { getKey: async () => "fakekey" },
|
|
448
|
+
});
|
|
449
|
+
const raws = [];
|
|
450
|
+
for await (const r of a.sync()) raws.push(r);
|
|
451
|
+
expect(raws).toHaveLength(0);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
it("aborts gracefully when DB doesn't look like EnMicroMsg", async () => {
|
|
455
|
+
const dbReaderFactory = () => ({
|
|
456
|
+
open: async () => ({ profile: "wcdb-legacy", tables: 0 }),
|
|
457
|
+
isEnMicroMsg: () => false,
|
|
458
|
+
close: () => {},
|
|
459
|
+
});
|
|
460
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wechat-"));
|
|
461
|
+
const dbPath = path.join(dir, "not-wechat.db");
|
|
462
|
+
fs.writeFileSync(dbPath, "fake");
|
|
463
|
+
try {
|
|
464
|
+
const a = new WechatAdapter({
|
|
465
|
+
account: { uin: "self123" },
|
|
466
|
+
dbPath, keyProvider: { getKey: async () => "fakekey" },
|
|
467
|
+
dbReaderFactory,
|
|
468
|
+
});
|
|
469
|
+
const raws = [];
|
|
470
|
+
for await (const r of a.sync()) raws.push(r);
|
|
471
|
+
expect(raws).toHaveLength(0);
|
|
472
|
+
} finally {
|
|
473
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
});
|
|
@@ -0,0 +1,135 @@
|
|
|
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 { WhatsAppAdapter } = require("../lib");
|
|
10
|
+
const { assertAdapter } = require("../lib/adapter-spec");
|
|
11
|
+
const { validateBatch } = require("../lib/batch");
|
|
12
|
+
|
|
13
|
+
function makeMockDriver(scriptedRows) {
|
|
14
|
+
return function () {
|
|
15
|
+
return {
|
|
16
|
+
prepare(sql) {
|
|
17
|
+
return {
|
|
18
|
+
all() {
|
|
19
|
+
for (const [matchSubstr, rows] of scriptedRows) {
|
|
20
|
+
if (sql.includes(matchSubstr)) return rows;
|
|
21
|
+
}
|
|
22
|
+
throw new Error("no such table");
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
},
|
|
26
|
+
close() {},
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function tmpDb() {
|
|
32
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "wa-"));
|
|
33
|
+
const dbPath = path.join(dir, "msgstore.db");
|
|
34
|
+
fs.writeFileSync(dbPath, "fake");
|
|
35
|
+
return { dir, dbPath };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe("WhatsAppAdapter", () => {
|
|
39
|
+
it("contract conformance", () => {
|
|
40
|
+
const a = new WhatsAppAdapter({ account: { phone: "13800001111" } });
|
|
41
|
+
expect(assertAdapter(a).ok).toBe(true);
|
|
42
|
+
expect(a.extractMode).toBe("device-pull");
|
|
43
|
+
expect(a.dataDisclosure.legalGate).toBe(true);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("rejects missing account.phone", () => {
|
|
47
|
+
expect(() => new WhatsAppAdapter({ account: {} })).toThrow(/phone/);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("authenticate fails without DB", async () => {
|
|
51
|
+
const a = new WhatsAppAdapter({ account: { phone: "1" } });
|
|
52
|
+
const r = await a.authenticate();
|
|
53
|
+
expect(r.ok).toBe(false);
|
|
54
|
+
expect(r.reason).toBe("DB_NOT_PULLED");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("sync yields contact + chat + message + call", async () => {
|
|
58
|
+
const { dir, dbPath } = tmpDb();
|
|
59
|
+
try {
|
|
60
|
+
const mockDriver = makeMockDriver([
|
|
61
|
+
["FROM jid", [
|
|
62
|
+
{ _id: 1, user: "13800001111", display_name: "Alice", raw_string: "13800001111@s.whatsapp.net" },
|
|
63
|
+
{ _id: 2, user: "group", display_name: "Group A", raw_string: "123456@g.us" },
|
|
64
|
+
]],
|
|
65
|
+
["FROM chat", [{ _id: 1, subject: "Chat 1" }]],
|
|
66
|
+
["FROM message", [
|
|
67
|
+
{ _id: 1, key_remote_jid: "1", text_data: "Hi", timestamp: 1700000000, from_me: 0 },
|
|
68
|
+
]],
|
|
69
|
+
["FROM messages", []],
|
|
70
|
+
["FROM call_log", [
|
|
71
|
+
{ _id: 1, jid_row_id: 1, video_call: 0, duration: 60, from_me: 1, timestamp: 1700001000, call_result: "completed" },
|
|
72
|
+
]],
|
|
73
|
+
]);
|
|
74
|
+
const a = new WhatsAppAdapter({
|
|
75
|
+
account: { phone: "13800001111" },
|
|
76
|
+
dbPath,
|
|
77
|
+
dbDriverFactory: () => mockDriver,
|
|
78
|
+
});
|
|
79
|
+
const raws = [];
|
|
80
|
+
for await (const r of a.sync()) raws.push(r);
|
|
81
|
+
// 2 jid contacts + 1 chat + 1 message + 1 call = 5
|
|
82
|
+
expect(raws.length).toBe(5);
|
|
83
|
+
const contacts = raws.filter((r) => r.payload.kind === "contact");
|
|
84
|
+
const calls = raws.filter((r) => r.payload.kind === "call");
|
|
85
|
+
expect(contacts).toHaveLength(2);
|
|
86
|
+
expect(calls).toHaveLength(1);
|
|
87
|
+
|
|
88
|
+
// Normalize all + validate
|
|
89
|
+
for (const r of raws) {
|
|
90
|
+
const batch = a.normalize(r);
|
|
91
|
+
expect(validateBatch(batch).valid).toBe(true);
|
|
92
|
+
}
|
|
93
|
+
} finally {
|
|
94
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
95
|
+
}
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("group jid → Topic not Person", async () => {
|
|
99
|
+
const a = new WhatsAppAdapter({ account: { phone: "1" } });
|
|
100
|
+
const batch = a.normalize({
|
|
101
|
+
adapter: "messaging-whatsapp",
|
|
102
|
+
originalId: "jid-2",
|
|
103
|
+
capturedAt: Date.now(),
|
|
104
|
+
payload: { kind: "contact", row: { _id: 2, raw_string: "123456@g.us", display_name: "Group A" } },
|
|
105
|
+
});
|
|
106
|
+
expect(batch.topics).toHaveLength(1);
|
|
107
|
+
expect(batch.persons).toHaveLength(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("individual jid → Person with phone identifier", async () => {
|
|
111
|
+
const a = new WhatsAppAdapter({ account: { phone: "1" } });
|
|
112
|
+
const batch = a.normalize({
|
|
113
|
+
adapter: "messaging-whatsapp",
|
|
114
|
+
originalId: "jid-1",
|
|
115
|
+
capturedAt: Date.now(),
|
|
116
|
+
payload: { kind: "contact", row: { _id: 1, user: "13800001111", display_name: "Alice", raw_string: "13800001111@s.whatsapp.net" } },
|
|
117
|
+
});
|
|
118
|
+
expect(batch.persons).toHaveLength(1);
|
|
119
|
+
expect(batch.persons[0].identifiers.phone).toEqual(["13800001111"]);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("call event captures duration + video flag", async () => {
|
|
123
|
+
const a = new WhatsAppAdapter({ account: { phone: "1" } });
|
|
124
|
+
const batch = a.normalize({
|
|
125
|
+
adapter: "messaging-whatsapp",
|
|
126
|
+
originalId: "call-1",
|
|
127
|
+
capturedAt: Date.now(),
|
|
128
|
+
payload: { kind: "call", row: { _id: 1, jid_row_id: 1, video_call: 1, duration: 300, from_me: 1, timestamp: 1700001000 } },
|
|
129
|
+
});
|
|
130
|
+
expect(batch.events[0].subtype).toBe("call");
|
|
131
|
+
expect(batch.events[0].extra.duration).toBe(300);
|
|
132
|
+
expect(batch.events[0].extra.isVideo).toBe(true);
|
|
133
|
+
expect(batch.events[0].extra.fromMe).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
});
|
package/lib/adapter-spec.js
CHANGED
|
@@ -69,6 +69,8 @@ function isAsyncIterableProducer(fn) {
|
|
|
69
69
|
* malformed adapter is rejected at registration time rather than failing
|
|
70
70
|
* mysteriously mid-sync.
|
|
71
71
|
*/
|
|
72
|
+
const EXTRACT_MODES = ["web-api", "device-pull", "file-import"];
|
|
73
|
+
|
|
72
74
|
function assertAdapter(a) {
|
|
73
75
|
const errors = [];
|
|
74
76
|
|
|
@@ -85,6 +87,15 @@ function assertAdapter(a) {
|
|
|
85
87
|
errors.push("capabilities must be a (possibly empty) array of strings");
|
|
86
88
|
}
|
|
87
89
|
|
|
90
|
+
// Phase 7.5: extractMode — optional, defaults "web-api". Adapters that
|
|
91
|
+
// pull from a mobile device declare "device-pull" so the registry can
|
|
92
|
+
// gate sync on device-connection state.
|
|
93
|
+
if (a.extractMode !== undefined) {
|
|
94
|
+
if (!EXTRACT_MODES.includes(a.extractMode)) {
|
|
95
|
+
errors.push(`extractMode must be one of ${EXTRACT_MODES.join("|")} when present`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
// Rate limits (optional, but if present must be an object with numeric fields)
|
|
89
100
|
if (a.rateLimits !== undefined) {
|
|
90
101
|
const rl = a.rateLimits;
|
|
@@ -151,5 +162,6 @@ function toError(thrown, context) {
|
|
|
151
162
|
module.exports = {
|
|
152
163
|
SENSITIVITY_LEVELS,
|
|
153
164
|
assertAdapter,
|
|
165
|
+
EXTRACT_MODES,
|
|
154
166
|
toError,
|
|
155
167
|
};
|