whalibmob 5.5.39 → 5.5.40

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.
@@ -0,0 +1,759 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * HistorySyncHandler — full WhatsApp history sync for whalibmob mobile client.
5
+ *
6
+ * Flow:
7
+ * 1. Server sends encrypted <message> containing ProtocolMessage (type=6)
8
+ * with HistorySyncNotification in field 6.
9
+ * 2. Client decrypts via Signal; MessageProto decoder returns:
10
+ * { type:'protocol', subtype:'history_sync', historySyncNotification: {...} }
11
+ * 3. Client calls processHistorySyncNotification() which:
12
+ * a) Uses inline payload if present, otherwise downloads CDN blob
13
+ * b) Decrypts with AES-256-CBC (HKDF key "WhatsApp History Keys")
14
+ * c) Inflates zlib
15
+ * d) Decodes HistorySync protobuf
16
+ * e) Persists to {phone}.history.json + {phone}.messages.json
17
+ * 4. Returns result; Client emits 'history_sync' event.
18
+ *
19
+ * App-state sync key share (ProtocolMessage type=5):
20
+ * Keys are saved to {phone}.appStateKeys.json for later use in app-state decryption.
21
+ *
22
+ * Persistence files (all in sessionDir):
23
+ * {phone}.history.json — chats, contacts, push names, LID↔PN mappings
24
+ * {phone}.messages.json — flat map of msgId → message metadata
25
+ * {phone}.appStateKeys.json — app-state sync keys received from server
26
+ */
27
+
28
+ const crypto = require('crypto');
29
+ const https = require('https');
30
+ const http = require('http');
31
+ const zlib = require('zlib');
32
+ const fs = require('fs');
33
+ const path = require('path');
34
+
35
+ const { hkdf } = require('@noble/hashes/hkdf');
36
+ const { sha256 } = require('@noble/hashes/sha256');
37
+
38
+ // ─── Constants ────────────────────────────────────────────────────────────────
39
+
40
+ const HISTORY_HKDF_INFO = 'WhatsApp History Keys';
41
+ const EXPANDED_SIZE = 112;
42
+ const IV_LEN = 16;
43
+ const KEY_LEN = 32;
44
+ const MAC_LEN = 10;
45
+ const CDN_HOST = 'mmg.whatsapp.net';
46
+
47
+ /** HistorySync.HistorySyncType enum — matches WhatsApp proto definition */
48
+ const HistorySyncType = {
49
+ INITIAL_BOOTSTRAP: 0,
50
+ INITIAL_STATUS_V3: 1,
51
+ FULL: 2,
52
+ RECENT: 3,
53
+ PUSH_NAME: 4,
54
+ NON_BLOCKING_DATA: 5,
55
+ ON_DEMAND: 6
56
+ };
57
+
58
+ const HistorySyncTypeName = Object.fromEntries(
59
+ Object.entries(HistorySyncType).map(([k, v]) => [v, k])
60
+ );
61
+
62
+ // ─── Mini protobuf helpers (no external deps) ─────────────────────────────────
63
+
64
+ function _readVarint(buf, offset) {
65
+ let result = 0, shift = 0;
66
+ while (offset < buf.length) {
67
+ const b = buf[offset++];
68
+ result += (b & 0x7f) * Math.pow(2, shift);
69
+ shift += 7;
70
+ if (!(b & 0x80)) break;
71
+ }
72
+ return { value: result, offset };
73
+ }
74
+
75
+ function _decodeFields(buf) {
76
+ const fields = {};
77
+ let offset = 0;
78
+ while (offset < buf.length) {
79
+ const tr = _readVarint(buf, offset);
80
+ if (!tr || tr.offset === offset) break;
81
+ offset = tr.offset;
82
+ const fieldNum = tr.value >> 3;
83
+ const wireType = tr.value & 0x7;
84
+ if (wireType === 0) {
85
+ const vr = _readVarint(buf, offset);
86
+ fields[fieldNum] = (fields[fieldNum] !== undefined)
87
+ ? [].concat(fields[fieldNum], vr.value)
88
+ : vr.value;
89
+ offset = vr.offset;
90
+ } else if (wireType === 2) {
91
+ const lr = _readVarint(buf, offset);
92
+ offset = lr.offset;
93
+ const data = buf.slice(offset, offset + lr.value);
94
+ offset += lr.value;
95
+ if (fields[fieldNum] !== undefined) {
96
+ if (!Array.isArray(fields[fieldNum])) fields[fieldNum] = [fields[fieldNum]];
97
+ fields[fieldNum].push(data);
98
+ } else {
99
+ fields[fieldNum] = data;
100
+ }
101
+ } else if (wireType === 1) {
102
+ fields[fieldNum] = buf.slice(offset, offset + 8);
103
+ offset += 8;
104
+ } else if (wireType === 5) {
105
+ offset += 4;
106
+ } else {
107
+ break;
108
+ }
109
+ }
110
+ return fields;
111
+ }
112
+
113
+ function _str(buf) {
114
+ if (!buf) return '';
115
+ if (typeof buf === 'string') return buf;
116
+ try { return buf.toString('utf8'); } catch (_) { return ''; }
117
+ }
118
+
119
+ function _uint64(val) {
120
+ if (typeof val === 'number') return val;
121
+ if (typeof val === 'bigint') return Number(val);
122
+ return 0;
123
+ }
124
+
125
+ // ─── HistorySyncNotification proto decoder ────────────────────────────────────
126
+ //
127
+ // HistorySyncNotification {
128
+ // bytes fileSha256 = 1;
129
+ // uint64 fileLength = 2;
130
+ // bytes mediaKey = 3;
131
+ // bytes fileEncSha256 = 4;
132
+ // string directPath = 5;
133
+ // HistorySyncType syncType = 6;
134
+ // uint32 chunkOrder = 7;
135
+ // string originalMessageId = 8;
136
+ // uint32 progress = 9;
137
+ // bytes initialHistBootstrapInlinePayload = 10;
138
+ // }
139
+
140
+ function decodeHistorySyncNotification(buf) {
141
+ if (!buf || !buf.length) return null;
142
+ try {
143
+ const f = _decodeFields(buf);
144
+ return {
145
+ fileSha256: f[1] || null,
146
+ fileLength: _uint64(f[2]),
147
+ mediaKey: f[3] || null,
148
+ fileEncSha256: f[4] || null,
149
+ directPath: _str(f[5]),
150
+ syncType: typeof f[6] === 'number' ? f[6] : 0,
151
+ chunkOrder: typeof f[7] === 'number' ? f[7] : 0,
152
+ originalMessageId: _str(f[8]),
153
+ progress: typeof f[9] === 'number' ? f[9] : 0,
154
+ initialHistBootstrapInlinePayload: f[10] || null
155
+ };
156
+ } catch (e) {
157
+ return null;
158
+ }
159
+ }
160
+
161
+ // ─── AppStateSyncKeyShare decoder ─────────────────────────────────────────────
162
+ //
163
+ // AppStateSyncKeyShare { repeated AppStateSyncKeyData keys=1 }
164
+ // AppStateSyncKeyData { AppStateSyncKeyId keyId=1; AppStateSyncKey data=2 }
165
+ // AppStateSyncKeyId { bytes keyId=1 }
166
+ // AppStateSyncKey { bytes keyData=1; bytes fingerprint=2; int64 timestamp=3 }
167
+
168
+ function decodeAppStateSyncKeyShare(buf) {
169
+ if (!buf || !Buffer.isBuffer(buf)) return null;
170
+ try {
171
+ const f = _decodeFields(buf);
172
+ const keys = [];
173
+ if (f[1]) {
174
+ const kBufs = Array.isArray(f[1]) ? f[1] : [f[1]];
175
+ for (const kb of kBufs) {
176
+ if (!Buffer.isBuffer(kb)) continue;
177
+ const kf = _decodeFields(kb);
178
+ const idFields = kf[1] ? _decodeFields(kf[1]) : null;
179
+ const dataFields = kf[2] ? _decodeFields(kf[2]) : null;
180
+ const keyId = idFields ? (idFields[1] ? Buffer.from(idFields[1]).toString('hex') : null) : null;
181
+ const keyData = dataFields ? (dataFields[1] ? Buffer.from(dataFields[1]) : null) : null;
182
+ const ts = dataFields ? _uint64(dataFields[3]) : 0;
183
+ if (keyId && keyData) keys.push({ keyId, keyData, timestamp: ts });
184
+ }
185
+ }
186
+ return { keys };
187
+ } catch (_) { return null; }
188
+ }
189
+
190
+ // ─── HistorySync proto decoder ────────────────────────────────────────────────
191
+
192
+ function decodeMessageKey(buf) {
193
+ if (!buf || !Buffer.isBuffer(buf)) return null;
194
+ try {
195
+ const f = _decodeFields(buf);
196
+ return {
197
+ remoteJid: _str(f[1]),
198
+ fromMe: !!f[2],
199
+ id: _str(f[3]),
200
+ participant: _str(f[4]) || undefined
201
+ };
202
+ } catch (_) { return null; }
203
+ }
204
+
205
+ function decodeWebMessageInfo(buf) {
206
+ if (!buf || !Buffer.isBuffer(buf)) return null;
207
+ try {
208
+ const f = _decodeFields(buf);
209
+ return {
210
+ key: decodeMessageKey(f[1]),
211
+ messageTimestamp: _uint64(f[3]),
212
+ status: typeof f[4] === 'number' ? f[4] : 0,
213
+ pushName: _str(f[5]) || undefined
214
+ };
215
+ } catch (_) { return null; }
216
+ }
217
+
218
+ function decodeHistorySyncMsg(buf) {
219
+ if (!buf || !Buffer.isBuffer(buf)) return null;
220
+ try {
221
+ const f = _decodeFields(buf);
222
+ if (!f[1]) return null;
223
+ return decodeWebMessageInfo(f[1]);
224
+ } catch (_) { return null; }
225
+ }
226
+
227
+ //
228
+ // Conversation — field numbers verified against WAProto.proto v2.3000.1029496320
229
+ //
230
+ // string id = 1
231
+ // HistorySyncMsg[] messages = 2
232
+ // string newJid = 3
233
+ // string oldJid = 4
234
+ // uint64 lastMsgTimestamp = 5
235
+ // uint32 unreadCount = 6
236
+ // bool readOnly = 7
237
+ // bool endOfHistoryTransfer = 8
238
+ // uint32 ephemeralExpiration = 9 <-- was 17 (WRONG old proto)
239
+ // int64 ephemeralSettingTimestamp = 10
240
+ // EndOfHistoryTransferType = 11
241
+ // uint64 conversationTimestamp = 12
242
+ // string name = 13 <-- was 11 (WRONG old proto)
243
+ // string pHash = 14
244
+ // bool notSpam = 15
245
+ // bool archived = 16
246
+ // DisappearingMode disappearingMode = 17
247
+ // uint32 unreadMentionCount = 18
248
+ // bool markedAsUnread = 19
249
+ // GroupParticipant participant[] = 20
250
+ // bytes tcToken = 21 <-- NEW, was missing
251
+ // uint64 tcTokenTimestamp = 22 <-- NEW, was missing
252
+ // bytes contactPrimaryIdentityKey = 23
253
+ // uint32 pinned = 24
254
+ // uint64 muteEndTime = 25
255
+ // WallpaperSettings wallpaper = 26
256
+ // MediaVisibility mediaVisibility = 27
257
+ // uint64 tcTokenSenderTimestamp = 28 <-- NEW (was wrongly used as 'username')
258
+ // bool suspended = 29
259
+ // bool terminated = 30
260
+ // uint64 createdAt = 31
261
+ // string createdBy = 32
262
+ // string description = 33
263
+ // bool support = 34
264
+ // bool isParentGroup = 35
265
+ // bool isDefaultSubgroup = 36
266
+ // string parentGroupId = 37
267
+ // string displayName = 38 <-- was 12 (WRONG old proto)
268
+ // string pnJid = 39 <-- was 18 (WRONG old proto)
269
+ // bool shareOwnPn = 40
270
+ // bool pnhDuplicateLidThread = 41
271
+ // string lidJid = 42 <-- was 19/24 (WRONG old proto)
272
+ // string username = 43 <-- was 28 (WRONG — collided with tcTokenSenderTimestamp!)
273
+ // string lidOriginType = 44
274
+ //
275
+ function decodeConversation(buf) {
276
+ if (!buf || !Buffer.isBuffer(buf)) return null;
277
+ try {
278
+ const f = _decodeFields(buf);
279
+ const messages = [];
280
+ if (f[2]) {
281
+ const msgBufs = Array.isArray(f[2]) ? f[2] : [f[2]];
282
+ for (const mb of msgBufs) {
283
+ if (Buffer.isBuffer(mb)) {
284
+ const m = decodeHistorySyncMsg(mb);
285
+ if (m) messages.push(m);
286
+ }
287
+ }
288
+ }
289
+ return {
290
+ id: _str(f[1]),
291
+ messages,
292
+ newJid: _str(f[3]) || undefined,
293
+ oldJid: _str(f[4]) || undefined,
294
+ lastMsgTimestamp: _uint64(f[5]),
295
+ unreadCount: typeof f[6] === 'number' ? f[6] : 0,
296
+
297
+ // Ephemeral: field 9 in v2.3000.x (was 17 in old proto — FIXED)
298
+ ephemeralExpiry: typeof f[9] === 'number' ? f[9] : undefined,
299
+
300
+ // Name / display: fields 13 and 38 in v2.3000.x (were 11/12 — FIXED)
301
+ name: _str(f[13]) || undefined,
302
+ displayName: _str(f[38]) || undefined,
303
+
304
+ // tcToken fields — all three were completely missing before (ADDED)
305
+ // field 21: raw trusted-contact token bytes (Buffer), attached to <tctoken> node on send
306
+ // field 22: server-assigned timestamp for the token (used for expiry check)
307
+ // field 28: sender-side timestamp (used by shouldSendNewTcToken bucket check)
308
+ tcToken: Buffer.isBuffer(f[21]) && f[21].length ? f[21] : undefined,
309
+ tcTokenTimestamp: f[22] != null ? _uint64(f[22]) : undefined,
310
+ tcTokenSenderTimestamp: f[28] != null ? _uint64(f[28]) : undefined,
311
+
312
+ // Identity key for the contact's primary device (field 23)
313
+ contactPrimaryIdentityKey: Buffer.isBuffer(f[23]) && f[23].length ? f[23] : undefined,
314
+
315
+ // pnJid / lidJid / username: fields 39/42/43 in v2.3000.x (were 18/19/28 — FIXED)
316
+ // NOTE: field 28 was previously read as 'username' — that was wrong AND collided
317
+ // with tcTokenSenderTimestamp above. username is correctly at field 43.
318
+ pnJid: _str(f[39]) || undefined,
319
+ lidJid: _str(f[42]) || undefined,
320
+ username: _str(f[43]) || undefined,
321
+
322
+ // Extra useful flags
323
+ archived: !!f[16],
324
+ pinned: typeof f[24] === 'number' ? f[24] : undefined,
325
+ suspended: !!f[29],
326
+ terminated: !!f[30],
327
+ };
328
+ } catch (_) { return null; }
329
+ }
330
+
331
+ function decodePushName(buf) {
332
+ if (!buf || !Buffer.isBuffer(buf)) return null;
333
+ try {
334
+ const f = _decodeFields(buf);
335
+ return { id: _str(f[1]), pushname: _str(f[2]) };
336
+ } catch (_) { return null; }
337
+ }
338
+
339
+ function decodeLidPnMapping(buf) {
340
+ if (!buf || !Buffer.isBuffer(buf)) return null;
341
+ try {
342
+ const f = _decodeFields(buf);
343
+ return { pnJid: _str(f[1]), lidJid: _str(f[2]) };
344
+ } catch (_) { return null; }
345
+ }
346
+
347
+ //
348
+ // HistorySync {
349
+ // HistorySyncType syncType = 1
350
+ // repeated Conversation conversations = 2
351
+ // repeated object statusV3Messages = 3
352
+ // repeated PushName pushnames = 4
353
+ // uint32 progress = 5
354
+ // repeated PhoneNumberToLidMapping lidPnMappings = 10
355
+ // }
356
+ //
357
+ function decodeHistorySync(buf) {
358
+ if (!buf || !buf.length) return null;
359
+ try {
360
+ const f = _decodeFields(buf);
361
+
362
+ const syncType = typeof f[1] === 'number' ? f[1] : 0;
363
+ const progress = typeof f[5] === 'number' ? f[5] : 0;
364
+
365
+ const conversations = [];
366
+ if (f[2]) {
367
+ const convBufs = Array.isArray(f[2]) ? f[2] : [f[2]];
368
+ for (const cb of convBufs) {
369
+ if (Buffer.isBuffer(cb)) {
370
+ const c = decodeConversation(cb);
371
+ if (c && c.id) conversations.push(c);
372
+ }
373
+ }
374
+ }
375
+
376
+ const pushnames = [];
377
+ if (f[4]) {
378
+ const pnBufs = Array.isArray(f[4]) ? f[4] : [f[4]];
379
+ for (const pb of pnBufs) {
380
+ if (Buffer.isBuffer(pb)) {
381
+ const p = decodePushName(pb);
382
+ if (p && p.id) pushnames.push(p);
383
+ }
384
+ }
385
+ }
386
+
387
+ const lidPnMappings = [];
388
+ if (f[10]) {
389
+ const mapBufs = Array.isArray(f[10]) ? f[10] : [f[10]];
390
+ for (const mb of mapBufs) {
391
+ if (Buffer.isBuffer(mb)) {
392
+ const m = decodeLidPnMapping(mb);
393
+ if (m && (m.pnJid || m.lidJid)) lidPnMappings.push(m);
394
+ }
395
+ }
396
+ }
397
+
398
+ return { syncType, progress, conversations, pushnames, lidPnMappings };
399
+ } catch (e) {
400
+ return null;
401
+ }
402
+ }
403
+
404
+ // ─── Crypto helpers ───────────────────────────────────────────────────────────
405
+
406
+ function deriveHistoryKeys(mediaKeyBuf) {
407
+ const ikm = Buffer.isBuffer(mediaKeyBuf) ? mediaKeyBuf : Buffer.from(mediaKeyBuf);
408
+ const info = Buffer.from(HISTORY_HKDF_INFO, 'utf8');
409
+ const expanded = Buffer.from(hkdf(sha256, ikm, Buffer.alloc(0), info, EXPANDED_SIZE));
410
+ return {
411
+ iv: expanded.slice(0, IV_LEN),
412
+ cipherKey: expanded.slice(IV_LEN, IV_LEN + KEY_LEN),
413
+ macKey: expanded.slice(IV_LEN + KEY_LEN, IV_LEN + KEY_LEN * 2)
414
+ };
415
+ }
416
+
417
+ function decryptHistoryBlob(encryptedWithMac, mediaKeyBuf) {
418
+ const { iv, cipherKey, macKey } = deriveHistoryKeys(mediaKeyBuf);
419
+ const ciphertext = encryptedWithMac.slice(0, -MAC_LEN);
420
+ const mac = encryptedWithMac.slice(-MAC_LEN);
421
+
422
+ const expectedMac = crypto.createHmac('sha256', macKey)
423
+ .update(iv)
424
+ .update(ciphertext)
425
+ .digest()
426
+ .slice(0, MAC_LEN);
427
+
428
+ if (!crypto.timingSafeEqual(mac, expectedMac)) {
429
+ throw new Error('History blob MAC verification failed — wrong key or corrupted data');
430
+ }
431
+
432
+ const decipher = crypto.createDecipheriv('aes-256-cbc', cipherKey, iv);
433
+ return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
434
+ }
435
+
436
+ // ─── Network helpers ──────────────────────────────────────────────────────────
437
+
438
+ function downloadUrl(url) {
439
+ return new Promise((resolve, reject) => {
440
+ const parsed = new URL(url);
441
+ const driver = parsed.protocol === 'https:' ? https : http;
442
+ const chunks = [];
443
+
444
+ const req = driver.get(url, {
445
+ headers: {
446
+ 'User-Agent': 'WhatsApp/2.24.20.82 A',
447
+ 'Accept': '*/*'
448
+ }
449
+ }, (res) => {
450
+ if (res.statusCode === 301 || res.statusCode === 302) {
451
+ const location = res.headers['location'];
452
+ res.resume();
453
+ if (location) return resolve(downloadUrl(location));
454
+ return reject(new Error('Redirect with no Location header'));
455
+ }
456
+ if (res.statusCode !== 200) {
457
+ reject(new Error('History CDN download failed: HTTP ' + res.statusCode));
458
+ res.resume();
459
+ return;
460
+ }
461
+ res.on('data', c => chunks.push(c));
462
+ res.on('end', () => resolve(Buffer.concat(chunks)));
463
+ res.on('error', reject);
464
+ });
465
+
466
+ req.on('error', reject);
467
+ req.setTimeout(30000, () => req.destroy(new Error('History CDN download timed out')));
468
+ });
469
+ }
470
+
471
+ function inflateBuffer(buf) {
472
+ return new Promise((resolve, reject) => {
473
+ zlib.inflate(buf, (err, result) => {
474
+ if (!err) return resolve(result);
475
+ zlib.inflateRaw(buf, (err2, result2) => {
476
+ if (!err2) return resolve(result2);
477
+ reject(new Error('zlib inflate failed: ' + err.message));
478
+ });
479
+ });
480
+ });
481
+ }
482
+
483
+ // ─── Persistence ──────────────────────────────────────────────────────────────
484
+
485
+ function _ensureDir(dir) {
486
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
487
+ }
488
+
489
+ function _readJsonSafe(filePath) {
490
+ try {
491
+ if (!fs.existsSync(filePath)) return null;
492
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
493
+ } catch (_) { return null; }
494
+ }
495
+
496
+ function _writeJsonSafe(filePath, data) {
497
+ try {
498
+ _ensureDir(path.dirname(filePath));
499
+ fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
500
+ } catch (_) {}
501
+ }
502
+
503
+ /**
504
+ * Persist app-state sync keys to {phone}.appStateKeys.json
505
+ */
506
+ function saveAppStateSyncKeys(sessionDir, phone, keyShare) {
507
+ if (!keyShare || !keyShare.keys || !keyShare.keys.length) return;
508
+ const file = path.join(sessionDir, phone + '.appStateKeys.json');
509
+ const existing = _readJsonSafe(file) || {};
510
+ for (const k of keyShare.keys) {
511
+ if (k.keyId && k.keyData) {
512
+ existing[k.keyId] = {
513
+ keyData: Buffer.from(k.keyData).toString('base64'),
514
+ timestamp: k.timestamp || 0
515
+ };
516
+ }
517
+ }
518
+ _writeJsonSafe(file, existing);
519
+ }
520
+
521
+ /**
522
+ * Load all persisted app-state sync keys.
523
+ * Returns { [keyIdHex]: { keyData: Buffer, timestamp: number } }
524
+ */
525
+ function loadAppStateSyncKeys(sessionDir, phone) {
526
+ const file = path.join(sessionDir, phone + '.appStateKeys.json');
527
+ const raw = _readJsonSafe(file) || {};
528
+ const out = {};
529
+ for (const [id, v] of Object.entries(raw)) {
530
+ out[id] = {
531
+ keyData: Buffer.from(v.keyData, 'base64'),
532
+ timestamp: v.timestamp || 0
533
+ };
534
+ }
535
+ return out;
536
+ }
537
+
538
+ /**
539
+ * Merge HistorySync data into on-disk files.
540
+ *
541
+ * {phone}.history.json schema:
542
+ * {
543
+ * phone, syncTime,
544
+ * chats: { [jid]: { id, name, displayName, unreadCount, lastMsgTimestamp, messageCount,
545
+ * tcToken, tcTokenTimestamp, tcTokenSenderTimestamp } }
546
+ * contacts: { [jid]: { id, name, displayName, username, pnJid, lidJid } }
547
+ * pushNames: { [jid]: string }
548
+ * lidPnMap: { [lid]: pn }
549
+ * pnLidMap: { [pn]: lid }
550
+ * }
551
+ *
552
+ * {phone}.messages.json schema:
553
+ * {
554
+ * [msgId]: { id, chatId, fromMe, fromJid, timestamp, pushName, status }
555
+ * }
556
+ *
557
+ * NOTE: tcToken is stored as base64 string so it survives JSON serialization.
558
+ * TcTokenStore reads it back as Buffer — see _seedTcTokensFromHistory() in Client.js.
559
+ */
560
+ function mergeHistoryToStore(sessionDir, phone, syncData) {
561
+ const histFile = path.join(sessionDir, phone + '.history.json');
562
+ const msgFile = path.join(sessionDir, phone + '.messages.json');
563
+
564
+ const hist = _readJsonSafe(histFile) || {
565
+ phone,
566
+ syncTime: null,
567
+ chats: {},
568
+ contacts: {},
569
+ pushNames: {},
570
+ lidPnMap: {},
571
+ pnLidMap: {}
572
+ };
573
+
574
+ const msgs = _readJsonSafe(msgFile) || {};
575
+
576
+ // Push names
577
+ for (const pn of (syncData.pushnames || [])) {
578
+ if (pn.id && pn.pushname) hist.pushNames[pn.id] = pn.pushname;
579
+ }
580
+
581
+ // LID ↔ PN mappings
582
+ for (const m of (syncData.lidPnMappings || [])) {
583
+ if (m.lidJid && m.pnJid) {
584
+ hist.lidPnMap[m.lidJid] = m.pnJid;
585
+ hist.pnLidMap[m.pnJid] = m.lidJid;
586
+ }
587
+ }
588
+
589
+ // Conversations
590
+ for (const conv of (syncData.conversations || [])) {
591
+ if (!conv.id) continue;
592
+ const jid = conv.id;
593
+
594
+ // Upsert contact
595
+ if (!hist.contacts[jid]) hist.contacts[jid] = { id: jid };
596
+ const ct = hist.contacts[jid];
597
+ if (conv.name) ct.name = conv.name;
598
+ if (conv.displayName) ct.displayName = conv.displayName;
599
+ if (conv.username) ct.username = conv.username;
600
+ if (conv.pnJid) ct.pnJid = conv.pnJid;
601
+ if (conv.lidJid) ct.lidJid = conv.lidJid;
602
+
603
+ // Cross-populate LID ↔ PN from conversation
604
+ if (conv.pnJid && conv.lidJid) {
605
+ hist.lidPnMap[conv.lidJid] = conv.pnJid;
606
+ hist.pnLidMap[conv.pnJid] = conv.lidJid;
607
+ }
608
+
609
+ // Upsert chat
610
+ if (!hist.chats[jid]) hist.chats[jid] = { id: jid, messageCount: 0 };
611
+ const chat = hist.chats[jid];
612
+ if (conv.name) chat.name = conv.name;
613
+ if (conv.displayName) chat.displayName = conv.displayName;
614
+ if (conv.lastMsgTimestamp) chat.lastMsgTimestamp = conv.lastMsgTimestamp;
615
+ if (conv.unreadCount) chat.unreadCount = conv.unreadCount;
616
+ if (conv.ephemeralExpiry) chat.ephemeralExpiry = conv.ephemeralExpiry;
617
+
618
+ // Persist tcToken from history so Client._seedTcTokensFromHistory() can
619
+ // populate TcTokenStore on connect — prevents error 463 on first send
620
+ // to contacts that already have an established conversation.
621
+ // Stored as base64 to survive JSON round-trip; TcTokenStore converts back to Buffer.
622
+ if (conv.tcToken) {
623
+ chat.tcToken = conv.tcToken.toString('base64');
624
+ chat.tcTokenTimestamp = conv.tcTokenTimestamp || 0;
625
+ chat.tcTokenSenderTimestamp = conv.tcTokenSenderTimestamp || 0;
626
+ }
627
+
628
+ // Messages
629
+ for (const msgInfo of (conv.messages || [])) {
630
+ if (!msgInfo || !msgInfo.key || !msgInfo.key.id) continue;
631
+ const msgId = msgInfo.key.id;
632
+ if (msgs[msgId]) continue;
633
+ msgs[msgId] = {
634
+ id: msgId,
635
+ chatId: jid,
636
+ fromMe: !!msgInfo.key.fromMe,
637
+ fromJid: msgInfo.key.fromMe
638
+ ? (phone + '@s.whatsapp.net')
639
+ : (msgInfo.key.participant || jid),
640
+ timestamp: msgInfo.messageTimestamp,
641
+ pushName: msgInfo.pushName || undefined,
642
+ status: msgInfo.status || 0
643
+ };
644
+ chat.messageCount = (chat.messageCount || 0) + 1;
645
+ }
646
+ }
647
+
648
+ hist.syncTime = new Date().toISOString();
649
+
650
+ _writeJsonSafe(histFile, hist);
651
+ _writeJsonSafe(msgFile, msgs);
652
+
653
+ return hist;
654
+ }
655
+
656
+ // ─── Main processing entry point ──────────────────────────────────────────────
657
+
658
+ /**
659
+ * processHistorySyncNotification — download/decrypt/parse/save one history chunk.
660
+ *
661
+ * @param {object} notification Decoded HistorySyncNotification (from MessageProto decoder)
662
+ * @param {string} sessionDir whalibmob session directory
663
+ * @param {string} phone Phone number digits only
664
+ * @returns {Promise<object>} Result object emitted in 'history_sync' event
665
+ */
666
+ async function processHistorySyncNotification(notification, sessionDir, phone) {
667
+ if (!notification) throw new Error('processHistorySyncNotification: missing notification');
668
+
669
+ const typeName = HistorySyncTypeName[notification.syncType] || String(notification.syncType);
670
+
671
+ let decompressed;
672
+
673
+ if (notification.initialHistBootstrapInlinePayload &&
674
+ notification.initialHistBootstrapInlinePayload.length > 0) {
675
+ // Inline payload — no network call needed (zlib-compressed HistorySync proto)
676
+ decompressed = await inflateBuffer(notification.initialHistBootstrapInlinePayload);
677
+ } else {
678
+ if (!notification.directPath || !notification.mediaKey) {
679
+ throw new Error('HistorySyncNotification missing directPath or mediaKey');
680
+ }
681
+
682
+ const cdnUrl = 'https://' + CDN_HOST + notification.directPath;
683
+ const encryptedBlob = await downloadUrl(cdnUrl);
684
+
685
+ // Verify fileEncSha256
686
+ if (notification.fileEncSha256 && notification.fileEncSha256.length > 0) {
687
+ const actualSha = crypto.createHash('sha256').update(encryptedBlob).digest();
688
+ if (!actualSha.equals(notification.fileEncSha256)) {
689
+ throw new Error('History blob fileEncSha256 mismatch — data corrupted or wrong key');
690
+ }
691
+ }
692
+
693
+ const plaintext = decryptHistoryBlob(encryptedBlob, notification.mediaKey);
694
+
695
+ // Verify fileSha256
696
+ if (notification.fileSha256 && notification.fileSha256.length > 0) {
697
+ const actualSha = crypto.createHash('sha256').update(plaintext).digest();
698
+ if (!actualSha.equals(notification.fileSha256)) {
699
+ throw new Error('History blob fileSha256 mismatch after decryption');
700
+ }
701
+ }
702
+
703
+ decompressed = await inflateBuffer(plaintext);
704
+ }
705
+
706
+ const syncData = decodeHistorySync(decompressed);
707
+ if (!syncData) throw new Error('Failed to decode HistorySync protobuf');
708
+
709
+ const merged = mergeHistoryToStore(sessionDir, phone, syncData);
710
+
711
+ // Build lightweight summary for the event payload
712
+ const chatSummary = (syncData.conversations || []).map(c => ({
713
+ id: c.id,
714
+ name: c.name || c.displayName || undefined,
715
+ unreadCount: c.unreadCount,
716
+ lastMsgTimestamp: c.lastMsgTimestamp,
717
+ messageCount: (c.messages || []).length
718
+ }));
719
+
720
+ const contactSummary = (syncData.conversations || [])
721
+ .filter(c => c.id)
722
+ .map(c => ({
723
+ id: c.id,
724
+ name: c.name || c.displayName || undefined,
725
+ username: c.username || undefined,
726
+ pnJid: c.pnJid || undefined,
727
+ lidJid: c.lidJid || undefined
728
+ }));
729
+
730
+ return {
731
+ syncType: notification.syncType,
732
+ syncTypeName: typeName,
733
+ progress: notification.progress,
734
+ chunkOrder: notification.chunkOrder,
735
+ chats: chatSummary,
736
+ contacts: contactSummary,
737
+ pushNames: syncData.pushnames,
738
+ lidPnMappings: syncData.lidPnMappings,
739
+ merged
740
+ };
741
+ }
742
+
743
+ // ─── Exports ──────────────────────────────────────────────────────────────────
744
+
745
+ module.exports = {
746
+ processHistorySyncNotification,
747
+ decodeHistorySyncNotification,
748
+ decodeHistorySync,
749
+ decodeAppStateSyncKeyShare,
750
+ saveAppStateSyncKeys,
751
+ loadAppStateSyncKeys,
752
+ mergeHistoryToStore,
753
+ decryptHistoryBlob,
754
+ deriveHistoryKeys,
755
+ downloadUrl,
756
+ inflateBuffer,
757
+ HistorySyncType,
758
+ HistorySyncTypeName
759
+ };