whalibmob 5.5.30 → 5.5.31

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 CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { dbg: _whaDbg, configureLogger: _whaConfigLogger } = require('./logger');
4
+
3
5
  const EventEmitter = require('events');
4
6
  const path = require('path');
5
7
  const crypto = require('crypto');
@@ -136,6 +138,7 @@ class WhalibmobClient extends EventEmitter {
136
138
  this._tcTokenStore = null; // TcTokenStore — loaded in init()
137
139
  this._inFlightTcTokenIssuance = new Set(); // dedupe concurrent proactive issuePrivacyTokens per JID
138
140
  this._inFlight463Recoveries = new Set(); // dedupe concurrent 463-triggered token issuances per JID (separate from proactive)
141
+ if (opts.pino !== undefined) _whaConfigLogger(opts.pino);
139
142
  }
140
143
 
141
144
  get store() { return this._store; }
@@ -181,6 +184,7 @@ class WhalibmobClient extends EventEmitter {
181
184
 
182
185
  this._signal = SignalProtocol.fromStore(this._store, signalFile, skFile);
183
186
  this._devMgr = new DeviceManager(this);
187
+ this._devMgr.attachSession(this._sessionDir, phoneNumber);
184
188
 
185
189
  // Restore LID ↔ phone mappings persisted from previous sessions.
186
190
  // This ensures we can route to LID JIDs even on fresh connections where no
@@ -191,7 +195,7 @@ class WhalibmobClient extends EventEmitter {
191
195
  this._pnToLid.set(phone, lid);
192
196
  this._lidToPn.set(lid, phone);
193
197
  }
194
- process.stderr.write('[DBG] LID_RESTORED count=' + Object.keys(persistedLid).length + '\n');
198
+ _whaDbg('[DBG] LID_RESTORED count=' + Object.keys(persistedLid).length);
195
199
 
196
200
  await this._connectSocket();
197
201
  return this;
@@ -209,6 +213,8 @@ class WhalibmobClient extends EventEmitter {
209
213
  socket.on('close', () => this._onClose());
210
214
  socket.on('error', err => this.emit('error', err));
211
215
 
216
+ // Pass reconnect attempt count into the Noise handshake ClientPayload
217
+ this._store.connectAttemptCount = this._reconnectTry;
212
218
  await socket.connect();
213
219
  return socket;
214
220
  }
@@ -293,11 +299,11 @@ class WhalibmobClient extends EventEmitter {
293
299
  if (!node) return;
294
300
  const attrs = node.attrs || {};
295
301
  // Debug: log the full success node structure
296
- process.stderr.write('[DBG] SUCCESS node attrs=' + JSON.stringify(attrs) + '\n');
302
+ _whaDbg('[DBG] SUCCESS node attrs=' + JSON.stringify(attrs));
297
303
  if (Array.isArray(node.content)) {
298
304
  for (const child of node.content) {
299
305
  if (child && child.description) {
300
- process.stderr.write('[DBG] SUCCESS child tag=' + child.description + ' attrs=' + JSON.stringify(child.attrs || {}) + '\n');
306
+ _whaDbg('[DBG] SUCCESS child tag=' + child.description + ' attrs=' + JSON.stringify(child.attrs || {}));
301
307
  }
302
308
  }
303
309
  }
@@ -331,7 +337,7 @@ class WhalibmobClient extends EventEmitter {
331
337
  _sendActiveIq() {
332
338
  if (!this._socket) return;
333
339
  const id = this._genMsgId();
334
- process.stderr.write('[DBG] SEND active IQ id=' + id + '\n');
340
+ _whaDbg('[DBG] SEND active IQ id=' + id);
335
341
  this._socket.sendNode(new BinaryNode('iq', {
336
342
  id,
337
343
  to: 's.whatsapp.net',
@@ -391,7 +397,7 @@ class WhalibmobClient extends EventEmitter {
391
397
  for (const type of dirtyTypes) {
392
398
  const mapped = COLLECTION_MAP[type];
393
399
  if (!mapped) {
394
- process.stderr.write('[DBG] ignoring unknown dirty type: ' + type + '\n');
400
+ _whaDbg('[DBG] ignoring unknown dirty type: ' + type);
395
401
  continue;
396
402
  }
397
403
  for (const c of mapped) collections.add(c);
@@ -409,7 +415,7 @@ class WhalibmobClient extends EventEmitter {
409
415
  }, null);
410
416
  });
411
417
 
412
- process.stderr.write('[DBG] SEND appStateSync IQ id=' + id + ' collections=' + [...collections].join(',') + '\n');
418
+ _whaDbg('[DBG] SEND appStateSync IQ id=' + id + ' collections=' + [...collections].join(','));
413
419
  this._socket.sendNode(new BinaryNode('iq', {
414
420
  id,
415
421
  to: 's.whatsapp.net',
@@ -436,6 +442,7 @@ class WhalibmobClient extends EventEmitter {
436
442
  _onClose() {
437
443
  this._connected = false;
438
444
  this._stopTimers();
445
+ this._socket = null; // clear dead reference — prevents accidental sends before reconnect
439
446
  this.emit('disconnected');
440
447
 
441
448
  // Reject all pending IQs / acks
@@ -469,11 +476,27 @@ class WhalibmobClient extends EventEmitter {
469
476
  }, [new BinaryNode('ping', {}, null)]));
470
477
  }
471
478
  }, KEEPALIVE_INTERVAL);
479
+
480
+ // Watchdog: if no data arrived from WhatsApp in 2× keepalive window,
481
+ // the connection is dead but TCP hasn't emitted 'close' (common with NAT drops).
482
+ // Force-destroy the socket so _onTcpClose fires and reconnection kicks in.
483
+ const WATCHDOG_INTERVAL = KEEPALIVE_INTERVAL * 2 + 5000;
484
+ this._watchdogTimer = setInterval(() => {
485
+ if (!this._socket || !this._connected) return;
486
+ const noiseSocket = this._socket;
487
+ const lastRx = noiseSocket._lastRxAt || 0;
488
+ const silentMs = Date.now() - lastRx;
489
+ if (silentMs > WATCHDOG_INTERVAL) {
490
+ _whaDbg('[DBG] WATCHDOG: no data for ' + Math.round(silentMs / 1000) + 's — force-closing dead connection');
491
+ try { noiseSocket.close(); } catch (_) {}
492
+ }
493
+ }, KEEPALIVE_INTERVAL);
472
494
  }
473
495
 
474
496
  _stopTimers() {
475
- if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
476
- if (this._keepTimer) { clearInterval(this._keepTimer); this._keepTimer = null; }
497
+ if (this._pingTimer) { clearInterval(this._pingTimer); this._pingTimer = null; }
498
+ if (this._keepTimer) { clearInterval(this._keepTimer); this._keepTimer = null; }
499
+ if (this._watchdogTimer) { clearInterval(this._watchdogTimer); this._watchdogTimer = null; }
477
500
  }
478
501
 
479
502
  // ─── Node dispatch ────────────────────────────────────────────────────────
@@ -482,9 +505,9 @@ class WhalibmobClient extends EventEmitter {
482
505
  if (!node || !node.description) return;
483
506
  const tag = node.description;
484
507
  // Debug: log every node received
485
- process.stderr.write('[DBG] _onNode tag=' + tag + ' attrs=' + JSON.stringify(node.attrs || {}) + '\n');
508
+ _whaDbg('[DBG] _onNode tag=' + tag + ' attrs=' + JSON.stringify(node.attrs || {}));
486
509
  if (tag === 'iq' && node.attrs && node.attrs.type === 'error') {
487
- try { process.stderr.write('[DBG] IQ_ERROR content=' + JSON.stringify(node.content) + '\n'); } catch(_) {}
510
+ try { _whaDbg('[DBG] IQ_ERROR content=' + JSON.stringify(node.content)); } catch(_) {}
488
511
  }
489
512
 
490
513
  if (tag === 'iq') this._handleIq(node);
@@ -554,11 +577,11 @@ class WhalibmobClient extends EventEmitter {
554
577
  }
555
578
  }
556
579
  }
557
- process.stderr.write('[DBG] _handleMessage from=' + from + ' participant=' + participant + ' senderPn=' + senderPn + ' id=' + id + '\n');
580
+ _whaDbg('[DBG] _handleMessage from=' + from + ' participant=' + participant + ' senderPn=' + senderPn + ' id=' + id);
558
581
  if (Array.isArray(node.content)) {
559
582
  for (const c of node.content) {
560
583
  if (c && c.description) {
561
- process.stderr.write('[DBG] MSG_CHILD tag=' + c.description + ' attrs=' + JSON.stringify(c.attrs || {}) + ' contentLen=' + (Buffer.isBuffer(c.content) ? c.content.length : (typeof c.content === 'string' ? c.content.length : 0)) + '\n');
584
+ _whaDbg('[DBG] MSG_CHILD tag=' + c.description + ' attrs=' + JSON.stringify(c.attrs || {}) + ' contentLen=' + (Buffer.isBuffer(c.content) ? c.content.length : (typeof c.content === 'string' ? c.content.length : 0)));
562
585
  }
563
586
  }
564
587
  }
@@ -632,12 +655,12 @@ class WhalibmobClient extends EventEmitter {
632
655
  const sigJid = senderPn
633
656
  || ((participant && participant !== from) ? participant : from);
634
657
 
635
- process.stderr.write('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid + ' cipherLen=' + cipherBuf.length + '\n');
658
+ _whaDbg('[DBG] DM_DECRYPT id=' + id + ' type=' + encType + ' sigJid=' + sigJid + ' cipherLen=' + cipherBuf.length);
636
659
 
637
660
  this._signal.decrypt(sigJid, encType, cipherBuf)
638
661
  .then(plaintext => {
639
662
  const decoded = this._decodeMsg(plaintext);
640
- process.stderr.write('[DBG] DM_DECODED id=' + id + ' type=' + decoded.type + ' ptLen=' + (plaintext ? plaintext.length : 0) + ' hasSKDM=' + !!(decoded.skdm || decoded.type === 'senderKeyDistribution') + '\n');
663
+ _whaDbg('[DBG] DM_DECODED id=' + id + ' type=' + decoded.type + ' ptLen=' + (plaintext ? plaintext.length : 0) + ' hasSKDM=' + !!(decoded.skdm || decoded.type === 'senderKeyDistribution'));
641
664
 
642
665
  // Process embedded SKDM when bundled with a real message (WhatsApp MD always does this
643
666
  // on the first message of a new session, even for 1-on-1 DMs).
@@ -645,9 +668,9 @@ class WhalibmobClient extends EventEmitter {
645
668
  if (decoded.skdm && decoded.skdm.axolotlBytes && decoded.skdm.axolotlBytes[0] === 0x33) {
646
669
  try {
647
670
  this._signal.processSKDM(decoded.skdm.groupId, sigJid, decoded.skdm.axolotlBytes);
648
- process.stderr.write('[DBG] SKDM_PROCESSED groupId=' + decoded.skdm.groupId + ' sender=' + sigJid + '\n');
671
+ _whaDbg('[DBG] SKDM_PROCESSED groupId=' + decoded.skdm.groupId + ' sender=' + sigJid);
649
672
  } catch (e) {
650
- process.stderr.write('[DBG] SKDM_ERR ' + e.message + '\n');
673
+ _whaDbg('[DBG] SKDM_ERR ' + e.message);
651
674
  }
652
675
  }
653
676
 
@@ -656,9 +679,9 @@ class WhalibmobClient extends EventEmitter {
656
679
  if (decoded.axolotlBytes && decoded.axolotlBytes[0] === 0x33) {
657
680
  try {
658
681
  this._signal.processSKDM(decoded.groupId, sigJid, decoded.axolotlBytes);
659
- process.stderr.write('[DBG] SKDM_ONLY_PROCESSED groupId=' + decoded.groupId + ' sender=' + sigJid + '\n');
682
+ _whaDbg('[DBG] SKDM_ONLY_PROCESSED groupId=' + decoded.groupId + ' sender=' + sigJid);
660
683
  } catch (e) {
661
- process.stderr.write('[DBG] SKDM_ONLY_ERR ' + e.message + '\n');
684
+ _whaDbg('[DBG] SKDM_ONLY_ERR ' + e.message);
662
685
  }
663
686
  }
664
687
  this._sendReadReceipt(id, fromRaw, partRaw);
@@ -666,7 +689,7 @@ class WhalibmobClient extends EventEmitter {
666
689
  }
667
690
 
668
691
  // Protocol messages (revoke, ephemeral, etc.) — silent ACK
669
- if (decoded.type === 'protocol') { process.stderr.write('[DBG] DM_FILTER proto id=' + id + '\n'); return; }
692
+ if (decoded.type === 'protocol') { _whaDbg('[DBG] DM_FILTER proto id=' + id); return; }
670
693
 
671
694
  this.emit('message', { id, from, participant, ts, decoded, node });
672
695
  this._sendReadReceipt(id, fromRaw, partRaw);
@@ -679,7 +702,7 @@ class WhalibmobClient extends EventEmitter {
679
702
  }
680
703
  })
681
704
  .catch(err => {
682
- process.stderr.write('[DBG] DM_ERR id=' + id + ' err=' + (err && err.message) + '\n');
705
+ _whaDbg('[DBG] DM_ERR id=' + id + ' err=' + (err && err.message));
683
706
  this.emit('decrypt_error', { id, from, participant, err });
684
707
  this._sendRetryRequest(id, node);
685
708
  });
@@ -785,7 +808,7 @@ class WhalibmobClient extends EventEmitter {
785
808
  const msgId = String(attrs.id || '');
786
809
  const cached = this._sentMsgCache && this._sentMsgCache.get(msgId);
787
810
  if (!cached || !this._sender) {
788
- process.stderr.write('[DBG] RETRY_RECV msgId=' + msgId + ' — no cached plaintext, skipping resend\n');
811
+ _whaDbg('[DBG] RETRY_RECV msgId=' + msgId + ' — no cached plaintext, skipping resend\n');
789
812
  return;
790
813
  }
791
814
 
@@ -793,7 +816,7 @@ class WhalibmobClient extends EventEmitter {
793
816
  // Extract bare phone number: "40756469325@s.whatsapp.net" → "40756469325"
794
817
  const recipientPhone = fromStr.split('@')[0].split('.')[0];
795
818
 
796
- process.stderr.write('[DBG] RETRY_RECV msgId=' + msgId + ' from=' + fromStr + ' — clearing session + resending\n');
819
+ _whaDbg('[DBG] RETRY_RECV msgId=' + msgId + ' from=' + fromStr + ' — clearing session + resending\n');
797
820
 
798
821
  // Delete all Signal sessions for the recipient's devices so next send creates fresh pkmsg
799
822
  const sigStore = this._signal && this._signal.store;
@@ -801,7 +824,7 @@ class WhalibmobClient extends EventEmitter {
801
824
  const sessions = sigStore._sessions;
802
825
  Object.keys(sessions).forEach(addr => {
803
826
  if (addr.startsWith(recipientPhone + '.')) {
804
- process.stderr.write('[DBG] RETRY deleting session for ' + addr + '\n');
827
+ _whaDbg('[DBG] RETRY deleting session for ' + addr);
805
828
  delete sessions[addr];
806
829
  }
807
830
  });
@@ -816,8 +839,8 @@ class WhalibmobClient extends EventEmitter {
816
839
  this._sender._sendDMMessage(
817
840
  cached.toJid, cached.msgId, cached.plaintext, cached.mediaType, cached.options
818
841
  )
819
- .then(r => process.stderr.write('[DBG] RETRY_RESEND ok id=' + r.id + '\n'))
820
- .catch(e => process.stderr.write('[DBG] RETRY_RESEND_ERR: ' + e.message + '\n'));
842
+ .then(r => _whaDbg('[DBG] RETRY_RESEND ok id=' + r.id + '\n'))
843
+ .catch(e => _whaDbg('[DBG] RETRY_RESEND_ERR: ' + e.message));
821
844
  }
822
845
 
823
846
  _handleAck(node) {
@@ -886,7 +909,7 @@ class WhalibmobClient extends EventEmitter {
886
909
  // Assign a stable, unique prekey index per msgId on first retry
887
910
  let pkIdx = existing ? existing.pkIdx : -1;
888
911
  if (pkIdx < 0 && this._signal) {
889
- const allKeys = this._signal.getPreKeysForUpload(50);
912
+ const allKeys = this._signal.getPreKeysForUpload(800);
890
913
  pkIdx = this._retryPreKeyIdx++ % Math.max(1, allKeys.length);
891
914
  }
892
915
  this._retryPending.set(msgId, { node: origNode, count, pkIdx });
@@ -907,7 +930,7 @@ class WhalibmobClient extends EventEmitter {
907
930
  try {
908
931
  const spk = this._signal.getSignedPreKeyForUpload();
909
932
  const identKey = this._signal.getIdentityKey();
910
- const allPreKeys = this._signal.getPreKeysForUpload(50);
933
+ const allPreKeys = this._signal.getPreKeysForUpload(800);
911
934
  const pk = allPreKeys[pkIdx] || allPreKeys[0];
912
935
 
913
936
  if (spk && identKey && pk) {
@@ -929,15 +952,15 @@ class WhalibmobClient extends EventEmitter {
929
952
  const advBytes = buildOrGetAdvIdentity(this._store);
930
953
  if (advBytes && advBytes.length > 0) {
931
954
  keysChildren.push(new BinaryNode('device-identity', {}, advBytes));
932
- process.stderr.write('[DBG] RETRY #' + count + ' for ' + msgId + ' — preKeyId=' + pk.keyId + ' +device-identity(' + advBytes.length + 'b)\n');
955
+ _whaDbg('[DBG] RETRY #' + count + ' for ' + msgId + ' — preKeyId=' + pk.keyId + ' +device-identity(' + advBytes.length + 'b)\n');
933
956
  } else {
934
- process.stderr.write('[DBG] RETRY #' + count + ' for ' + msgId + ' — preKeyId=' + pk.keyId + ' (no advIdentity)\n');
957
+ _whaDbg('[DBG] RETRY #' + count + ' for ' + msgId + ' — preKeyId=' + pk.keyId + ' (no advIdentity)\n');
935
958
  }
936
959
 
937
960
  children.push(new BinaryNode('keys', {}, keysChildren));
938
961
  }
939
962
  } catch (e) {
940
- process.stderr.write('[DBG] RETRY prekey bundle error: ' + e.message + '\n');
963
+ _whaDbg('[DBG] RETRY prekey bundle error: ' + e.message);
941
964
  }
942
965
  }
943
966
 
@@ -952,7 +975,7 @@ class WhalibmobClient extends EventEmitter {
952
975
  if (origAttrs.recipient) receiptAttrs.recipient = origAttrs.recipient;
953
976
  if (origAttrs.participant) receiptAttrs.participant = origAttrs.participant;
954
977
 
955
- process.stderr.write('[DBG] RETRY #' + count + ' receipt to=' + JSON.stringify(fromJid) + ' participant=' + JSON.stringify(origAttrs.participant || null) + '\n');
978
+ _whaDbg('[DBG] RETRY #' + count + ' receipt to=' + JSON.stringify(fromJid) + ' participant=' + JSON.stringify(origAttrs.participant || null));
956
979
  this._socket.sendNode(new BinaryNode('receipt', receiptAttrs, children));
957
980
  }
958
981
 
@@ -1114,7 +1137,7 @@ class WhalibmobClient extends EventEmitter {
1114
1137
  _requestMediaConnection() {
1115
1138
  if (!this._socket || !this._connected) return;
1116
1139
  const mcId = this._genMsgId();
1117
- process.stderr.write('[DBG] SEND media_conn IQ id=' + mcId + '\n');
1140
+ _whaDbg('[DBG] SEND media_conn IQ id=' + mcId);
1118
1141
  this._socket.sendNode(new BinaryNode('iq', {
1119
1142
  id: mcId,
1120
1143
  to: 's.whatsapp.net',
@@ -1146,7 +1169,7 @@ class WhalibmobClient extends EventEmitter {
1146
1169
  _uploadPreKeys() {
1147
1170
  if (!this._signal || !this._socket || !this._connected) return;
1148
1171
 
1149
- const preKeys = this._signal.getPreKeysForUpload(50);
1172
+ const preKeys = this._signal.getPreKeysForUpload(800);
1150
1173
  const spk = this._signal.getSignedPreKeyForUpload();
1151
1174
  const identKey = this._signal.getIdentityKey();
1152
1175
  if (!preKeys.length || !spk) return;
@@ -1163,7 +1186,7 @@ class WhalibmobClient extends EventEmitter {
1163
1186
  ]);
1164
1187
 
1165
1188
  const pkId = this._genMsgId();
1166
- process.stderr.write('[DBG] SEND uploadPreKeys IQ id=' + pkId + '\n');
1189
+ _whaDbg('[DBG] SEND uploadPreKeys IQ id=' + pkId);
1167
1190
  this._socket.sendNode(new BinaryNode('iq', {
1168
1191
  id: pkId,
1169
1192
  to: 's.whatsapp.net',
@@ -1413,7 +1436,7 @@ class WhalibmobClient extends EventEmitter {
1413
1436
  })
1414
1437
  ])
1415
1438
  ]);
1416
- process.stderr.write('[DBG] ISSUE_PRIVACY_TOKENS jid=' + normalizedJid + ' t=' + timestamp + ' iq_id=' + id + '\n');
1439
+ _whaDbg('[DBG] ISSUE_PRIVACY_TOKENS jid=' + normalizedJid + ' t=' + timestamp + ' iq_id=' + id);
1417
1440
  return this._sendIq(node);
1418
1441
  }
1419
1442
 
@@ -1437,23 +1460,23 @@ class WhalibmobClient extends EventEmitter {
1437
1460
 
1438
1461
  // ── Debug: dump result structure so we can trace server response ─────────
1439
1462
  if (!result) {
1440
- process.stderr.write('[DBG] STORE_TCTOKEN result=NULL (IQ timeout or not matched) fallback=' + fallbackJid + '\n');
1463
+ _whaDbg('[DBG] STORE_TCTOKEN result=NULL (IQ timeout or not matched) fallback=' + fallbackJid);
1441
1464
  } else {
1442
1465
  const contentLen = Array.isArray(result.content) ? result.content.length
1443
1466
  : Buffer.isBuffer(result.content) ? result.content.length
1444
1467
  : 0;
1445
- process.stderr.write('[DBG] STORE_TCTOKEN result type=' + (result.attrs && result.attrs.type) +
1468
+ _whaDbg('[DBG] STORE_TCTOKEN result type=' + (result.attrs && result.attrs.type) +
1446
1469
  ' id=' + (result.attrs && result.attrs.id) +
1447
- ' contentLen=' + contentLen + '\n');
1470
+ ' contentLen=' + contentLen);
1448
1471
  if (Array.isArray(result.content)) {
1449
1472
  result.content.forEach((c, i) => {
1450
1473
  if (c && c.description) {
1451
1474
  const childContent = Array.isArray(c.content) ? c.content.length + ' children'
1452
1475
  : Buffer.isBuffer(c.content) ? c.content.length + 'B'
1453
1476
  : String(c.content);
1454
- process.stderr.write('[DBG] STORE_TCTOKEN child[' + i + '] tag=' + c.description +
1477
+ _whaDbg('[DBG] STORE_TCTOKEN child[' + i + '] tag=' + c.description +
1455
1478
  ' attrs=' + JSON.stringify(c.attrs || {}) +
1456
- ' content=' + childContent + '\n');
1479
+ ' content=' + childContent);
1457
1480
  }
1458
1481
  });
1459
1482
  }
@@ -1486,13 +1509,13 @@ class WhalibmobClient extends EventEmitter {
1486
1509
  if (bytes && bytes.length) {
1487
1510
  this._tcTokenStore.setToken(jid, bytes, t);
1488
1511
  tokenStoredCount++;
1489
- process.stderr.write('[DBG] TCTOKEN_STORED jid=' + jid + ' t=' + t + ' len=' + bytes.length + '\n');
1512
+ _whaDbg('[DBG] TCTOKEN_STORED jid=' + jid + ' t=' + t + ' len=' + bytes.length);
1490
1513
  } else {
1491
- process.stderr.write('[DBG] STORE_TCTOKEN token node has no bytes jid=' + jid + '\n');
1514
+ _whaDbg('[DBG] STORE_TCTOKEN token node has no bytes jid=' + jid);
1492
1515
  }
1493
1516
  }
1494
1517
  } catch (e) {
1495
- process.stderr.write('[DBG] STORE_TCTOKEN parse error: ' + e.message + '\n');
1518
+ _whaDbg('[DBG] STORE_TCTOKEN parse error: ' + e.message);
1496
1519
  }
1497
1520
  }
1498
1521
 
@@ -1505,8 +1528,8 @@ class WhalibmobClient extends EventEmitter {
1505
1528
  if (saveSenderTs && issueTs != null) {
1506
1529
  try {
1507
1530
  this._tcTokenStore.setSenderTimestamp(fallbackJid, issueTs);
1508
- process.stderr.write('[DBG] SENDER_TS_SAVED jid=' + fallbackJid + ' ts=' + issueTs +
1509
- ' tokenBytes=' + tokenStoredCount + '\n');
1531
+ _whaDbg('[DBG] SENDER_TS_SAVED jid=' + fallbackJid + ' ts=' + issueTs +
1532
+ ' tokenBytes=' + tokenStoredCount);
1510
1533
  } catch (_) {}
1511
1534
  }
1512
1535
  }
@@ -1533,14 +1556,14 @@ class WhalibmobClient extends EventEmitter {
1533
1556
  const { normalizeJidForTcToken } = require('./messages/TcTokenStore');
1534
1557
 
1535
1558
  const attrs = node.attrs || {};
1536
- process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF_ENTER from=' + String(attrs.from || '') +
1537
- ' sender_lid=' + String(attrs.sender_lid || '') + '\n');
1559
+ _whaDbg('[DBG] PRIVACY_TOKEN_NOTIF_ENTER from=' + String(attrs.from || '') +
1560
+ ' sender_lid=' + String(attrs.sender_lid || ''));
1538
1561
 
1539
1562
  // ── 1. Require <tokens> wrapper (matches Baileys' getBinaryNodeChild check) ──
1540
1563
  const content = Array.isArray(node.content) ? node.content : [];
1541
1564
  const tokensNode = content.find(n => n && n.description === 'tokens');
1542
1565
  if (!tokensNode) {
1543
- process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF no <tokens> wrapper — abort\n');
1566
+ _whaDbg('[DBG] PRIVACY_TOKEN_NOTIF no <tokens> wrapper — abort\n');
1544
1567
  return;
1545
1568
  }
1546
1569
 
@@ -1569,7 +1592,7 @@ class WhalibmobClient extends EventEmitter {
1569
1592
  storageJid = normalizeJidForTcToken(rawFrom);
1570
1593
  }
1571
1594
 
1572
- process.stderr.write('[DBG] PRIVACY_TOKEN_NOTIF storageJid=' + storageJid + '\n');
1595
+ _whaDbg('[DBG] PRIVACY_TOKEN_NOTIF storageJid=' + storageJid);
1573
1596
 
1574
1597
  // ── 3. Parse <token> children and store bytes (no senderTimestamp update) ──
1575
1598
  // Reuse _storeTcTokenFromIqResult with the full notification node so that its
@@ -1606,15 +1629,15 @@ class WhalibmobClient extends EventEmitter {
1606
1629
  return;
1607
1630
  }
1608
1631
 
1609
- process.stderr.write('[DBG] REISSUE_TC_TOKEN_AFTER_IDENTITY_CHANGE jid=' + tcJid +
1610
- ' prevSenderTs=' + senderTs + '\n');
1632
+ _whaDbg('[DBG] REISSUE_TC_TOKEN_AFTER_IDENTITY_CHANGE jid=' + tcJid +
1633
+ ' prevSenderTs=' + senderTs);
1611
1634
 
1612
1635
  const issueTs = Math.floor(Date.now() / 1000);
1613
1636
  const result = await this._issuePrivacyTokens(tcJid, issueTs);
1614
1637
  this._storeTcTokenFromIqResult(result, tcJid, issueTs);
1615
1638
  } catch (e) {
1616
- process.stderr.write('[DBG] REISSUE_TC_TOKEN_ERR from=' + from +
1617
- ' err=' + (e && e.message) + '\n');
1639
+ _whaDbg('[DBG] REISSUE_TC_TOKEN_ERR from=' + from +
1640
+ ' err=' + (e && e.message));
1618
1641
  }
1619
1642
  })();
1620
1643
  }
@@ -1,7 +1,11 @@
1
1
  'use strict';
2
2
 
3
+ const { dbg: _whaDbg } = require('./logger');
4
+
3
5
  const { BinaryNode } = require('./BinaryNode');
4
6
  const { NodeCache } = require('@cacheable/node-cache');
7
+ const fs = require('fs');
8
+ const path = require('path');
5
9
 
6
10
  const DEVICE_CACHE_TTL = '5m';
7
11
 
@@ -141,6 +145,13 @@ class DeviceManager {
141
145
  // own device list cache + expiry timestamp (refreshed every 5 min)
142
146
  this._ownDeviceJids = null;
143
147
  this._ownDeviceExpiry = 0;
148
+ // ─── Disk persistence ─────────────────────────────────────────────
149
+ this._cacheSnapshot = {}; // mirrors _deviceCache for serialisation
150
+ this._sessionDir = null;
151
+ this._phone = null;
152
+ this._cacheFile = null;
153
+ this._lidMapFile = null;
154
+ this._diskSaveTimer = null;
144
155
  }
145
156
 
146
157
  // ─── NodeCache helper methods ─────────────────────────────────────────────
@@ -149,24 +160,137 @@ class DeviceManager {
149
160
 
150
161
  _dcHas(key) { return this._deviceCache.has(key); }
151
162
  _dcGet(key) { return this._deviceCache.get(key) || null; } // number[] | null
152
- _dcSet(key, arr) { this._deviceCache.set(key, arr); }
163
+ _dcSet(key, arr) { this._deviceCache.set(key, arr); this._cacheSnapshot[key] = arr; }
153
164
  _dcAdd(key, id) {
154
165
  const arr = this._deviceCache.get(key) || [];
155
166
  if (!arr.includes(id)) arr.push(id);
156
167
  this._deviceCache.set(key, arr);
168
+ this._cacheSnapshot[key] = arr;
157
169
  }
158
170
  _dcEnsure(key) {
159
- if (!this._deviceCache.has(key)) this._deviceCache.set(key, []);
171
+ if (!this._deviceCache.has(key)) {
172
+ this._deviceCache.set(key, []);
173
+ this._cacheSnapshot[key] = [];
174
+ }
160
175
  }
161
176
  _dcDel(keys) {
162
- for (const k of keys) this._deviceCache.del(k);
177
+ for (const k of keys) { this._deviceCache.del(k); delete this._cacheSnapshot[k]; }
163
178
  }
164
179
  _dcFlush() {
165
180
  this._deviceCache.flushAll();
181
+ this._cacheSnapshot = {};
166
182
  this._ownDeviceJids = null;
167
183
  this._ownDeviceExpiry = 0;
168
184
  }
169
185
 
186
+
187
+ // ─── Session-dir attachment & disk persistence ─────────────────────────────
188
+ //
189
+ // Call attachSession(sessionDir, phone) right after DeviceManager construction.
190
+ // It loads the on-disk device cache so the very first send after a process
191
+ // restart is instant — no cold-start usync round-trip needed.
192
+ //
193
+ // Files saved in session dir (baileys-compatible naming):
194
+ // <phone>.device-cache.json ← combined cache (internal)
195
+ // device-list-447911234567@s.whatsapp.net.json ← per-phone device IDs
196
+ // device-list-139471160877194@lid.json ← per-LID device IDs
197
+ // <phone>.lid-mapping.json ← phone ↔ LID mappings
198
+ // <phone>.lid-reverse-mapping.json ← LID → phone mappings
199
+ //
200
+ // Cache validity: only restored if saved < 5 min ago (matches NodeCache TTL).
201
+ // After 5 min the TTL fires in-memory AND the next usync rewrites the files.
202
+
203
+ attachSession(sessionDir, phone) {
204
+ this._sessionDir = sessionDir;
205
+ this._phone = String(phone);
206
+ this._cacheFile = path.join(sessionDir, this._phone + '.device-cache.json');
207
+ this._lidMapFile = path.join(sessionDir, this._phone + '.lid-mapping.json');
208
+ this._loadFromDisk();
209
+ }
210
+
211
+ _loadFromDisk() {
212
+ if (!this._cacheFile) return;
213
+ try {
214
+ if (fs.existsSync(this._cacheFile)) {
215
+ const raw = fs.readFileSync(this._cacheFile, 'utf8');
216
+ const data = JSON.parse(raw);
217
+ const age = Date.now() - (data.saved || 0);
218
+ if (data.entries && age < 5 * 60 * 1000) {
219
+ for (const [key, ids] of Object.entries(data.entries)) {
220
+ if (Array.isArray(ids)) {
221
+ this._deviceCache.set(key, ids);
222
+ this._cacheSnapshot[key] = ids;
223
+ }
224
+ }
225
+ _whaDbg('[DBG] DEVICE_CACHE_LOADED entries=' + Object.keys(data.entries).length + ' age=' + Math.round(age / 1000) + 's');
226
+ } else if (data.entries) {
227
+ _whaDbg('[DBG] DEVICE_CACHE_STALE age=' + Math.round(age / 1000) + 's — usync will re-query');
228
+ }
229
+ }
230
+ } catch (e) {
231
+ _whaDbg('[DBG] DEVICE_CACHE_LOAD_ERR ' + e.message);
232
+ }
233
+ }
234
+
235
+ // Debounced save (300 ms) so rapid usync responses don't cause I/O storms
236
+ _scheduleSave() {
237
+ if (!this._cacheFile) return;
238
+ if (this._diskSaveTimer) clearTimeout(this._diskSaveTimer);
239
+ this._diskSaveTimer = setTimeout(() => {
240
+ this._diskSaveTimer = null;
241
+ this._saveToDisk();
242
+ }, 300);
243
+ }
244
+
245
+ _saveToDisk() {
246
+ if (!this._sessionDir || !this._cacheFile) return;
247
+ try {
248
+ const entries = Object.assign({}, this._cacheSnapshot);
249
+
250
+ // ── Combined cache file ─────────────────────────────────────────────────
251
+ fs.writeFileSync(
252
+ this._cacheFile,
253
+ JSON.stringify({ saved: Date.now(), ttl: 300000, entries }, null, 2),
254
+ 'utf8'
255
+ );
256
+
257
+ // ── Individual device-list files (baileys-compatible) ──────────────────
258
+ for (const [key, ids] of Object.entries(entries)) {
259
+ let fileName;
260
+ if (key.startsWith('lid:')) {
261
+ fileName = 'device-list-' + key.slice(4) + '@lid.json';
262
+ } else {
263
+ fileName = 'device-list-' + key + '@s.whatsapp.net.json';
264
+ }
265
+ try {
266
+ fs.writeFileSync(path.join(this._sessionDir, fileName), JSON.stringify(ids), 'utf8');
267
+ } catch (_) {}
268
+ }
269
+
270
+ // ── LID mapping files ────────────────────────────────────────────────────
271
+ if (this._client._pnToLid && this._client._lidToPn) {
272
+ const pnToLid = {};
273
+ const lidToPn = {};
274
+ for (const [pn, lid] of this._client._pnToLid) { pnToLid[pn] = lid; }
275
+ for (const [lid, pn] of this._client._lidToPn) { lidToPn[lid] = pn; }
276
+ fs.writeFileSync(
277
+ this._lidMapFile,
278
+ JSON.stringify({ pnToLid, lidToPn }, null, 2),
279
+ 'utf8'
280
+ );
281
+ fs.writeFileSync(
282
+ path.join(this._sessionDir, this._phone + '.lid-reverse-mapping.json'),
283
+ JSON.stringify(lidToPn, null, 2),
284
+ 'utf8'
285
+ );
286
+ }
287
+
288
+ _whaDbg('[DBG] DEVICE_CACHE_SAVED entries=' + Object.keys(entries).length);
289
+ } catch (e) {
290
+ _whaDbg('[DBG] DEVICE_CACHE_SAVE_ERR ' + e.message);
291
+ }
292
+ }
293
+
170
294
  // ─── Fetch pre-key bundles for a list of JIDs via encrypt IQ ───────────────
171
295
  // Only called for JIDs where no Signal session exists yet.
172
296
  // Returns Map<string_jid, bundle> — keys are always normalised strings.
@@ -203,20 +327,20 @@ class DeviceManager {
203
327
  [new BinaryNode('key', {}, userNodes)]
204
328
  );
205
329
 
206
- process.stderr.write('[DBG] FETCH_BUNDLES jids=[' + jids.join(',') + ']\n');
330
+ _whaDbg('[DBG] FETCH_BUNDLES jids=[' + jids.join(',') + ']\n');
207
331
 
208
332
  const response = await this._client._sendIq(iqNode);
209
333
  const bundles = new Map();
210
334
  if (!response) {
211
- process.stderr.write('[DBG] FETCH_BUNDLES_NULL\n');
335
+ _whaDbg('[DBG] FETCH_BUNDLES_NULL\n');
212
336
  return bundles;
213
337
  }
214
338
 
215
339
  const listNode = findChild(response, 'list');
216
340
  const children = listNode ? listNode.content : (response.content || []);
217
341
 
218
- process.stderr.write('[DBG] FETCH_BUNDLES_RESP childCount=' +
219
- (Array.isArray(children) ? children.length : 0) + '\n');
342
+ _whaDbg('[DBG] FETCH_BUNDLES_RESP childCount=' +
343
+ (Array.isArray(children) ? children.length : 0));
220
344
 
221
345
  for (const userNode of (Array.isArray(children) ? children : [])) {
222
346
  if (!userNode || userNode.description !== 'user') continue;
@@ -224,15 +348,15 @@ class DeviceManager {
224
348
  if (!jidRaw) continue;
225
349
  // Always normalise to a string key so Map lookups are consistent
226
350
  const jidStr = String(jidRaw);
227
- process.stderr.write('[DBG] FETCH_BUNDLES_USER jid=' + jidStr +
351
+ _whaDbg('[DBG] FETCH_BUNDLES_USER jid=' + jidStr +
228
352
  ' childTags=' + (Array.isArray(userNode.content)
229
353
  ? userNode.content.map(c => c && c.description).filter(Boolean).join(',')
230
- : '') + '\n');
354
+ : ''));
231
355
  const bundle = parseBundleFromUserNode(userNode);
232
356
  if (bundle) bundles.set(jidStr, bundle);
233
- else process.stderr.write('[DBG] FETCH_BUNDLES_NO_BUNDLE jid=' + jidStr + '\n');
357
+ else _whaDbg('[DBG] FETCH_BUNDLES_NO_BUNDLE jid=' + jidStr);
234
358
  }
235
- process.stderr.write('[DBG] FETCH_BUNDLES_DONE count=' + bundles.size + '\n');
359
+ _whaDbg('[DBG] FETCH_BUNDLES_DONE count=' + bundles.size);
236
360
  return bundles;
237
361
  }
238
362
 
@@ -332,10 +456,10 @@ class DeviceManager {
332
456
  )]
333
457
  );
334
458
 
335
- process.stderr.write('[DBG] USYNC_IQ phones=[' + phones.join(',') + ']\n');
459
+ _whaDbg('[DBG] USYNC_IQ phones=[' + phones.join(',') + ']\n');
336
460
 
337
461
  const response = await this._client._sendIq(iqNode).catch(err => {
338
- process.stderr.write('[DBG] USYNC_ERR ' + (err && err.message) + '\n');
462
+ _whaDbg('[DBG] USYNC_ERR ' + (err && err.message));
339
463
  return null;
340
464
  });
341
465
 
@@ -383,7 +507,7 @@ class DeviceManager {
383
507
  } else {
384
508
  cachePhone = rawUser; // last resort: use LID user as key
385
509
  }
386
- process.stderr.write('[DBG] USYNC_LID_USER userJid=' + userJid + ' → cachePhone=' + cachePhone + '\n');
510
+ _whaDbg('[DBG] USYNC_LID_USER userJid=' + userJid + ' → cachePhone=' + cachePhone);
387
511
  } else {
388
512
  // Normal account: user JID is phone@s.whatsapp.net
389
513
  cachePhone = rawUser;
@@ -394,7 +518,7 @@ class DeviceManager {
394
518
  const lidUser = String(lidAttr).split('@')[0].split(':')[0];
395
519
  if (this._client._lidToPn) this._client._lidToPn.set(lidUser, cachePhone);
396
520
  if (this._client._pnToLid) this._client._pnToLid.set(cachePhone, lidUser);
397
- process.stderr.write('[DBG] USYNC_LID_MAP phone=' + cachePhone + ' ↔ lid=' + lidUser + '\n');
521
+ _whaDbg('[DBG] USYNC_LID_MAP phone=' + cachePhone + ' ↔ lid=' + lidUser);
398
522
  }
399
523
  }
400
524
 
@@ -413,19 +537,19 @@ class DeviceManager {
413
537
  : 0;
414
538
  this._dcAdd(cachePhone, devId);
415
539
  }
416
- process.stderr.write('[DBG] USYNC_DEVICES phone=' + cachePhone +
540
+ _whaDbg('[DBG] USYNC_DEVICES phone=' + cachePhone +
417
541
  ' ids=[' + (this._dcGet(cachePhone) || []).join(',') + ']\n');
418
542
  } else {
419
543
  // No device-list in response — fall back to device 0
420
544
  this._dcAdd(cachePhone, 0);
421
- process.stderr.write('[DBG] USYNC_NO_DEVLIST phone=' + cachePhone + ' → fallback id=0\n');
545
+ _whaDbg('[DBG] USYNC_NO_DEVLIST phone=' + cachePhone + ' → fallback id=0\n');
422
546
  }
423
547
  }
424
548
  } else {
425
- process.stderr.write('[DBG] USYNC_RESP_NO_LIST\n');
549
+ _whaDbg('[DBG] USYNC_RESP_NO_LIST\n');
426
550
  }
427
551
  } else {
428
- process.stderr.write('[DBG] USYNC_NULL_RESP\n');
552
+ _whaDbg('[DBG] USYNC_NULL_RESP\n');
429
553
  // Device usync timed out — fall back to a contact usync (query/contact).
430
554
  // This DOES receive a server response and resolves the phone → LID mapping.
431
555
  // Once _pnToLid is populated here, _sendDMMessage (after its await) will
@@ -437,9 +561,10 @@ class DeviceManager {
437
561
  for (const p of phones) {
438
562
  if (!this._dcHas(p)) {
439
563
  this._dcSet(p, [0]);
440
- process.stderr.write('[DBG] USYNC_FALLBACK phone=' + p + '\n');
564
+ _whaDbg('[DBG] USYNC_FALLBACK phone=' + p);
441
565
  }
442
566
  }
567
+ this._scheduleSave();
443
568
  }
444
569
 
445
570
  // ─── Ensure sessions exist for all devices of a set of recipients ──────────
@@ -508,10 +633,10 @@ class DeviceManager {
508
633
  // desktop, web) are silently ignored and only device 0 (primary phone) is used.
509
634
  if (skipUsync) {
510
635
  if (!this._dcHas(cacheKey)) {
511
- process.stderr.write('[DBG] LID_SKIP_USYNC_CACHE_MISS lidUser=' + lidUser + ' → real usync\n');
636
+ _whaDbg('[DBG] LID_SKIP_USYNC_CACHE_MISS lidUser=' + lidUser + ' → real usync\n');
512
637
  await this._doUsyncIqByJid(lidJid, cacheKey, lidUser);
513
638
  } else {
514
- process.stderr.write('[DBG] LID_SKIP_USYNC_HIT lidUser=' + lidUser + ' → cache\n');
639
+ _whaDbg('[DBG] LID_SKIP_USYNC_HIT lidUser=' + lidUser + ' → cache\n');
515
640
  }
516
641
  } else if (!this._dcHas(cacheKey)) {
517
642
  await this._doUsyncIqByJid(lidJid, cacheKey, lidUser);
@@ -520,7 +645,7 @@ class DeviceManager {
520
645
  const deviceIds = this._dcGet(cacheKey) || [0];
521
646
  const deviceJids = deviceIds.map(d => makeDeviceJid(lidUser, d, 'lid'));
522
647
 
523
- process.stderr.write('[DBG] LID_DEVICES lidUser=' + lidUser +
648
+ _whaDbg('[DBG] LID_DEVICES lidUser=' + lidUser +
524
649
  ' deviceJids=[' + deviceJids.join(',') + ']\n');
525
650
 
526
651
  // Split: existing sessions (ready) vs new (need bundle fetch)
@@ -540,9 +665,9 @@ class DeviceManager {
540
665
  try {
541
666
  await signalProto.buildSessionFromBundle(jid, bundle);
542
667
  readyJids.push(jid);
543
- process.stderr.write('[DBG] LID_SESSION_BUILT jid=' + jid + '\n');
668
+ _whaDbg('[DBG] LID_SESSION_BUILT jid=' + jid);
544
669
  } catch (e) {
545
- process.stderr.write('[DBG] LID_SESSION_ERR jid=' + jid + ' err=' + e.message + '\n');
670
+ _whaDbg('[DBG] LID_SESSION_ERR jid=' + jid + ' err=' + e.message);
546
671
  }
547
672
  }
548
673
  }
@@ -584,15 +709,15 @@ class DeviceManager {
584
709
  )]
585
710
  );
586
711
 
587
- process.stderr.write('[DBG] CONTACT_USYNC phones=[' + phones.join(',') + ']\n');
712
+ _whaDbg('[DBG] CONTACT_USYNC phones=[' + phones.join(',') + ']\n');
588
713
 
589
714
  const response = await this._client._sendIq(iqNode).catch(err => {
590
- process.stderr.write('[DBG] CONTACT_USYNC_ERR ' + (err && err.message) + '\n');
715
+ _whaDbg('[DBG] CONTACT_USYNC_ERR ' + (err && err.message));
591
716
  return null;
592
717
  });
593
718
 
594
719
  if (!response) {
595
- process.stderr.write('[DBG] CONTACT_USYNC_NULL\n');
720
+ _whaDbg('[DBG] CONTACT_USYNC_NULL\n');
596
721
  return;
597
722
  }
598
723
 
@@ -600,7 +725,7 @@ class DeviceManager {
600
725
  const listNode = usyncNode ? findChild(usyncNode, 'list') : findChild(response, 'list');
601
726
 
602
727
  if (!listNode || !Array.isArray(listNode.content)) {
603
- process.stderr.write('[DBG] CONTACT_USYNC_NO_LIST\n');
728
+ _whaDbg('[DBG] CONTACT_USYNC_NO_LIST\n');
604
729
  return;
605
730
  }
606
731
 
@@ -620,7 +745,7 @@ class DeviceManager {
620
745
  // unambiguous; for multi-phone queries match by index.
621
746
  const phone = phones.length === 1 ? phones[0] : phones[i];
622
747
  if (phone) {
623
- process.stderr.write('[DBG] CONTACT_USYNC_LID phone=' + phone + ' → lid=' + lidUser + '\n');
748
+ _whaDbg('[DBG] CONTACT_USYNC_LID phone=' + phone + ' → lid=' + lidUser);
624
749
  if (this._client._pnToLid) this._client._pnToLid.set(phone, lidUser);
625
750
  if (this._client._lidToPn) this._client._lidToPn.set(lidUser, phone);
626
751
  // Persist the mapping so future sessions don't need to re-query
@@ -631,9 +756,10 @@ class DeviceManager {
631
756
  // PN account — note it (still on phone-based routing, no LID needed)
632
757
  const { user: pnUser } = stripUser(actualJidStr);
633
758
  const phone = phones.length === 1 ? phones[0] : phones[i];
634
- process.stderr.write('[DBG] CONTACT_USYNC_PN phone=' + phone + ' jid=' + actualJidStr + '\n');
759
+ _whaDbg('[DBG] CONTACT_USYNC_PN phone=' + phone + ' jid=' + actualJidStr);
635
760
  }
636
761
  }
762
+ this._scheduleSave();
637
763
  }
638
764
 
639
765
  // ─── usync IQ for a specific JID (LID or phone-based) ─────────────────────
@@ -658,10 +784,10 @@ class DeviceManager {
658
784
  )]
659
785
  );
660
786
 
661
- process.stderr.write('[DBG] USYNC_LID_IQ jid=' + jid + '\n');
787
+ _whaDbg('[DBG] USYNC_LID_IQ jid=' + jid);
662
788
 
663
789
  const response = await this._client._sendIq(iqNode).catch(err => {
664
- process.stderr.write('[DBG] USYNC_LID_IQ_ERR ' + (err && err.message) + '\n');
790
+ _whaDbg('[DBG] USYNC_LID_IQ_ERR ' + (err && err.message));
665
791
  return null;
666
792
  });
667
793
 
@@ -685,21 +811,22 @@ class DeviceManager {
685
811
  ? parseInt(String(devNode.attrs.id), 10) : 0;
686
812
  this._dcAdd(cacheKey, devId);
687
813
  }
688
- process.stderr.write('[DBG] USYNC_LID_DEVICES jid=' + jid +
814
+ _whaDbg('[DBG] USYNC_LID_DEVICES jid=' + jid +
689
815
  ' ids=[' + (this._dcGet(cacheKey) || []).join(',') + ']\n');
690
816
  } else {
691
817
  this._dcAdd(cacheKey, 0);
692
- process.stderr.write('[DBG] USYNC_LID_NO_DEVLIST jid=' + jid + ' → fallback id=0\n');
818
+ _whaDbg('[DBG] USYNC_LID_NO_DEVLIST jid=' + jid + ' → fallback id=0\n');
693
819
  }
694
820
  }
695
821
  } else {
696
- process.stderr.write('[DBG] USYNC_LID_NO_LIST jid=' + jid + '\n');
822
+ _whaDbg('[DBG] USYNC_LID_NO_LIST jid=' + jid);
697
823
  this._dcAdd(cacheKey, 0);
698
824
  }
699
825
  } else {
700
- process.stderr.write('[DBG] USYNC_LID_NULL_RESP jid=' + jid + '\n');
826
+ _whaDbg('[DBG] USYNC_LID_NULL_RESP jid=' + jid);
701
827
  this._dcAdd(cacheKey, 0);
702
828
  }
829
+ this._scheduleSave();
703
830
  }
704
831
 
705
832
  // ─── Ensure sessions for own linked devices (device != 0) ─────────────────
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { dbg: _whaDbg } = require('./logger');
4
+
3
5
  const crypto = require('crypto');
4
6
  const https = require('https');
5
7
  const tls = require('tls');
@@ -691,7 +693,7 @@ async function requestSmsCode(store, method, opts) {
691
693
  // Auto-fallback on no_routes
692
694
  if (result && result._noRoutes && !autoFallbackDone) {
693
695
  autoFallbackDone = true;
694
- process.stderr.write(`[REG] ${method} returned no_routes — auto-trying ${fallbackMethod}\n`);
696
+ _whaDbg(`[REG] ${method} returned no_routes — auto-trying ${fallbackMethod}`);
695
697
  result = await _tryMethod(fallbackMethod);
696
698
  }
697
699
 
package/lib/Store.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { warn: _whaWarn } = require('./logger');
4
+
3
5
  const fs = require('fs');
4
6
  const path = require('path');
5
7
  const crypto = require('crypto');
@@ -182,7 +184,7 @@ function storeFromJson(obj) {
182
184
  function saveStore(store, filePath) {
183
185
  // MAIN FIX: avoid crashing when called with undefined/null store
184
186
  if (!store) {
185
- console.warn('saveStore: called with empty store, skipping write for', filePath);
187
+ _whaWarn('saveStore: called with empty store, skipping write for ' + filePath);
186
188
  return;
187
189
  }
188
190
 
package/lib/logger.js ADDED
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const _noop = {
4
+ trace: () => {},
5
+ debug: () => {},
6
+ info: () => {},
7
+ warn: () => {},
8
+ error: () => {}
9
+ };
10
+
11
+ let _logger = _noop;
12
+
13
+ function configureLogger(opts) {
14
+ if (!opts || opts === false) {
15
+ _logger = _noop;
16
+ return;
17
+ }
18
+ try {
19
+ const pino = require('pino');
20
+ const pinoOpts = (opts === true) ? { level: 'debug' } : opts;
21
+ _logger = pino(pinoOpts);
22
+ } catch (_) {
23
+ _logger = _noop;
24
+ }
25
+ }
26
+
27
+ function getLogger() {
28
+ return _logger;
29
+ }
30
+
31
+ function dbg(msg) { _logger.debug(msg); }
32
+ function warn(msg) { _logger.warn(msg); }
33
+ function err(msg) { _logger.error(msg); }
34
+ function info(msg) { _logger.info(msg); }
35
+
36
+ module.exports = { configureLogger, getLogger, dbg, warn, err, info };
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { dbg: _whaDbg } = require('../logger');
4
+
3
5
  const crypto = require('crypto');
4
6
  const { Mutex } = require('async-mutex');
5
7
  const path = require('path');
@@ -129,10 +131,10 @@ function _buildOrGetAdvIdentity(store) {
129
131
 
130
132
  const adv = _encodeADVSignedIdentity(details, pub32, acctSig, devSig);
131
133
  store.advIdentity = adv;
132
- process.stderr.write('[DBG] ADV_BUILT from primary iOS keys (' + adv.length + 'b)\n');
134
+ _whaDbg('[DBG] ADV_BUILT from primary iOS keys (' + adv.length + 'b)\n');
133
135
  return adv;
134
136
  } catch (e) {
135
- process.stderr.write('[DBG] ADV_BUILD_ERR: ' + e.message + '\n');
137
+ _whaDbg('[DBG] ADV_BUILD_ERR: ' + e.message);
136
138
  return null;
137
139
  }
138
140
  }
@@ -581,8 +583,8 @@ class MessageSender {
581
583
  const lidUser = this._client._pnToLid && this._client._pnToLid.get(recipientPhone);
582
584
  const routingToJid = lidUser ? `${lidUser}@lid` : toJid;
583
585
 
584
- process.stderr.write('[DBG] DM_ROUTE phone=' + recipientPhone +
585
- ' routing=' + routingToJid + (lidUser ? ' (LID)' : ' (PN)') + '\n');
586
+ _whaDbg('[DBG] DM_ROUTE phone=' + recipientPhone +
587
+ ' routing=' + routingToJid + (lidUser ? ' (LID)' : ' (PN)'));
586
588
 
587
589
  let otherJids;
588
590
  if (lidUser) {
@@ -652,11 +654,11 @@ class MessageSender {
652
654
  if (tcEntry && tcEntry.token && tcEntry.token.length) {
653
655
  if (!tcTokenExpired(tcEntry.timestamp)) {
654
656
  msgContent.push(new BinaryNode('tctoken', {}, tcEntry.token));
655
- process.stderr.write('[DBG] TCTOKEN_ATTACH jid=' + tcJid + '\n');
657
+ _whaDbg('[DBG] TCTOKEN_ATTACH jid=' + tcJid);
656
658
  } else {
657
659
  // Expired token — clear it, keep senderTimestamp for dedupe
658
660
  tcStore.clearToken(tcJid);
659
- process.stderr.write('[DBG] TCTOKEN_EXPIRED jid=' + tcJid + ' — cleared\n');
661
+ _whaDbg('[DBG] TCTOKEN_EXPIRED jid=' + tcJid + ' — cleared\n');
660
662
  }
661
663
  }
662
664
  }
@@ -665,16 +667,15 @@ class MessageSender {
665
667
  const msgNode = new BinaryNode('message', stanzaAttrs, msgContent);
666
668
 
667
669
  // Debug: log outgoing stanza details
668
- process.stderr.write('[DBG] DM_SEND to=' + toJid +
670
+ _whaDbg('[DBG] DM_SEND to=' + toJid +
669
671
  ' otherJids=[' + otherJids.join(',') + ']' +
670
672
  ' ownLinked=[' + ownLinkedJids.join(',') + ']' +
671
673
  ' encrypted=' + encryptedList.length +
672
674
  ' hasPkmsg=' + hasPkmsg +
673
- ' hasAdv=' + !!(advBytes) +
674
- '\n');
675
+ ' hasAdv=' + !!(advBytes));
675
676
  if (encryptedList.length > 0) {
676
- process.stderr.write('[DBG] DM_PARTICIPANTS ' +
677
- encryptedList.map(e => e.jid + '(' + e.type + ')').join(', ') + '\n');
677
+ _whaDbg('[DBG] DM_PARTICIPANTS ' +
678
+ encryptedList.map(e => e.jid + '(' + e.type + ')').join(', '));
678
679
  }
679
680
 
680
681
  // Cache plaintext so Client can re-send with fresh session on recipient retry
@@ -722,7 +723,7 @@ class MessageSender {
722
723
  let members = this._client._getGroupMembers(groupJid);
723
724
  if (members.length === 0 && typeof this._client.getGroupMetadata === 'function') {
724
725
  try {
725
- process.stderr.write('[DBG] GROUP_SEND auto-fetch metadata for ' + groupJid + '\n');
726
+ _whaDbg('[DBG] GROUP_SEND auto-fetch metadata for ' + groupJid);
726
727
  await this._client.getGroupMetadata(groupJid);
727
728
  members = this._client._getGroupMembers(groupJid);
728
729
  } catch (_) {}
@@ -745,7 +746,7 @@ class MessageSender {
745
746
  if (isLid) {
746
747
  const pn = this._client._lidToPn && this._client._lidToPn.get(raw);
747
748
  if (!pn) {
748
- process.stderr.write('[DBG] GROUP_SEND skip LID member with no PN mapping: ' + jid + '\n');
749
+ _whaDbg('[DBG] GROUP_SEND skip LID member with no PN mapping: ' + jid);
749
750
  return null;
750
751
  }
751
752
  return pn;
@@ -859,16 +860,14 @@ class MessageSender {
859
860
  const tcJid = normalizeJidForTcToken(
860
861
  ackFrom || String(node.attrs && node.attrs.to || '')
861
862
  );
862
- process.stderr.write(
863
- '[DBG] ACK_463 from=' + ackFrom + ' id=' + id +
864
- ' tcJid=' + tcJid + ' — issuing tctoken then retrying\n'
865
- );
863
+ _whaDbg('[DBG] ACK_463 from=' + ackFrom + ' id=' + id +
864
+ ' tcJid=' + tcJid + ' issuing tctoken then retrying\n');
866
865
 
867
866
  if (client._inFlight463Recoveries.has(tcJid)) {
868
867
  // Another concurrent recovery in progress for this JID —
869
868
  // wait ~3 s for it to finish then retry with whatever token
870
869
  // is now stored (may or may not succeed).
871
- process.stderr.write('[DBG] ACK_463 already in-flight for ' + tcJid + ' — retrying after delay\n');
870
+ _whaDbg('[DBG] ACK_463 already in-flight for ' + tcJid + ' — retrying after delay\n');
872
871
  setTimeout(() => {
873
872
  const retryId = crypto.randomBytes(8).toString('hex').toUpperCase();
874
873
  const retryNode = new BinaryNode(
@@ -897,16 +896,14 @@ class MessageSender {
897
896
  const entry = tcStore && tcStore.get(tcJid);
898
897
  const hasToken = entry && entry.token && entry.token.length > 0;
899
898
  if (!hasToken) {
900
- process.stderr.write(
901
- '[DBG] ACK_463 IQ timeout or server refused no token bytes stored for ' +
902
- tcJid + ', giving up (account may be restricted)\n'
903
- );
899
+ _whaDbg('[DBG] ACK_463 IQ timeout or server refused — no token bytes stored for ' +
900
+ tcJid + ', giving up (account may be restricted)\n');
904
901
  throw new Error(
905
902
  'Send error 463: server did not issue a tctoken for ' + tcJid +
906
903
  ' (IQ timeout or account restricted)'
907
904
  );
908
905
  }
909
- process.stderr.write('[DBG] ACK_463 token stored for ' + tcJid + ' — retrying send\n');
906
+ _whaDbg('[DBG] ACK_463 token stored for ' + tcJid + ' — retrying send\n');
910
907
  const retryId = crypto.randomBytes(8).toString('hex').toUpperCase();
911
908
  const retryNode = new BinaryNode(
912
909
  node.description,
@@ -917,7 +914,7 @@ class MessageSender {
917
914
  })
918
915
  .then(r => resolve(r))
919
916
  .catch(err => {
920
- process.stderr.write('[DBG] ACK_463 recovery failed: ' + (err && err.message) + '\n');
917
+ _whaDbg('[DBG] ACK_463 recovery failed: ' + (err && err.message));
921
918
  reject(new Error('Send error 463: tctoken recovery failed — ' + (err && err.message)));
922
919
  })
923
920
  .finally(() => client._inFlight463Recoveries.delete(tcJid));
package/lib/noise.js CHANGED
@@ -202,6 +202,8 @@ class NoiseSocket extends EventEmitter {
202
202
  this._awaitingAuth = false; // true after ClientFinish sent, before server <success>/<failure>
203
203
  this.rxBuf = Buffer.alloc(0);
204
204
  this._ephemeralKeyPair = null;
205
+ this._lastRxAt = Date.now(); // updated on every TCP data received
206
+ this._connectTimeoutTimer = null; // cleared once TCP connect fires
205
207
  }
206
208
 
207
209
  connect() {
@@ -210,7 +212,23 @@ class NoiseSocket extends EventEmitter {
210
212
  this._connectReject = reject;
211
213
 
212
214
  this.socket = net.createConnection({ host: WHATSAPP_HOST, port: WHATSAPP_PORT });
213
- this.socket.on('connect', () => this._onTcpConnect());
215
+ // OS-level TCP keepalive: detects silently-dead NAT connections that never send FIN/RST
216
+ this.socket.setKeepAlive(true, 15000);
217
+ // Connect-phase watchdog: abort if TCP handshake takes >20s
218
+ this._connectTimeoutTimer = setTimeout(() => {
219
+ if (this._connectReject) {
220
+ const err = new Error('WA TCP connect timeout (20s)');
221
+ this._connectReject(err);
222
+ this._connectResolve = null;
223
+ this._connectReject = null;
224
+ }
225
+ try { this.socket.destroy(); } catch (_) {}
226
+ }, 20000);
227
+ this.socket.on('connect', () => {
228
+ clearTimeout(this._connectTimeoutTimer);
229
+ this._connectTimeoutTimer = null;
230
+ this._onTcpConnect();
231
+ });
214
232
  this.socket.on('data', (data) => this._onData(data));
215
233
  this.socket.on('error', (err) => this._onTcpError(err));
216
234
  this.socket.on('close', () => this._onTcpClose());
@@ -238,6 +256,7 @@ class NoiseSocket extends EventEmitter {
238
256
  }
239
257
 
240
258
  _onData(data) {
259
+ this._lastRxAt = Date.now(); // watchdog uses this to detect dead connections
241
260
  this.rxBuf = Buffer.concat([this.rxBuf, data]);
242
261
  this._processBuffer();
243
262
  }
@@ -294,7 +313,7 @@ class NoiseSocket extends EventEmitter {
294
313
  shortConnect: false,
295
314
  connectType: 1,
296
315
  connectReason: 1,
297
- connectAttemptCount: 0,
316
+ connectAttemptCount: (this.store.connectAttemptCount || 0),
298
317
  device: 0,
299
318
  oc: false,
300
319
  userAgent: {
@@ -407,6 +426,8 @@ class NoiseSocket extends EventEmitter {
407
426
  }
408
427
 
409
428
  _onTcpError(err) {
429
+ clearTimeout(this._connectTimeoutTimer);
430
+ this._connectTimeoutTimer = null;
410
431
  if (this._connectReject) {
411
432
  this._connectReject(err);
412
433
  this._connectResolve = null;
@@ -416,8 +437,17 @@ class NoiseSocket extends EventEmitter {
416
437
  }
417
438
 
418
439
  _onTcpClose() {
440
+ clearTimeout(this._connectTimeoutTimer);
441
+ this._connectTimeoutTimer = null;
419
442
  this.connected = false;
420
443
  this.secured = false;
444
+ // If the TCP connection closed while the WA handshake was still in progress,
445
+ // the Promise returned by connect() would hang forever — reject it now.
446
+ if (this._connectReject) {
447
+ this._connectReject(new Error('WA TCP closed during handshake'));
448
+ this._connectResolve = null;
449
+ this._connectReject = null;
450
+ }
421
451
  this.emit('close');
422
452
  }
423
453
 
@@ -7,8 +7,8 @@ const { SenderKeyStore, SenderKeyCrypto } = require('./SenderKey');
7
7
 
8
8
  const { SessionCipher, SessionBuilder, ProtocolAddress, keyhelper } = libsignal;
9
9
 
10
- const PRE_KEY_COUNT = 100;
11
- const PRE_KEY_MIN = 10;
10
+ const PRE_KEY_COUNT = 800;
11
+ const PRE_KEY_MIN = 50;
12
12
  const PRE_KEY_START = 1;
13
13
 
14
14
  // ─── Per-JID async mutex ────────────
@@ -1,3 +1,5 @@
1
+ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('../../logger');
2
+
1
3
 
2
4
  'use strict';
3
5
 
@@ -40,7 +42,7 @@ function scrubPubKeyFormat(pubKey) {
40
42
  if (pubKey.byteLength == 33) {
41
43
  return pubKey.slice(1);
42
44
  } else {
43
- console.error("WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey");
45
+ _whaErr('WARNING: Expected pubkey of length 33, please report the ST and client that generated the pubkey');
44
46
  return pubKey;
45
47
  }
46
48
  }
@@ -1,3 +1,5 @@
1
+ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('../../logger');
2
+
1
3
  // vim: ts=4:sw=4:expandtab
2
4
 
3
5
  /*
@@ -47,7 +49,7 @@ module.exports = function(bucket, awaitable) {
47
49
  if (typeof bucket === 'string') {
48
50
  awaitable.name = bucket;
49
51
  } else {
50
- console.warn("Unhandled bucket type (for naming):", typeof bucket, bucket);
52
+ _whaWarn('Unhandled bucket type (for naming): ' + typeof bucket);
51
53
  }
52
54
  }
53
55
  let inactive;
@@ -1,3 +1,5 @@
1
+ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('../../logger');
2
+
1
3
 
2
4
  'use strict';
3
5
 
@@ -71,7 +73,7 @@ class SessionBuilder {
71
73
  }
72
74
  const existingOpenSession = record.getOpenSession();
73
75
  if (existingOpenSession) {
74
- console.warn("Closing open session in favor of incoming prekey bundle");
76
+ _whaWarn('Closing open session in favor of incoming prekey bundle');
75
77
  record.closeSession(existingOpenSession);
76
78
  }
77
79
  record.setSession(await this.initSession(false, preKeyPair, signedPreKeyPair,
@@ -1,3 +1,5 @@
1
+ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('../../logger');
2
+
1
3
  // vim: ts=4:sw=4:expandtab
2
4
 
3
5
  const ChainType = require('./chain_type');
@@ -175,7 +177,7 @@ class SessionCipher {
175
177
  // was the most current. Simply make a note of it and continue. If our
176
178
  // actual open session is for reason invalid, that must be handled via
177
179
  // a full SessionError response.
178
- console.warn("Decrypted message with closed session.");
180
+ _whaWarn('Decrypted message with closed session.');
179
181
  }
180
182
  await this.storeRecord(record);
181
183
  return result.plaintext;
@@ -1,3 +1,5 @@
1
+ const { dbg: _whaDbg, warn: _whaWarn, err: _whaErr, info: _whaInfo } = require('../../logger');
2
+
1
3
  // vim: ts=4:sw=4
2
4
 
3
5
  const BaseKeyType = require('./base_key_type');
@@ -170,9 +172,7 @@ const migrations = [{
170
172
  } else {
171
173
  for (const key in sessions) {
172
174
  if (sessions[key].indexInfo.closed === -1) {
173
- console.error('V1 session storage migration error: registrationId',
174
- data.registrationId, 'for open session version',
175
- data.version);
175
+ _whaErr('V1 session storage migration error: registrationId ' + data.registrationId + ' for open session version ' + data.version);
176
176
  }
177
177
  }
178
178
  }
@@ -190,7 +190,7 @@ class SessionRecord {
190
190
  let run = (data.version === undefined);
191
191
  for (let i = 0; i < migrations.length; ++i) {
192
192
  if (run) {
193
- console.info("Migrating session to:", migrations[i].version);
193
+ _whaInfo('Migrating session to: ' + migrations[i].version);
194
194
  migrations[i].migrate(data);
195
195
  } else if (migrations[i].version === data.version) {
196
196
  run = true;
@@ -267,18 +267,18 @@ class SessionRecord {
267
267
 
268
268
  closeSession(session) {
269
269
  if (this.isClosed(session)) {
270
- console.warn("Session already closed", session);
270
+ _whaWarn('Session already closed');
271
271
  return;
272
272
  }
273
- console.info("Closing session:", session);
273
+ _whaInfo('Closing session');
274
274
  session.indexInfo.closed = Date.now();
275
275
  }
276
276
 
277
277
  openSession(session) {
278
278
  if (!this.isClosed(session)) {
279
- console.warn("Session already open");
279
+ _whaWarn('Session already open');
280
280
  }
281
- console.info("Opening session:", session);
281
+ _whaInfo('Opening session');
282
282
  session.indexInfo.closed = -1;
283
283
  }
284
284
 
@@ -298,7 +298,7 @@ class SessionRecord {
298
298
  }
299
299
  }
300
300
  if (oldestKey) {
301
- console.info("Removing old closed session:", oldestSession);
301
+ _whaInfo('Removing old closed session');
302
302
  delete this.sessions[oldestKey];
303
303
  } else {
304
304
  throw new Error('Corrupt sessions object');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whalibmob",
3
- "version": "5.5.30",
3
+ "version": "5.5.31",
4
4
  "description": "WhatsApp library for interaction with WhatsApp Mobile API no web",
5
5
  "author": "Kunboruto20",
6
6
  "main": "index.js",
@@ -46,6 +46,7 @@
46
46
  "protobufjs": "^6.11.4",
47
47
  "uuid": "^11.1.0",
48
48
  "async-mutex": "^0.5.0",
49
- "@cacheable/node-cache": "^3.0.1"
49
+ "@cacheable/node-cache": "^3.0.1",
50
+ "pino": "^9.0.0"
50
51
  }
51
52
  }