whalibmob 2.0.0

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/lib/Client.js ADDED
@@ -0,0 +1,957 @@
1
+ 'use strict';
2
+
3
+ const EventEmitter = require('events');
4
+ const path = require('path');
5
+ const crypto = require('crypto');
6
+ const { NoiseSocket } = require('./noise');
7
+ const { MessageSender, generateMessageId, makeJid } = require('./messages/MessageSender');
8
+ const { checkIfRegistered, checkNumberStatus, requestSmsCode, verifyCode } = require('./Registration');
9
+ const { createNewStore, saveStore, loadStore, toSixParts, fromSixParts } = require('./Store');
10
+ const { BinaryNode } = require('./BinaryNode');
11
+ const { SignalProtocol } = require('./signal/SignalProtocol');
12
+ const { DeviceManager } = require('./DeviceManager');
13
+
14
+ const PING_INTERVAL_MS = 25000;
15
+ const KEEPALIVE_INTERVAL = 20000;
16
+ const RECONNECT_BACKOFF = [1000, 2000, 4000, 8000, 15000, 30000];
17
+
18
+ // ─── Key helpers ──────────────────────────────────────────────────────────────
19
+
20
+ function intToBytes(n, byteLen) {
21
+ const buf = Buffer.alloc(byteLen);
22
+ for (let i = byteLen - 1; i >= 0; i--) { buf[i] = n & 0xff; n >>= 8; }
23
+ return buf;
24
+ }
25
+
26
+ function stripKeyPrefix(key) {
27
+ if (!key) return Buffer.alloc(32);
28
+ const buf = Buffer.from(key);
29
+ if (buf.length === 33 && buf[0] === 0x05) return buf.slice(1);
30
+ return buf;
31
+ }
32
+
33
+ function findChild(node, desc) {
34
+ if (!node || !Array.isArray(node.content)) return null;
35
+ return node.content.find(c => c && c.description === desc) || null;
36
+ }
37
+
38
+ function findChildDeep(node, desc) {
39
+ if (!node) return null;
40
+ if (node.description === desc) return node;
41
+ if (!Array.isArray(node.content)) return null;
42
+ for (const child of node.content) {
43
+ const found = findChildDeep(child, desc);
44
+ if (found) return found;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function getNodeContent(node) {
50
+ if (!node) return null;
51
+ if (Buffer.isBuffer(node.content)) return node.content;
52
+ if (typeof node.content === 'string') return Buffer.from(node.content);
53
+ return null;
54
+ }
55
+
56
+ function intFromBuf(buf) {
57
+ if (!buf) return 0;
58
+ let v = 0;
59
+ for (const b of buf) v = (v << 8) | b;
60
+ return v;
61
+ }
62
+
63
+ function toSignalKey(raw) {
64
+ if (!raw) return null;
65
+ if (raw.length === 33 && raw[0] === 0x05) return Buffer.from(raw);
66
+ if (raw.length === 32) return Buffer.concat([Buffer.from([0x05]), raw]);
67
+ return Buffer.from(raw);
68
+ }
69
+
70
+ // ─── WhalibmobClient ──────────────────────────────────────────────────────────
71
+
72
+ class WhalibmobClient extends EventEmitter {
73
+ constructor(opts) {
74
+ super();
75
+ opts = opts || {};
76
+ this._store = null;
77
+ this._socket = null;
78
+ this._sender = null;
79
+ this._signal = null;
80
+ this._devMgr = null;
81
+ this._sessionDir = opts.sessionDir || process.env.HOME + '/.waSession';
82
+ this._pingTimer = null;
83
+ this._keepTimer = null;
84
+ this._connected = false;
85
+ this._reconnecting = false;
86
+ this._reconnectTry = 0;
87
+ this._phoneNumber = null;
88
+ this._mediaConn = null;
89
+ this._pendingIqs = new Map(); // id → resolve fn
90
+ this._pendingAcks = new Map(); // msgId → resolve fn
91
+ this._groupMembers = new Map(); // groupJid → Set<memberJid>
92
+ this._retryPending = new Map(); // msgId → {node, retryCount}
93
+ this._appStateVersions = {}; // collectionName → version (int)
94
+ }
95
+
96
+ get store() { return this._store; }
97
+ get sender() { return this._sender; }
98
+ get connected() { return this._connected; }
99
+
100
+ // ─── Registration statics ─────────────────────────────────────────────────
101
+
102
+ static async register(phoneNumber, opts) {
103
+ return checkIfRegistered(phoneNumber, opts || {});
104
+ }
105
+
106
+ static async requestCode(phoneNumber, method, opts) {
107
+ return requestSmsCode(phoneNumber, method || 'sms', opts || {});
108
+ }
109
+
110
+ static async confirmCode(phoneNumber, code, opts) {
111
+ return verifyCode(phoneNumber, code, opts || {});
112
+ }
113
+
114
+ // ─── Connect ──────────────────────────────────────────────────────────────
115
+
116
+ async connect(phoneNumber) {
117
+ return this.init(phoneNumber);
118
+ }
119
+
120
+ async init(phoneNumber) {
121
+ phoneNumber = String(phoneNumber).replace(/\D/g, '');
122
+ this._phoneNumber = phoneNumber;
123
+
124
+ const sessionFile = path.join(this._sessionDir, `${phoneNumber}.json`);
125
+ this._store = loadStore(sessionFile);
126
+ if (!this._store) {
127
+ throw new Error(`No session for ${phoneNumber}. Run 'wa registration -R <code>' first.`);
128
+ }
129
+
130
+ const signalFile = path.join(this._sessionDir, `${phoneNumber}.signal.json`);
131
+ const skFile = path.join(this._sessionDir, `${phoneNumber}.sk.json`);
132
+
133
+ this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
134
+ this._devMgr = new DeviceManager(this);
135
+
136
+ await this._connectSocket();
137
+ return this;
138
+ }
139
+
140
+ async _connectSocket() {
141
+ const socket = new NoiseSocket(this._store);
142
+ this._socket = socket;
143
+
144
+ // Recreate sender with updated socket reference
145
+ this._sender = new MessageSender(this);
146
+
147
+ socket.on('open', (sn) => this._onOpen(sn));
148
+ socket.on('node', node => this._onNode(node));
149
+ socket.on('close', () => this._onClose());
150
+ socket.on('error', err => this.emit('error', err));
151
+
152
+ await socket.connect();
153
+ return socket;
154
+ }
155
+
156
+ disconnect() {
157
+ this._reconnecting = false;
158
+ this._reconnectTry = 0;
159
+ this._stopTimers();
160
+ if (this._socket) {
161
+ try { this._socket.close(); } catch (_) {}
162
+ this._socket = null;
163
+ }
164
+ this._connected = false;
165
+ this.emit('close');
166
+ }
167
+
168
+ // ─── Reconnection ─────────────────────────────────────────────────────────
169
+
170
+ _scheduleReconnect() {
171
+ if (this._reconnecting) return;
172
+ this._reconnecting = true;
173
+ const delay = RECONNECT_BACKOFF[Math.min(this._reconnectTry, RECONNECT_BACKOFF.length - 1)];
174
+ this._reconnectTry++;
175
+ this.emit('reconnecting', { delay, attempt: this._reconnectTry });
176
+
177
+ setTimeout(async () => {
178
+ if (!this._reconnecting) return;
179
+ try {
180
+ await this._connectSocket();
181
+ this._reconnecting = false;
182
+ this._reconnectTry = 0;
183
+ this.emit('reconnected');
184
+ } catch (err) {
185
+ this._reconnecting = false;
186
+ this._scheduleReconnect();
187
+ }
188
+ }, delay);
189
+ }
190
+
191
+ // ─── Connection events ────────────────────────────────────────────────────
192
+
193
+ _onOpen(successNode) {
194
+ this._connected = true;
195
+ this._startTimers();
196
+
197
+ // ── Feature 1: Parse <success> node ──────────────────────────────────────
198
+ // Extract ADVSignedDeviceIdentity, platform, and other account info that
199
+ // the server provides on each successful authentication.
200
+ this._parseSuccessNode(successNode);
201
+
202
+ // ── Feature 3 (part A): Send <active> IQ ─────────────────────────────────
203
+ // WhatsApp opens sessions as "passive" — the client must explicitly
204
+ // activate the connection before the server will deliver messages.
205
+ this._sendActiveIq();
206
+
207
+ this._requestMediaConnection();
208
+ this._uploadPreKeys();
209
+ this.emit('connected');
210
+ }
211
+
212
+ // ── Feature 1: Parse the <success> node ────────────────────────────────────
213
+ //
214
+ // The <success> node from WhatsApp contains:
215
+ // attrs.platform — server-reported client platform
216
+ // attrs.lid — linked-device ID (for multi-device)
217
+ // child <device-identity> — raw ADVSignedDeviceIdentity protobuf bytes
218
+ //
219
+ // ADVSignedDeviceIdentity fields (all bytes / wire type 2):
220
+ // 1: details — ADVDeviceIdentityDetails proto
221
+ // 2: accountSignatureKey — 32-byte public key (we strip this when sending)
222
+ // 3: accountSignature — 64-byte Ed25519 signature
223
+ // 4: deviceSignature — 64-byte Ed25519 signature (empty on primary)
224
+ //
225
+ // We persist the bytes so device_identity nodes can be attached to pkmsg
226
+ // stanzas on every subsequent send — even after a reconnect.
227
+ _parseSuccessNode(node) {
228
+ if (!node) return;
229
+ const attrs = node.attrs || {};
230
+ if (attrs.platform) this._platform = attrs.platform;
231
+
232
+ const devIdNode = findChild(node, 'device-identity');
233
+ if (devIdNode) {
234
+ const bytes = getNodeContent(devIdNode);
235
+ if (bytes && bytes.length > 0) {
236
+ this._store.advIdentity = bytes;
237
+ // Persist to session file so it survives restart
238
+ const sessionFile = path.join(
239
+ this._sessionDir,
240
+ `${this._store.phoneNumber}.json`
241
+ );
242
+ try {
243
+ const { saveStore } = require('./Store');
244
+ saveStore(this._store, sessionFile);
245
+ } catch (_) {}
246
+ }
247
+ } else if (this._store.advIdentity) {
248
+ // Already have it persisted from a previous session — keep using it
249
+ }
250
+ }
251
+
252
+ // ── Feature 3 (part A): Activate the connection ────────────────────────────
253
+ //
254
+ // WhatsApp starts every Noise session in "passive" mode. The client must
255
+ // send this IQ to become "active" so the server starts delivering messages.
256
+ _sendActiveIq() {
257
+ if (!this._socket) return;
258
+ const id = this._genMsgId();
259
+ this._socket.sendNode(new BinaryNode('iq', {
260
+ id,
261
+ to: 's.whatsapp.net',
262
+ type: 'set',
263
+ xmlns: 'passive'
264
+ }, [new BinaryNode('active', {}, null)]));
265
+ }
266
+
267
+ // ── Feature 3 (part B): Handle <ib> dirty-state notifications ──────────────
268
+ //
269
+ // After login the server sends <ib><dirty type="account_sync"…/></ib> to
270
+ // tell the client that its app state is stale. We respond with a sync IQ
271
+ // for each dirty collection. We track the version number for each
272
+ // collection in _appStateVersions so we don't repeatedly request version 0.
273
+ _handleIb(node) {
274
+ const children = Array.isArray(node.content) ? node.content : [];
275
+
276
+ const dirtyTypes = [];
277
+ for (const child of children) {
278
+ if (!child || child.description !== 'dirty') continue;
279
+ const type = child.attrs && child.attrs.type;
280
+ if (type) dirtyTypes.push(type);
281
+ }
282
+
283
+ if (dirtyTypes.length > 0) {
284
+ this._sendAppStateSyncForTypes(dirtyTypes);
285
+ }
286
+
287
+ this.emit('ib', { node });
288
+ }
289
+
290
+ // ── Feature 3 (part C): Send an app-state-sync IQ ──────────────────────────
291
+ //
292
+ // Maps WhatsApp dirty-type names → the actual collection names used in the
293
+ // sync IQ. We use return_snapshot=true on the first request (version 0) so
294
+ // the server sends a full snapshot; subsequent requests use the stored
295
+ // version and return_snapshot=false (incremental patches only).
296
+ //
297
+ // We do NOT decrypt the patches — that requires the full LTHASH machinery.
298
+ // We DO update our version counters from the server response so that we
299
+ // never re-request the same snapshot twice.
300
+ _sendAppStateSyncForTypes(dirtyTypes) {
301
+ if (!this._socket || !this._connected) return;
302
+
303
+ const COLLECTION_MAP = {
304
+ account_sync: ['critical_block', 'critical_unblock_low', 'regular_low', 'regular_high', 'regular'],
305
+ critical_block: ['critical_block'],
306
+ critical_unblock_low:['critical_unblock_low'],
307
+ regular_low: ['regular_low'],
308
+ regular_high: ['regular_high'],
309
+ regular: ['regular']
310
+ };
311
+
312
+ const collections = new Set();
313
+ for (const type of dirtyTypes) {
314
+ const mapped = COLLECTION_MAP[type] || [type];
315
+ for (const c of mapped) collections.add(c);
316
+ }
317
+
318
+ const id = this._genMsgId();
319
+ const collectionNodes = [...collections].map(name => {
320
+ const version = String(this._appStateVersions[name] || 0);
321
+ return new BinaryNode('collection', {
322
+ name,
323
+ version,
324
+ return_snapshot: version === '0' ? 'true' : 'false'
325
+ }, null);
326
+ });
327
+
328
+ this._socket.sendNode(new BinaryNode('iq', {
329
+ id,
330
+ to: 's.whatsapp.net',
331
+ type: 'set',
332
+ xmlns: 'w:sync:app:state'
333
+ }, [new BinaryNode('sync', {}, collectionNodes)]));
334
+
335
+ // When the server replies, update our version numbers so future syncs
336
+ // request incremental patches rather than a full snapshot.
337
+ this._pendingIqs.set(id, (resp) => {
338
+ if (!resp || !Array.isArray(resp.content)) return;
339
+ const syncNode = findChild(resp, 'sync');
340
+ if (!syncNode || !Array.isArray(syncNode.content)) return;
341
+ for (const colNode of syncNode.content) {
342
+ if (!colNode || colNode.description !== 'collection') continue;
343
+ const colAttrs = colNode.attrs || {};
344
+ if (colAttrs.name && colAttrs.version !== undefined) {
345
+ this._appStateVersions[colAttrs.name] = parseInt(colAttrs.version, 10) || 0;
346
+ }
347
+ }
348
+ });
349
+ }
350
+
351
+ _onClose() {
352
+ this._connected = false;
353
+ this._stopTimers();
354
+ this.emit('disconnected');
355
+
356
+ // Reject all pending IQs / acks
357
+ for (const [, resolve] of this._pendingIqs) {
358
+ resolve(null);
359
+ }
360
+ this._pendingIqs.clear();
361
+
362
+ for (const [, resolve] of this._pendingAcks) {
363
+ resolve({ error: 'disconnected' });
364
+ }
365
+ this._pendingAcks.clear();
366
+
367
+ this._scheduleReconnect();
368
+ }
369
+
370
+ // ─── Timers ───────────────────────────────────────────────────────────────
371
+
372
+ _startTimers() {
373
+ this._pingTimer = setInterval(() => {
374
+ if (this._sender && this._connected) this._sender.ping();
375
+ }, PING_INTERVAL_MS);
376
+
377
+ this._keepTimer = setInterval(() => {
378
+ if (this._socket && this._connected) {
379
+ this._socket.sendNode(new BinaryNode('iq', {
380
+ id: this._genMsgId(),
381
+ to: 's.whatsapp.net',
382
+ type: 'get',
383
+ xmlns: 'w:p'
384
+ }, [new BinaryNode('ping', {}, null)]));
385
+ }
386
+ }, KEEPALIVE_INTERVAL);
387
+ }
388
+
389
+ _stopTimers() {
390
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
391
+ if (this._keepTimer) { clearInterval(this._keepTimer); this._keepTimer = null; }
392
+ }
393
+
394
+ // ─── Node dispatch ────────────────────────────────────────────────────────
395
+
396
+ _onNode(node) {
397
+ if (!node || !node.description) return;
398
+ const tag = node.description;
399
+
400
+ if (tag === 'iq') this._handleIq(node);
401
+ else if (tag === 'message') this._handleMessage(node);
402
+ else if (tag === 'receipt') this._handleReceipt(node);
403
+ else if (tag === 'ack') this._handleAck(node);
404
+ else if (tag === 'notification') this._handleNotification(node);
405
+ else if (tag === 'presence') this._handlePresence(node);
406
+ else if (tag === 'call') this._handleCall(node);
407
+ else if (tag === 'ib') this._handleIb(node);
408
+ else if (tag === 'success') this.emit('session_refresh', { node }); // late success (re-auth)
409
+ else if (tag === 'failure') this._handleFailure(node);
410
+ else if (tag === 'stream:error') this.emit('stream_error', { reason: node.attrs && node.attrs.reason });
411
+ else this.emit('node', node);
412
+ }
413
+
414
+ // ─── IQ handling ──────────────────────────────────────────────────────────
415
+
416
+ _handleIq(node) {
417
+ const id = node.attrs && node.attrs.id;
418
+ const type = node.attrs && node.attrs.type;
419
+ const xmlns = node.attrs && node.attrs.xmlns;
420
+
421
+ // Resolve pending IQ
422
+ if (id) {
423
+ const handler = this._pendingIqs.get(id);
424
+ if (handler) {
425
+ this._pendingIqs.delete(id);
426
+ handler(node);
427
+ return;
428
+ }
429
+ }
430
+
431
+ // Media connection result
432
+ if (type === 'result' && xmlns === 'w:m') {
433
+ this._onMediaConnectionResult(node);
434
+ }
435
+
436
+ // Pre-key upload ack — could also replenish
437
+ if (type === 'result' && xmlns === 'encrypt') {
438
+ this._checkAndReplenishPreKeys();
439
+ }
440
+
441
+ this.emit('iq', { id, type, xmlns, node });
442
+ }
443
+
444
+ // ─── Message handling ─────────────────────────────────────────────────────
445
+
446
+ _handleMessage(node) {
447
+ const attrs = node.attrs || {};
448
+ const from = attrs.from || '';
449
+ const id = attrs.id || '';
450
+ const ts = parseInt(attrs.t || '0', 10);
451
+ const participant = attrs.participant || from;
452
+
453
+ // Group: sender is the group JID, participant is the actual sender
454
+ const isGroup = from.endsWith('@g.us');
455
+
456
+ // Find enc node(s) — could be skmsg for groups, or msg/pkmsg for DMs
457
+ const encNodes = Array.isArray(node.content)
458
+ ? node.content.filter(c => c && c.description === 'enc')
459
+ : [];
460
+
461
+ // Find participants node (multi-device DMs, or SKDM distribution in groups)
462
+ const participantsNode = findChild(node, 'participants');
463
+ const myJid = `${this._store.phoneNumber}@s.whatsapp.net`;
464
+
465
+ // For groups: look for skmsg enc node
466
+ const skmsgNode = encNodes.find(n => n.attrs && n.attrs.type === 'skmsg');
467
+
468
+ // For DMs: look for direct enc node or enc in participant targeting us
469
+ let dmEncNode = encNodes.find(n => n.attrs && (n.attrs.type === 'msg' || n.attrs.type === 'pkmsg'));
470
+
471
+ // Multi-device: check participants node for our JID
472
+ if (!dmEncNode && participantsNode && Array.isArray(participantsNode.content)) {
473
+ for (const toNode of participantsNode.content) {
474
+ if (!toNode || toNode.description !== 'to') continue;
475
+ const toJid = toNode.attrs && toNode.attrs.jid;
476
+ if (!toJid) continue;
477
+ // Match our main JID or any of our device JIDs
478
+ const toPhone = toJid.split('@')[0].split(':')[0];
479
+ if (toPhone === this._store.phoneNumber) {
480
+ const enc = findChild(toNode, 'enc');
481
+ if (enc) {
482
+ dmEncNode = enc;
483
+ break;
484
+ }
485
+ }
486
+ }
487
+ }
488
+
489
+ // Process SKDM from participants (group messages)
490
+ if (isGroup && participantsNode) {
491
+ this._processSKDMDistribution(from, participant, participantsNode);
492
+ }
493
+
494
+ if (skmsgNode && isGroup) {
495
+ this._decryptGroupMessage({ node, from, participant, id, ts, skmsgNode });
496
+ return;
497
+ }
498
+
499
+ if (dmEncNode && this._signal) {
500
+ this._decryptDMMessage({ node, from, id, ts, dmEncNode });
501
+ return;
502
+ }
503
+
504
+ // Plain text (no encryption) — rare
505
+ const bodyNode = findChild(node, 'body');
506
+ const text = bodyNode ? (
507
+ Buffer.isBuffer(bodyNode.content)
508
+ ? bodyNode.content.toString('utf8')
509
+ : (bodyNode.content || null)
510
+ ) : null;
511
+
512
+ this.emit('message', { id, from, participant, ts, text, node });
513
+ this._sendReadReceipt(id, from, participant);
514
+ }
515
+
516
+ _decryptDMMessage({ node, from, id, ts, dmEncNode }) {
517
+ const encType = dmEncNode.attrs && dmEncNode.attrs.type;
518
+ const cipherBuf = Buffer.isBuffer(dmEncNode.content)
519
+ ? dmEncNode.content
520
+ : Buffer.from(dmEncNode.content || '');
521
+
522
+ this._signal.decrypt(from, encType, cipherBuf)
523
+ .then(plaintext => {
524
+ this.emit('message', { id, from, participant: from, ts, plaintext, node });
525
+ this._sendReadReceipt(id, from, from);
526
+ })
527
+ .catch(err => {
528
+ this.emit('decrypt_error', { id, from, err });
529
+ this._sendRetryRequest(id, from, node);
530
+ });
531
+ }
532
+
533
+ _decryptGroupMessage({ node, from, participant, id, ts, skmsgNode }) {
534
+ const cipherBuf = Buffer.isBuffer(skmsgNode.content)
535
+ ? skmsgNode.content
536
+ : Buffer.from(skmsgNode.content || '');
537
+
538
+ try {
539
+ const plaintext = this._signal.senderKeyDecrypt(from, participant, cipherBuf);
540
+ this.emit('message', { id, from, participant, ts, plaintext, isGroup: true, node });
541
+ this._sendReadReceipt(id, from, participant);
542
+ } catch (err) {
543
+ this.emit('decrypt_error', { id, from, participant, err });
544
+ }
545
+ }
546
+
547
+ // Process SKDM messages in participants node
548
+ _processSKDMDistribution(groupJid, senderJid, participantsNode) {
549
+ // For incoming group messages, the SKDM for us is in the participants node
550
+ if (!Array.isArray(participantsNode.content)) return;
551
+ for (const toNode of participantsNode.content) {
552
+ if (!toNode || toNode.description !== 'to') continue;
553
+ const toJid = toNode.attrs && toNode.attrs.jid;
554
+ if (!toJid) continue;
555
+ const toPhone = toJid.split('@')[0].split(':')[0];
556
+ if (toPhone !== String(this._store.phoneNumber)) continue;
557
+
558
+ const enc = findChild(toNode, 'enc');
559
+ if (!enc) continue;
560
+
561
+ const encType = enc.attrs && enc.attrs.type;
562
+ const cipherBuf = Buffer.isBuffer(enc.content) ? enc.content : Buffer.from(enc.content || '');
563
+
564
+ // Decrypt SKDM with our Signal session
565
+ this._signal.decrypt(senderJid, encType, cipherBuf)
566
+ .then(skdmBytes => {
567
+ this._signal.processSKDM(groupJid, senderJid, skdmBytes);
568
+ })
569
+ .catch(() => {});
570
+ }
571
+ }
572
+
573
+ // ─── Receipt / ack ────────────────────────────────────────────────────────
574
+
575
+ _handleReceipt(node) {
576
+ const attrs = node.attrs || {};
577
+ this.emit('receipt', {
578
+ id: attrs.id || '',
579
+ from: attrs.from || '',
580
+ type: attrs.type || ''
581
+ });
582
+ // ACK the receipt back to server
583
+ if (this._socket && this._connected) {
584
+ this._socket.sendNode(new BinaryNode('ack', {
585
+ id: attrs.id || '',
586
+ class: 'receipt',
587
+ to: attrs.from || ''
588
+ }, null));
589
+ }
590
+ }
591
+
592
+ _handleAck(node) {
593
+ const attrs = node.attrs || {};
594
+ const id = attrs.id || '';
595
+ const cls = attrs.class || '';
596
+
597
+ if (cls === 'message') {
598
+ const handler = this._pendingAcks.get(id);
599
+ if (handler) {
600
+ this._pendingAcks.delete(id);
601
+ handler(attrs);
602
+ }
603
+ }
604
+ }
605
+
606
+ _sendReadReceipt(msgId, from, participant) {
607
+ if (!this._socket || !this._connected) return;
608
+ const node = new BinaryNode('receipt', {
609
+ id: msgId,
610
+ type: 'read',
611
+ to: from
612
+ }, null);
613
+ if (participant && participant !== from) {
614
+ node.attrs.participant = participant;
615
+ }
616
+ this._socket.sendNode(node);
617
+ }
618
+
619
+ // ─── Retry request (when decryption fails) ────────────────────────────────
620
+
621
+ _sendRetryRequest(msgId, from, origNode) {
622
+ const pending = this._retryPending.get(msgId);
623
+ const count = pending ? pending.count + 1 : 1;
624
+ if (count > 3) return;
625
+ this._retryPending.set(msgId, { node: origNode, count });
626
+
627
+ if (!this._socket || !this._connected) return;
628
+ this._socket.sendNode(new BinaryNode('receipt', {
629
+ id: msgId,
630
+ type: 'retry',
631
+ to: from,
632
+ t: String(Math.floor(Date.now() / 1000))
633
+ }, [
634
+ new BinaryNode('retry', { count: String(count), id: msgId, t: String(Math.floor(Date.now() / 1000)), v: '1' }, null),
635
+ new BinaryNode('registration', {}, intToBytes(this._store.registrationId, 4))
636
+ ]));
637
+ }
638
+
639
+ // ─── Notification handling ────────────────────────────────────────────────
640
+
641
+ _handleNotification(node) {
642
+ const attrs = node.attrs || {};
643
+ const type = attrs.type || '';
644
+
645
+ this.emit('notification', { type, attrs, node });
646
+
647
+ if (type === 'encrypt') {
648
+ // Server tells us pre-keys are running low
649
+ this._checkAndReplenishPreKeys();
650
+ }
651
+
652
+ if (type === 'account_sync') {
653
+ // Account sync notification — may contain device list updates
654
+ const devicesNode = findChildDeep(node, 'devices');
655
+ if (devicesNode) this._processDeviceUpdate(devicesNode);
656
+ }
657
+
658
+ if (type === 'w:gp2') {
659
+ // Group update: member add/remove
660
+ this._processGroupUpdate(node);
661
+ }
662
+
663
+ // Ack the notification
664
+ if (this._socket && this._connected) {
665
+ this._socket.sendNode(new BinaryNode('ack', {
666
+ id: attrs.id || '',
667
+ class: 'notification',
668
+ type: type,
669
+ to: attrs.from || 's.whatsapp.net'
670
+ }, null));
671
+ }
672
+ }
673
+
674
+ _processDeviceUpdate(devicesNode) {
675
+ if (!Array.isArray(devicesNode.content)) return;
676
+ for (const deviceNode of devicesNode.content) {
677
+ if (!deviceNode || deviceNode.description !== 'device') continue;
678
+ const jid = deviceNode.attrs && deviceNode.attrs.jid;
679
+ if (jid && this._devMgr) {
680
+ const phone = jid.split('@')[0].split(':')[0];
681
+ const device = parseInt((jid.split(':')[1] || '0').split('@')[0], 10);
682
+ if (!this._devMgr._deviceCache.has(phone)) this._devMgr._deviceCache.set(phone, new Set());
683
+ this._devMgr._deviceCache.get(phone).add(device);
684
+ }
685
+ }
686
+ }
687
+
688
+ _processGroupUpdate(node) {
689
+ const groupJid = node.attrs && node.attrs.from;
690
+ if (!groupJid) return;
691
+ if (!this._groupMembers.has(groupJid)) this._groupMembers.set(groupJid, new Set());
692
+ const members = this._groupMembers.get(groupJid);
693
+
694
+ if (!Array.isArray(node.content)) return;
695
+ for (const child of node.content) {
696
+ if (!child) continue;
697
+ if (child.description === 'add' || child.description === 'promote') {
698
+ for (const p of (child.content || [])) {
699
+ if (p && p.description === 'participant' && p.attrs && p.attrs.jid) {
700
+ members.add(p.attrs.jid);
701
+ }
702
+ }
703
+ }
704
+ if (child.description === 'remove' || child.description === 'demote') {
705
+ for (const p of (child.content || [])) {
706
+ if (p && p.description === 'participant' && p.attrs && p.attrs.jid) {
707
+ members.delete(p.attrs.jid);
708
+ }
709
+ }
710
+ }
711
+ }
712
+ }
713
+
714
+ // ─── Presence ─────────────────────────────────────────────────────────────
715
+
716
+ _handlePresence(node) {
717
+ const attrs = node.attrs || {};
718
+ this.emit('presence', {
719
+ from: attrs.from || '',
720
+ available: attrs.type !== 'unavailable'
721
+ });
722
+ }
723
+
724
+ // ─── Call ─────────────────────────────────────────────────────────────────
725
+
726
+ _handleCall(node) {
727
+ const attrs = node.attrs || {};
728
+ this.emit('call', { from: attrs.from || '', node });
729
+ // Reject call automatically
730
+ if (this._socket && this._connected) {
731
+ const offer = findChild(node, 'offer');
732
+ const callId = offer && offer.attrs && offer.attrs.call_id;
733
+ if (callId) {
734
+ this._socket.sendNode(new BinaryNode('call', {
735
+ to: attrs.from || '',
736
+ id: this._genMsgId()
737
+ }, [new BinaryNode('reject', { call_id: callId, call_creator: attrs.from || '' }, null)]));
738
+ }
739
+ }
740
+ }
741
+
742
+ // ─── Auth failure (server forces logout during active session) ──────────────
743
+
744
+ _handleFailure(node) {
745
+ const attrs = (node && node.attrs) || {};
746
+ const reason = attrs.reason || attrs.location || 'unknown';
747
+ this.emit('auth_failure', { reason, node });
748
+ // The session is no longer valid — stop reconnecting and disconnect
749
+ this._reconnecting = false;
750
+ this.disconnect();
751
+ }
752
+
753
+ // ─── Media connection ─────────────────────────────────────────────────────
754
+
755
+ _requestMediaConnection() {
756
+ if (!this._socket || !this._connected) return;
757
+ this._socket.sendNode(new BinaryNode('iq', {
758
+ id: this._genMsgId(),
759
+ to: 's.whatsapp.net',
760
+ type: 'set',
761
+ xmlns: 'w:m'
762
+ }, [new BinaryNode('media_conn', {}, null)]));
763
+ }
764
+
765
+ _onMediaConnectionResult(node) {
766
+ const connNode = findChild(node, 'media_conn');
767
+ if (!connNode) return;
768
+ const auth = (connNode.attrs && connNode.attrs.auth) || '';
769
+ const hosts = [];
770
+ if (Array.isArray(connNode.content)) {
771
+ for (const child of connNode.content) {
772
+ if (child && child.description === 'host') {
773
+ const hostname = child.attrs && child.attrs.hostname;
774
+ if (hostname) hosts.push(hostname);
775
+ }
776
+ }
777
+ }
778
+ this._mediaConn = { hosts, auth };
779
+ if (this._sender) this._sender.setMediaConnection(hosts, auth);
780
+ this.emit('media_conn', { hosts, auth });
781
+ }
782
+
783
+ // ─── Pre-key upload ───────────────────────────────────────────────────────
784
+
785
+ _uploadPreKeys() {
786
+ if (!this._signal || !this._socket || !this._connected) return;
787
+
788
+ const preKeys = this._signal.getPreKeysForUpload(50);
789
+ const spk = this._signal.getSignedPreKeyForUpload();
790
+ const identKey = this._signal.getIdentityKey();
791
+ if (!preKeys.length || !spk) return;
792
+
793
+ const preKeyNodes = preKeys.map(pk => new BinaryNode('key', {}, [
794
+ new BinaryNode('id', {}, intToBytes(pk.keyId, 3)),
795
+ new BinaryNode('value', {}, stripKeyPrefix(pk.pubKey))
796
+ ]));
797
+
798
+ const skNode = new BinaryNode('skey', {}, [
799
+ new BinaryNode('id', {}, intToBytes(spk.keyId, 3)),
800
+ new BinaryNode('value', {}, stripKeyPrefix(spk.keyPair.pubKey)),
801
+ new BinaryNode('signature', {}, spk.signature)
802
+ ]);
803
+
804
+ this._socket.sendNode(new BinaryNode('iq', {
805
+ id: this._genMsgId(),
806
+ to: 's.whatsapp.net',
807
+ type: 'set',
808
+ xmlns: 'encrypt'
809
+ }, [
810
+ new BinaryNode('registration', {}, intToBytes(this._store.registrationId, 4)),
811
+ new BinaryNode('type', {}, Buffer.from([5])),
812
+ new BinaryNode('identity', {}, stripKeyPrefix(identKey)),
813
+ new BinaryNode('list', {}, preKeyNodes),
814
+ skNode
815
+ ]));
816
+ }
817
+
818
+ _checkAndReplenishPreKeys() {
819
+ if (!this._signal) return;
820
+ const count = this._signal.preKeyCount();
821
+ if (count < 20) {
822
+ const newKeys = this._signal.replenishPreKeys();
823
+ if (newKeys.length > 0) this._uploadPreKeys();
824
+ }
825
+ }
826
+
827
+ // ─── IQ helper (send + await response) ────────────────────────────────────
828
+
829
+ _sendIq(node) {
830
+ return new Promise((resolve, reject) => {
831
+ const id = node.attrs && node.attrs.id;
832
+ if (!id) return reject(new Error('IQ node has no id'));
833
+
834
+ const timer = setTimeout(() => {
835
+ this._pendingIqs.delete(id);
836
+ resolve(null); // Resolve null on timeout rather than reject
837
+ }, 15000);
838
+
839
+ this._pendingIqs.set(id, (result) => {
840
+ clearTimeout(timer);
841
+ resolve(result);
842
+ });
843
+
844
+ try {
845
+ this._socket.sendNode(node);
846
+ } catch (err) {
847
+ clearTimeout(timer);
848
+ this._pendingIqs.delete(id);
849
+ reject(err);
850
+ }
851
+ });
852
+ }
853
+
854
+ // ─── Group metadata ───────────────────────────────────────────────────────
855
+
856
+ async getGroupMetadata(groupJid) {
857
+ const id = this._genMsgId();
858
+ const node = new BinaryNode('iq', {
859
+ id,
860
+ to: groupJid,
861
+ type: 'get',
862
+ xmlns: 'w:g2'
863
+ }, [new BinaryNode('query', { request: 'interactive' }, null)]);
864
+
865
+ const response = await this._sendIq(node);
866
+ if (!response) return null;
867
+
868
+ // Parse members from response and update cache
869
+ const groupNode = findChild(response, 'group');
870
+ if (groupNode && Array.isArray(groupNode.content)) {
871
+ if (!this._groupMembers.has(groupJid)) this._groupMembers.set(groupJid, new Set());
872
+ const members = this._groupMembers.get(groupJid);
873
+ for (const child of groupNode.content) {
874
+ if (child && child.description === 'participant' && child.attrs && child.attrs.jid) {
875
+ members.add(child.attrs.jid);
876
+ }
877
+ }
878
+ }
879
+ return response;
880
+ }
881
+
882
+ // ─── Group member helpers ─────────────────────────────────────────────────
883
+
884
+ _getGroupMembers(groupJid) {
885
+ const members = this._groupMembers.get(groupJid);
886
+ return members ? [...members] : [];
887
+ }
888
+
889
+ async refreshGroupMembers(groupJid) {
890
+ await this.getGroupMetadata(groupJid);
891
+ return this._getGroupMembers(groupJid);
892
+ }
893
+
894
+ // ─── Message ID generator ─────────────────────────────────────────────────
895
+
896
+ _genMsgId() {
897
+ return crypto.randomBytes(8).toString('hex').toUpperCase();
898
+ }
899
+
900
+ // ─── Public API ───────────────────────────────────────────────────────────
901
+
902
+ setPresence(available) {
903
+ if (this._sender) this._sender.sendPresence(available);
904
+ }
905
+
906
+ markRead(jid, messageIds) {
907
+ if (this._sender) this._sender.markRead(jid, messageIds);
908
+ }
909
+
910
+ async sendText(to, text, opts) {
911
+ if (!this._sender || !this._connected) throw new Error('Not connected');
912
+ return this._sender.sendText(to, text, opts);
913
+ }
914
+
915
+ async sendImage(to, data, opts) {
916
+ if (!this._sender || !this._connected) throw new Error('Not connected');
917
+ return this._sender.sendImage(to, data, opts);
918
+ }
919
+
920
+ async sendVideo(to, data, opts) {
921
+ if (!this._sender || !this._connected) throw new Error('Not connected');
922
+ return this._sender.sendVideo(to, data, opts);
923
+ }
924
+
925
+ async sendAudio(to, data, opts) {
926
+ if (!this._sender || !this._connected) throw new Error('Not connected');
927
+ return this._sender.sendAudio(to, data, opts);
928
+ }
929
+
930
+ async sendDocument(to, data, opts) {
931
+ if (!this._sender || !this._connected) throw new Error('Not connected');
932
+ return this._sender.sendDocument(to, data, opts);
933
+ }
934
+
935
+ async sendSticker(to, data, opts) {
936
+ if (!this._sender || !this._connected) throw new Error('Not connected');
937
+ return this._sender.sendSticker(to, data, opts);
938
+ }
939
+
940
+ async sendReaction(to, messageId, emoji, opts) {
941
+ if (!this._sender || !this._connected) throw new Error('Not connected');
942
+ return this._sender.sendReaction(to, messageId, emoji, opts);
943
+ }
944
+ }
945
+
946
+ module.exports = {
947
+ WhalibmobClient,
948
+ checkIfRegistered,
949
+ checkNumberStatus,
950
+ requestSmsCode,
951
+ verifyCode,
952
+ createNewStore,
953
+ saveStore,
954
+ loadStore,
955
+ toSixParts,
956
+ fromSixParts
957
+ };