@violetix/baileys 0.5.5 → 0.6.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.
Files changed (80) hide show
  1. package/README.md +1530 -2009
  2. package/WAProto/index.d.ts +14030 -14586
  3. package/WAProto/index.js +58426 -88874
  4. package/lib/Defaults/index.d.ts +2 -7
  5. package/lib/Defaults/index.js +5 -20
  6. package/lib/Signal/Group/sender-key-message.js +19 -1
  7. package/lib/Signal/libsignal.js +82 -29
  8. package/lib/Signal/xeddsa-fast.js +114 -0
  9. package/lib/Socket/Client/types.js +6 -8
  10. package/lib/Socket/Client/websocket.js +3 -38
  11. package/lib/Socket/business.d.ts +0 -3
  12. package/lib/Socket/chats.d.ts +0 -3
  13. package/lib/Socket/chats.js +18 -45
  14. package/lib/Socket/communities.d.ts +0 -3
  15. package/lib/Socket/graphql.js +3 -7
  16. package/lib/Socket/groups.d.ts +0 -3
  17. package/lib/Socket/groups.js +3 -8
  18. package/lib/Socket/index.d.ts +15 -17
  19. package/lib/Socket/messages-recv.d.ts +0 -3
  20. package/lib/Socket/messages-recv.js +43 -127
  21. package/lib/Socket/messages-send.d.ts +0 -4
  22. package/lib/Socket/messages-send.js +63 -191
  23. package/lib/Socket/newsletter.d.ts +0 -3
  24. package/lib/Socket/newsletter.js +1 -2
  25. package/lib/Socket/newsletter.js.map +1 -1
  26. package/lib/Socket/socket.d.ts +0 -9
  27. package/lib/Socket/socket.js +53 -341
  28. package/lib/Store/make-in-memory-store.js +30 -41
  29. package/lib/Types/Auth.d.ts +2 -60
  30. package/lib/Types/Bussines.d.ts +2 -9
  31. package/lib/Types/Call.d.ts +2 -22
  32. package/lib/Types/Chat.d.ts +1 -2
  33. package/lib/Types/Contact.d.ts +2 -11
  34. package/lib/Types/Events.d.ts +2 -41
  35. package/lib/Types/GroupMetadata.d.ts +2 -36
  36. package/lib/Types/Message.d.ts +1 -11
  37. package/lib/Types/Product.d.ts +2 -5
  38. package/lib/Types/Signal.d.ts +2 -10
  39. package/lib/Types/Socket.d.ts +2 -33
  40. package/lib/Types/USync.d.ts +2 -9
  41. package/lib/Types/index.d.ts +1 -3
  42. package/lib/Utils/auth-utils.d.ts +2 -3
  43. package/lib/Utils/auth-utils.js +53 -37
  44. package/lib/Utils/business.js +11 -25
  45. package/lib/Utils/chat-utils.js +4 -6
  46. package/lib/Utils/decode-wa-message.js +2 -2
  47. package/lib/Utils/event-buffer.js +12 -21
  48. package/lib/Utils/generics.d.ts +5 -5
  49. package/lib/Utils/generics.js +7 -25
  50. package/lib/Utils/history.d.ts +1 -2
  51. package/lib/Utils/history.js +83 -137
  52. package/lib/Utils/index.d.ts +0 -4
  53. package/lib/Utils/index.js +0 -4
  54. package/lib/Utils/listener-guard.js +0 -2
  55. package/lib/Utils/message-retry-manager.js +0 -20
  56. package/lib/Utils/messages-media.d.ts +8 -39
  57. package/lib/Utils/messages-media.js +89 -218
  58. package/lib/Utils/messages.d.ts +0 -1
  59. package/lib/Utils/messages.js +36 -122
  60. package/lib/Utils/process-message.d.ts +0 -18
  61. package/lib/Utils/process-message.js +47 -99
  62. package/lib/Utils/rich-message-utils.js +4 -3
  63. package/lib/Utils/use-multi-file-auth-state.d.ts +3 -5
  64. package/lib/Utils/use-multi-file-auth-state.js +6 -27
  65. package/lib/Utils/use-single-file-auth-state.d.ts +1 -1
  66. package/lib/Utils/use-single-file-auth-state.js +3 -12
  67. package/lib/WABinary/types.d.ts +2 -6
  68. package/lib/WAUSync/Protocols/USyncBusinessProtocol.js +3 -6
  69. package/lib/WAUSync/Protocols/USyncContactProtocol.js +2 -3
  70. package/lib/WAUSync/USyncQuery.d.ts +1 -1
  71. package/lib/WAUSync/USyncQuery.js +4 -7
  72. package/package.json +107 -123
  73. package/lib/Utils/a2ui.d.ts +0 -50
  74. package/lib/Utils/a2ui.js +0 -168
  75. package/lib/Utils/anchor-guard.d.ts +0 -71
  76. package/lib/Utils/anchor-guard.js +0 -404
  77. package/lib/Utils/connection-manager.d.ts +0 -44
  78. package/lib/Utils/connection-manager.js +0 -258
  79. package/lib/Utils/ignore-key.d.ts +0 -51
  80. package/lib/Utils/ignore-key.js +0 -176
@@ -6,9 +6,7 @@ import { proto } from '../../WAProto/index.js';
6
6
  import { DEF_CALLBACK_PREFIX, DEF_TAG_PREFIX, INITIAL_PREKEY_COUNT, MIN_PREKEY_COUNT, NOISE_WA_HEADER, PROCESSABLE_HISTORY_TYPES, TimeMs, UPLOAD_TIMEOUT } from '../Defaults/index.js';
7
7
  import { QueryIds, ReachoutTimelockEnforcementType } from '../Types/index.js';
8
8
  import { DisconnectReason, XWAPaths } from '../Types/index.js';
9
- import { addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, buildPairingQRData, bytesToCrockford, configureSuccessfulPairing, Curve, derivePairingCodeKey, fetchLatestWaWebVersion, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, getPairingCodePlatform, invalidPairingCodeChars, makeEventBuffer, makeNoiseHandler, normalizePairingCode, PAIRING_CODE_ALPHABET, promiseTimeout, signedKeyPair, xmppSignedPreKey } from '../Utils/index.js';
10
- import { ignoredStanzaNeedsAck, makeIgnoreKeyRegistry } from '../Utils/ignore-key.js';
11
- import { buildAckStanza } from '../Utils/stanza-ack.js';
9
+ import { addTransactionCapability, aesEncryptCTR, bindWaitForConnectionUpdate, buildPairingQRData, bytesToCrockford, configureSuccessfulPairing, Curve, derivePairingCodeKey, generateLoginNode, generateMdTagPrefix, generateRegistrationNode, getCodeFromWSError, getErrorCodeFromStreamError, getNextPreKeysNode, getPairingCodePlatform, invalidPairingCodeChars, makeEventBuffer, makeNoiseHandler, normalizePairingCode, PAIRING_CODE_ALPHABET, promiseTimeout, signedKeyPair, xmppSignedPreKey } from '../Utils/index.js';
12
10
  import { assertNodeErrorFree, binaryNodeToString, encodeBinaryNode, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildren, isLidUser, jidDecode, jidEncode, S_WHATSAPP_NET } from '../WABinary/index.js';
13
11
  import { BinaryInfo } from '../WAM/BinaryInfo.js';
14
12
  import { USyncQuery, USyncUser } from '../WAUSync/index.js';
@@ -21,23 +19,6 @@ import { executeWMexQuery } from './mex.js';
21
19
  * - listen to messages and emit events
22
20
  * - query phone connection
23
21
  */
24
- /**
25
- * Version the server accepted after it rejected the configured one with 405 (client_too_old).
26
- * Process-wide on purpose: every socket built afterwards with a static `version` reuses it,
27
- * so a stale hardcoded version heals on the next reconnect instead of looping on 405.
28
- */
29
- let latestVersionOverride;
30
- /**
31
- * Reasons that end a session for good. Reconnecting any of these is how an unattended host turns
32
- * one logout into an auth loop and gets the number banned.
33
- */
34
- const DEAD_DISCONNECT_REASONS = new Set([
35
- DisconnectReason.loggedOut,
36
- DisconnectReason.forbidden,
37
- DisconnectReason.multideviceMismatch,
38
- DisconnectReason.badSession,
39
- DisconnectReason.connectionReplaced
40
- ]);
41
22
  export const makeSocket = (config) => {
42
23
  const { waWebSocketUrl, connectTimeoutMs, logger, keepAliveIntervalMs, keepAliveGracePeriodMs, browser, auth: authState, printQRInTerminal, defaultQueryTimeoutMs, transactionOpts, qrTimeout, makeSignalRepository } = config;
43
24
  const publicWAMBuffer = new BinaryInfo();
@@ -63,9 +44,9 @@ export const makeSocket = (config) => {
63
44
  url.searchParams.append('ED', authState.creds.routingInfo.toString('base64url'));
64
45
  }
65
46
  /** ephemeral key pair used to encrypt/decrypt communication. Unique for each connection */
66
- let ephemeralKeyPair = Curve.generateKeyPair();
47
+ const ephemeralKeyPair = Curve.generateKeyPair();
67
48
  /** WA noise protocol wrapper */
68
- let noise = makeNoiseHandler({
49
+ const noise = makeNoiseHandler({
69
50
  keyPair: ephemeralKeyPair,
70
51
  NOISE_HEADER: NOISE_WA_HEADER,
71
52
  logger,
@@ -147,13 +128,12 @@ export const makeSocket = (config) => {
147
128
  node.attrs.id = generateMessageTag();
148
129
  }
149
130
  const msgId = node.attrs.id;
150
- pendingQueries++;
151
131
  const result = await promiseTimeout(timeoutMs, async (resolve, reject) => {
152
132
  const result = waitForMessage(msgId, timeoutMs).catch(reject);
153
133
  sendNode(node)
154
134
  .then(async () => resolve(await result))
155
135
  .catch(reject);
156
- }).finally(() => { pendingQueries--; });
136
+ });
157
137
  if (result && 'tag' in result) {
158
138
  assertNodeErrorFree(result);
159
139
  }
@@ -271,8 +251,7 @@ export const makeSocket = (config) => {
271
251
  * where usernameKey is the PIN protecting the username.
272
252
  */
273
253
  const onWhatsAppUsername = async (...queries) => {
274
- // username users are LID-only; ask for LID addressing like WA Web does
275
- const usyncQuery = new USyncQuery().withContactProtocol('lid').withUsernameProtocol().withBusinessProtocol(null);
254
+ const usyncQuery = new USyncQuery().withContactProtocol().withUsernameProtocol();
276
255
  const normalized = [];
277
256
  for (const query of queries) {
278
257
  const raw = typeof query === 'string' ? query : query?.username;
@@ -302,10 +281,7 @@ export const makeSocket = (config) => {
302
281
  return results.list.map((result, index) => ({
303
282
  username: typeof result.username === 'string' ? result.username : normalized[index] || '',
304
283
  jid: result.id,
305
- exists: result.contact === true || result.keyRequired === true,
306
- keyRequired: result.keyRequired === true,
307
- isBusiness: !!result.business,
308
- pnJid: result.business?.pnJid || undefined
284
+ exists: result.contact === true
309
285
  }));
310
286
  };
311
287
  /** fetch the @username of one or more jids, if they have one set */
@@ -384,31 +360,14 @@ export const makeSocket = (config) => {
384
360
  };
385
361
  const ev = makeEventBuffer(logger);
386
362
  const { creds } = authState;
387
- /**
388
- * Last identity this socket has been logged in as. `sock.user` is a getter over
389
- * `authState.creds.me`, and hosts read it as `sock.user.id` with no optional chaining (the
390
- * @neoxr/wb engine does exactly that in serialize() and in its JID() helper). One stanza that
391
- * arrives while `creds.me` is momentarily absent therefore throws inside the host's event
392
- * listener instead of being processed. Keeping the identity we already know turns that into a
393
- * correct answer: a socket that is receiving stanzas is logged in as this user.
394
- */
395
- let lastMe = creds.me;
396
- let userMissingLogged = false;
397
363
  // add transaction capability
398
364
  const keys = addTransactionCapability(authState.keys, logger, transactionOpts);
399
365
  const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync);
400
366
  let lastDateRecv;
401
- /** queries awaiting a reply; keep-alive skips its ping while these are flowing */
402
- let pendingQueries = 0;
403
367
  let epoch = 1;
404
368
  let keepAliveReq;
405
369
  let qrTimer;
406
370
  let closed = false;
407
- /** transparent transport reconnect state; see scheduleReconnect */
408
- let reconnecting = false;
409
- let reconnectAttempts = 0;
410
- let reconnectTimer;
411
- let reconnectWaitResolve;
412
371
  /** set once WhatsApp sends pair-device; requestPairingCode queues until then */
413
372
  let pairingReady = false;
414
373
  let pairingInProgress = false;
@@ -417,8 +376,6 @@ export const makeSocket = (config) => {
417
376
  let pendingPairingResolve;
418
377
  let pendingPairingReject;
419
378
  const socketEndHandlers = [];
420
- /** per-connection caches (media host auth, ...) that must be dropped when the transport reopens */
421
- const socketReconnectHandlers = [];
422
379
  const timers = new Set();
423
380
  const safeSetTimeout = (fn, ms) => { const id = setTimeout(fn, ms); timers.add(id); return id; };
424
381
  const safeSetInterval = (fn, ms) => { const id = setInterval(fn, ms); timers.add(id); return id; };
@@ -465,19 +422,13 @@ export const makeSocket = (config) => {
465
422
  const handshake = proto.HandshakeMessage.decode(result);
466
423
  logger.trace({ handshake }, 'handshake recv from WA');
467
424
  const keyEnc = noise.processHandshake(handshake, creds.noiseKey);
468
- // `version` may be a resolver (e.g. () => fetchLatestWaWebVersion().then(r => r.version));
469
- // a version the server already rejected as too old is replaced by the fetched one
470
- const resolvedVersion = typeof config.version === 'function'
471
- ? await config.version()
472
- : latestVersionOverride ?? config.version;
473
- const connectConfig = resolvedVersion === config.version ? config : { ...config, version: resolvedVersion };
474
425
  let node;
475
426
  if (!creds.me) {
476
- node = generateRegistrationNode(creds, connectConfig);
427
+ node = generateRegistrationNode(creds, config);
477
428
  logger.info({ node }, 'not logged in, attempting registration...');
478
429
  }
479
430
  else {
480
- node = generateLoginNode(creds.me.id, connectConfig);
431
+ node = generateLoginNode(creds.me.id, config);
481
432
  logger.info({ node }, 'logging in...');
482
433
  }
483
434
  const payloadEnc = noise.encrypt(proto.ClientPayload.encode(node).finish());
@@ -593,11 +544,6 @@ export const makeSocket = (config) => {
593
544
  // Don't throw - allow connection to continue even if pre-key check fails
594
545
  }
595
546
  };
596
- const ignoreKeyRegistry = makeIgnoreKeyRegistry({
597
- getMeId: () => authState.creds.me?.id,
598
- getMeLid: () => authState.creds.me?.lid,
599
- logger
600
- });
601
547
  const onMessageReceived = async (data) => {
602
548
  await noise.decodeFrame(data, frame => {
603
549
  lastDateRecv = new Date();
@@ -608,16 +554,6 @@ export const makeSocket = (config) => {
608
554
  if (logger.level === 'trace') {
609
555
  logger.trace({ xml: binaryNodeToString(frame), msg: 'recv xml' });
610
556
  }
611
- // Filters run before every other handler, so a dropped stanza never reaches the
612
- // decryption or persistence paths. The ack still goes out, otherwise the server
613
- // keeps re-delivering what the host just asked to ignore.
614
- if (ignoreKeyRegistry.shouldDrop(frame)) {
615
- logger.debug({ tag: frame.tag, id: msgId, from: frame.attrs.from }, 'stanza dropped by ignoreKey');
616
- if (ignoredStanzaNeedsAck(frame.tag)) {
617
- sendNode(buildAckStanza(frame, undefined, authState.creds.me?.id)).catch(err => logger.error({ err, id: msgId }, 'failed to ack ignored stanza'));
618
- }
619
- return;
620
- }
621
557
  anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered;
622
558
  const l0 = frame.tag;
623
559
  const l1 = frame.attrs || {};
@@ -642,24 +578,6 @@ export const makeSocket = (config) => {
642
578
  return;
643
579
  }
644
580
  closed = true;
645
- // Name the disconnect reason in the message itself. The code was always on `error.output`,
646
- // but a host does not always receive the Boom: a wrapper that re-emits this close as its own
647
- // `error` event forwards `message` and nothing else, and the messages alone cannot be told
648
- // apart — `CB:failure` says the constant 'Connection Failure' for a 401 device-removal and
649
- // for a transient 503 alike. A host that cannot distinguish them keeps reconnecting a
650
- // session that is really gone, which is how an unattended fleet turns one logout into a
651
- // re-auth loop and gets the number banned.
652
- //
653
- // Done here rather than at each `end()` call site because there are a dozen of them and a
654
- // new one would silently miss it. The existing text is kept as a prefix, so message matching
655
- // that already works keeps working; the suffix is additive.
656
- const statusCode = error?.output?.statusCode;
657
- if (statusCode !== undefined && typeof error?.message === 'string' && !error.message.includes(`[${statusCode} `)) {
658
- try {
659
- error.message = `${error.message} [${statusCode} ${DisconnectReason[statusCode] || 'unknown'}]`;
660
- }
661
- catch { }
662
- }
663
581
  logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
664
582
  // Signal-key writes are debounced (addTransactionCapability). Nothing else in the library
665
583
  // called flush, so every ratchet advance from the last window was dropped on close and the
@@ -678,9 +596,6 @@ export const makeSocket = (config) => {
678
596
  keepAliveReq?.cancel();
679
597
  clearSafeTimer(qrTimer);
680
598
  clearAllTimers();
681
- // release a pending reconnect wait so scheduleReconnect cannot stay suspended
682
- reconnectWaitResolve?.();
683
- reconnectWaitResolve = undefined;
684
599
  // a queued requestPairingCode would hang forever otherwise
685
600
  pairingReady = false;
686
601
  pairingInProgress = false;
@@ -724,90 +639,6 @@ export const makeSocket = (config) => {
724
639
  logger.error({ err }, 'error while closing connection');
725
640
  }
726
641
  };
727
- /** rebuild the state that belongs to one transport connection, not to the socket object */
728
- const resetTransport = () => {
729
- keepAliveReq?.cancel();
730
- keepAliveReq = undefined;
731
- clearSafeTimer(qrTimer);
732
- qrTimer = undefined;
733
- lastDateRecv = undefined;
734
- ephemeralKeyPair = Curve.generateKeyPair();
735
- noise = makeNoiseHandler({
736
- keyPair: ephemeralKeyPair,
737
- NOISE_HEADER: NOISE_WA_HEADER,
738
- logger,
739
- routingInfo: authState?.creds?.routingInfo
740
- });
741
- // the media host token is issued per connection; a cached one fails every upload after this
742
- for (const handler of socketReconnectHandlers) {
743
- try {
744
- handler();
745
- }
746
- catch (err) {
747
- logger.error({ err }, 'error in socket reconnect handler');
748
- }
749
- }
750
- };
751
- /**
752
- * Transparent reconnect. The WebSocket client object is kept and only the raw socket and the
753
- * noise session under it are replaced, so every stanza listener registered by the layers above
754
- * (messages-recv, chats, the host) keeps working and the host never sees a `close` for a drop
755
- * that this socket can repair itself.
756
- */
757
- const scheduleReconnect = async (error) => {
758
- if (reconnecting || closed) {
759
- return;
760
- }
761
- reconnecting = true;
762
- const statusCode = error?.output?.statusCode;
763
- const maxAttempts = config.maxReconnectAttempts ?? 10;
764
- if (!config.autoReconnect || DEAD_DISCONNECT_REASONS.has(statusCode) || reconnectAttempts >= maxAttempts) {
765
- reconnecting = false;
766
- return end(error);
767
- }
768
- reconnectAttempts += 1;
769
- // 515 means the server wants the connection back now; backing off only delays it
770
- const immediate = statusCode === DisconnectReason.restartRequired;
771
- const base = config.reconnectDelayMs ?? 2000;
772
- const ceiling = config.maxReconnectDelayMs ?? 60000;
773
- const backoff = Math.min(ceiling, base * 2 ** (reconnectAttempts - 1));
774
- // jitter keeps a fleet from reconnecting in lockstep after a server-side blip
775
- const delayMs = immediate ? 0 : Math.round(backoff * (0.75 + Math.random() * 0.5));
776
- logger.info({ statusCode, attempt: reconnectAttempts, delayMs }, 'connection lost, reconnecting');
777
- // NOT 'close': a host reads close as "build a new socket", and a second socket for the same
778
- // account is what earns a 440 connectionReplaced
779
- ev.emit('connection.update', {
780
- connection: 'connecting',
781
- lastDisconnect: { error, date: new Date() }
782
- });
783
- await new Promise(resolve => {
784
- reconnectWaitResolve = resolve;
785
- reconnectTimer = safeSetTimeout(() => {
786
- reconnectWaitResolve = undefined;
787
- resolve();
788
- }, delayMs);
789
- });
790
- reconnectTimer = undefined;
791
- reconnecting = false;
792
- if (closed) {
793
- return;
794
- }
795
- try {
796
- resetTransport();
797
- ws.reconnect();
798
- }
799
- catch (err) {
800
- logger.error({ err }, 'failed to reopen the transport');
801
- return end(err);
802
- }
803
- };
804
- /** single entry point for every transport-level close */
805
- const handleDisconnect = (error) => {
806
- if (closed) {
807
- return;
808
- }
809
- return scheduleReconnect(error);
810
- };
811
642
  const waitForSocketOpen = async () => {
812
643
  if (ws.isOpen) {
813
644
  return;
@@ -829,89 +660,48 @@ export const makeSocket = (config) => {
829
660
  ws.off('error', onClose);
830
661
  });
831
662
  };
832
- /**
833
- * Keep-alive, modelled on zapo-js (src/transport/keepalive/WaKeepAlive.ts):
834
- * - self-rescheduling setTimeout with +-10% jitter (min 250ms), so a fleet of sockets in one
835
- * process drifts apart instead of pinging in the same tick
836
- * - at most one ping in flight; a slow pong never stacks more pings on a busy loop
837
- * - no ping while other queries are pending and data arrived recently: the link is
838
- * demonstrably alive and the ping would only add queue pressure
839
- * - "lost" is still decided by inbound silence (interval + grace), and skipped when the
840
- * timer itself fired late (blocked event loop: pongs may be unread in the socket buffer)
841
- */
842
663
  const startKeepAliveRequest = () => {
843
- let timer;
844
- let stopped = false;
845
- let pingInFlight = false;
846
- let lastCheckAt;
847
- let expectedDelayMs = 0;
848
- const nextDelay = () => {
849
- const jitter = Math.max(250, Math.floor(keepAliveIntervalMs * 0.1));
850
- return Math.max(1000, keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter));
851
- };
852
- const schedule = (delayMs) => {
853
- if (stopped) {
854
- return;
855
- }
856
- expectedDelayMs = delayMs;
857
- timer = safeSetTimeout(() => {
858
- timer = undefined;
859
- cb();
860
- schedule(nextDelay());
861
- }, delayMs);
862
- };
863
664
  const cb = () => {
864
665
  if (!lastDateRecv) {
865
666
  lastDateRecv = new Date();
866
667
  }
867
- const now = Date.now();
868
- const sinceRecvMs = now - lastDateRecv.getTime();
869
- const lagMs = lastCheckAt ? now - lastCheckAt - expectedDelayMs : 0;
870
- lastCheckAt = now;
871
- const loopStalled = lagMs > keepAliveGracePeriodMs;
872
- if (loopStalled) {
873
- logger.warn({ lagMs, sinceLastRecvMs: sinceRecvMs }, 'event loop stalled; deferring keep-alive timeout');
668
+ const diff = Date.now() - lastDateRecv.getTime();
669
+ if (diff > keepAliveIntervalMs + keepAliveGracePeriodMs) {
670
+ void end(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
874
671
  }
875
- if (sinceRecvMs > keepAliveIntervalMs + keepAliveGracePeriodMs && !loopStalled) {
876
- void handleDisconnect(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
877
- return;
672
+ else if (ws.isOpen) {
673
+ query({
674
+ tag: 'iq',
675
+ attrs: {
676
+ id: generateMessageTag(),
677
+ to: S_WHATSAPP_NET,
678
+ type: 'get',
679
+ xmlns: 'w:p'
680
+ },
681
+ content: [{ tag: 'ping', attrs: {} }]
682
+ }).catch(err => {
683
+ logger.error({ trace: err.stack }, 'error in sending keep alive');
684
+ });
878
685
  }
879
- if (!ws.isOpen) {
686
+ else {
880
687
  logger.warn('keep alive called when WS not open');
881
- return;
882
- }
883
- if (pingInFlight) {
884
- logger.trace('keep alive skipped: ping already in flight');
885
- return;
886
- }
887
- if (pendingQueries > 0 && sinceRecvMs < keepAliveGracePeriodMs) {
888
- logger.trace({ pendingQueries, sinceRecvMs }, 'keep alive skipped: queries pending with recent inbound');
889
- return;
890
688
  }
891
- pingInFlight = true;
892
- query({
893
- tag: 'iq',
894
- attrs: {
895
- id: generateMessageTag(),
896
- to: S_WHATSAPP_NET,
897
- type: 'get',
898
- xmlns: 'w:p'
899
- },
900
- content: [{ tag: 'ping', attrs: {} }]
901
- }, keepAliveIntervalMs + keepAliveGracePeriodMs)
902
- .catch(err => {
903
- // the verdict stays with inbound silence above; a failed ping alone is not "lost"
904
- logger.warn({ trace: err.stack }, 'error in sending keep alive');
905
- })
906
- .finally(() => { pingInFlight = false; });
907
689
  };
908
- // random first tick staggers sockets that connect together (reconnect storms)
909
- schedule(Math.floor(Math.random() * (keepAliveIntervalMs / 2)));
690
+ // Per socket, so keepAliveIntervalMs from the config is actually the ping interval: the
691
+ // shared process-wide timer ran at a hardcoded 30s while the staleness check below still
692
+ // used the configured value, so any interval other than 30s produced false connectionLost.
693
+ // The random start offset staggers the fleet - on the shared timer every socket in the
694
+ // process pinged in the same tick, which is a self-inflicted burst at 100+ sessions.
695
+ let pinger;
696
+ const starter = safeSetTimeout(() => {
697
+ cb();
698
+ pinger = safeSetInterval(cb, keepAliveIntervalMs);
699
+ }, Math.floor(Math.random() * (keepAliveIntervalMs / 2)));
910
700
  keepAliveReq = {
911
701
  cb,
912
702
  cancel: () => {
913
- stopped = true;
914
- clearSafeTimer(timer);
703
+ clearSafeTimer(starter);
704
+ clearSafeTimer(pinger);
915
705
  }
916
706
  };
917
707
  };
@@ -1024,14 +814,14 @@ export const makeSocket = (config) => {
1024
814
  if (customPairingCode && customPairingCode.length !== 8) {
1025
815
  throw new Error('Custom pairing code must be exactly 8 chars');
1026
816
  }
1027
- // WhatsApp Web rejects these characters up front: the phone normalizes 0/I/O/U before it
1028
- // derives its key, so a code containing them fails on the phone ("Couldn't link device")
1029
- // with nothing on the wire. Fail here instead, where the caller can see it.
817
+ // Not fatal, because whether WhatsApp normalizes these characters is not observable from
818
+ // here - but it is the one thing that fails silently on the phone rather than on the wire,
819
+ // so it gets named in the log instead of being guessed at during the next outage.
1030
820
  const wantedCode = customPairingCode ? normalizePairingCode(customPairingCode) : undefined;
1031
821
  if (wantedCode) {
1032
822
  const invalid = invalidPairingCodeChars(wantedCode);
1033
823
  if (invalid.length) {
1034
- throw new Boom(`Custom pairing code "${wantedCode}" contains characters WhatsApp does not accept (${invalid.join(', ')}); allowed: ${PAIRING_CODE_ALPHABET}`, { statusCode: 400, data: { pairingCode: wantedCode, invalid } });
824
+ logger.warn({ pairingCode: wantedCode, invalid, allowed: PAIRING_CODE_ALPHABET }, 'custom pairing code uses characters absent from WhatsApp\'s own alphabet; if linking fails with "Couldn\'t link device", try a code built only from the allowed characters');
1035
825
  }
1036
826
  }
1037
827
  if (pairingInProgress) {
@@ -1103,13 +893,13 @@ export const makeSocket = (config) => {
1103
893
  }
1104
894
  catch (err) {
1105
895
  logger.error({ err }, 'error in validating connection');
1106
- void handleDisconnect(err);
896
+ void end(err);
1107
897
  }
1108
898
  });
1109
- ws.on('error', mapWebSocketError(handleDisconnect));
1110
- ws.on('close', () => void handleDisconnect(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })));
899
+ ws.on('error', mapWebSocketError(end));
900
+ ws.on('close', () => void end(new Boom('Connection Terminated', { statusCode: DisconnectReason.connectionClosed })));
1111
901
  // the server terminated the connection
1112
- ws.on('CB:xmlstreamend', () => void handleDisconnect(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed })));
902
+ ws.on('CB:xmlstreamend', () => void end(new Boom('Connection Terminated by Server', { statusCode: DisconnectReason.connectionClosed })));
1113
903
  // QR gen
1114
904
  ws.on('CB:iq,type:set,pair-device', async (stanza) => {
1115
905
  const iq = {
@@ -1174,9 +964,6 @@ export const makeSocket = (config) => {
1174
964
  });
1175
965
  // login complete
1176
966
  ws.on('CB:success', async (node) => {
1177
- // a completed handshake clears the reconnect budget: the next drop starts from the base delay
1178
- reconnectAttempts = 0;
1179
- reconnecting = false;
1180
967
  try {
1181
968
  updateServerTimeOffset(node);
1182
969
  await uploadPreKeysToServerIfRequired();
@@ -1195,11 +982,6 @@ export const makeSocket = (config) => {
1195
982
  logger.info('opened connection to WA');
1196
983
  clearTimeout(qrTimer); // will never happen in all likelyhood -- but just in case WA sends success on first try
1197
984
  ev.emit('creds.update', { me: { ...authState.creds.me, lid: node.attrs.lid } });
1198
- if (!authState.creds.me?.id) {
1199
- // `me` absent: the spread above produced a lid-only identity, which hosts then read as
1200
- // `sock.user.id === undefined`. Say so once per socket instead of letting it look healthy.
1201
- logger.error({ credsKeys: Object.keys(authState.creds), registered: authState.creds.registered, hasAccount: !!authState.creds.account }, 'login completed but creds.me.id is missing — host reads sock.user.id unguarded');
1202
- }
1203
985
  ev.emit('connection.update', { connection: 'open' });
1204
986
  void sendUnifiedSession();
1205
987
  if (node.attrs.lid && authState.creds.me?.id) {
@@ -1230,62 +1012,23 @@ export const makeSocket = (config) => {
1230
1012
  const [reasonNode] = getAllBinaryNodeChildren(node);
1231
1013
  logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out');
1232
1014
  const { reason, statusCode } = getErrorCodeFromStreamError(node);
1233
- void handleDisconnect(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }));
1015
+ void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }));
1234
1016
  });
1235
1017
  // stream fail, possible logout
1236
- ws.on('CB:failure', async (node) => {
1018
+ ws.on('CB:failure', (node) => {
1237
1019
  const reason = +(node.attrs.reason || 500);
1238
- if (reason === 405 && typeof config.version !== 'function') {
1239
- // client_too_old: fetch the live WA Web build so the caller's reconnect uses it
1240
- const latest = await fetchLatestWaWebVersion({});
1241
- if (latest.isLatest) {
1242
- latestVersionOverride = latest.version;
1243
- logger.warn({ rejected: latestVersionOverride === config.version ? undefined : config.version, latest: latest.version }, 'server rejected the client version as too old (405); the next connection uses the latest WA Web version');
1244
- }
1245
- else {
1246
- logger.warn({ err: latest.error?.message }, 'server rejected the client version as too old (405) and the latest version could not be fetched; pass a current `version`');
1247
- }
1248
- }
1249
- void handleDisconnect(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }));
1020
+ void end(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }));
1250
1021
  });
1251
1022
  ws.on('CB:ib,,downgrade_webclient', () => {
1252
- void handleDisconnect(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }));
1023
+ void end(new Boom('Multi-device beta not joined', { statusCode: DisconnectReason.multideviceMismatch }));
1253
1024
  });
1254
- // The server delivers the offline queue in windows and waits for another <offline_batch/>
1255
- // before sending the next one. A single request only drained the first 100 stanzas, so a
1256
- // larger queue never finished and grew on every reconnect. Request the next window each
1257
- // time a queued stanza (attrs.offline) arrives, debounced so a burst yields one request.
1258
- // ponytail: no stanza timeout; the terminal 'offline' bulletin ends the loop.
1259
- let offlineResuming = false;
1260
- let offlineBatchInFlight = false;
1261
- let offlineBatchTimer;
1262
- const requestOfflineBatch = () => {
1263
- if (!offlineResuming || offlineBatchInFlight) {
1264
- return;
1265
- }
1266
- offlineBatchInFlight = true;
1267
- sendNode({
1025
+ ws.on('CB:ib,,offline_preview', async (node) => {
1026
+ logger.info('offline preview received', JSON.stringify(node));
1027
+ await sendNode({
1268
1028
  tag: 'ib',
1269
1029
  attrs: {},
1270
1030
  content: [{ tag: 'offline_batch', attrs: { count: '100' } }]
1271
- }).catch(err => {
1272
- offlineBatchInFlight = false;
1273
- logger.debug({ err }, 'offline batch request failed');
1274
1031
  });
1275
- };
1276
- ws.on('CB:ib,,offline_preview', (node) => {
1277
- logger.info('offline preview received', JSON.stringify(node));
1278
- offlineResuming = true;
1279
- offlineBatchInFlight = false;
1280
- requestOfflineBatch();
1281
- });
1282
- ws.on('frame', (frame) => {
1283
- if (!offlineResuming || frame instanceof Uint8Array || frame.attrs?.offline === undefined) {
1284
- return;
1285
- }
1286
- offlineBatchInFlight = false;
1287
- clearTimeout(offlineBatchTimer);
1288
- offlineBatchTimer = setTimeout(requestOfflineBatch, 100);
1289
1032
  });
1290
1033
  ws.on('CB:ib,,edge_routing', (node) => {
1291
1034
  const edgeRoutingNode = getBinaryNodeChild(node, 'edge_routing');
@@ -1307,8 +1050,6 @@ export const makeSocket = (config) => {
1307
1050
  });
1308
1051
  // called when all offline notifs are handled
1309
1052
  ws.on('CB:ib,,offline', (node) => {
1310
- offlineResuming = false;
1311
- clearTimeout(offlineBatchTimer);
1312
1053
  const child = getBinaryNodeChild(node, 'offline');
1313
1054
  const offlineNotifs = +(child?.attrs.count || 0);
1314
1055
  logger.info(`handled ${offlineNotifs} offline messages/notifications`);
@@ -1332,9 +1073,6 @@ export const makeSocket = (config) => {
1332
1073
  });
1333
1074
  }
1334
1075
  Object.assign(creds, update);
1335
- if (creds.me) {
1336
- lastMe = creds.me;
1337
- }
1338
1076
  });
1339
1077
  const updateServerTimeOffset = ({ attrs }) => {
1340
1078
  const tValue = attrs?.t;
@@ -1381,9 +1119,6 @@ export const makeSocket = (config) => {
1381
1119
  const registerSocketEndHandler = (handler) => {
1382
1120
  socketEndHandlers.push(handler);
1383
1121
  };
1384
- const registerSocketReconnectHandler = (handler) => {
1385
- socketReconnectHandlers.push(handler);
1386
- };
1387
1122
  /**
1388
1123
  * Fetches your account's standing when it comes to restrictions.
1389
1124
  * @returns Returns the state of the restrictions.
@@ -1414,31 +1149,9 @@ export const makeSocket = (config) => {
1414
1149
  authState: { creds, keys },
1415
1150
  signalRepository,
1416
1151
  get user() {
1417
- const me = authState.creds.me ?? lastMe;
1418
- if (!me && !userMissingLogged) {
1419
- userMissingLogged = true;
1420
- logger.error(
1421
- {
1422
- credsKeys: Object.keys(authState.creds),
1423
- registered: authState.creds.registered,
1424
- hasAccount: !!authState.creds.account,
1425
- sameAuthObject: authState === config.auth
1426
- },
1427
- 'sock.user is undefined: creds.me never arrived on this socket (host reads sock.user.id unguarded)'
1428
- );
1429
- }
1430
- return me;
1431
- },
1432
- /**
1433
- * True while the transport is repairing itself. A host that reads this does not build a
1434
- * second socket for the same account, which is what a `ws.isOpen === false` check would
1435
- * make it do during the backoff window and the handshake that follows.
1436
- */
1437
- get isReconnecting() {
1438
- return reconnecting || (reconnectAttempts > 0 && !closed && !ws.isOpen);
1152
+ return authState.creds.me;
1439
1153
  },
1440
1154
  generateMessageTag,
1441
- ignoreKey: ignoreKeyRegistry.register,
1442
1155
  query,
1443
1156
  waitForMessage,
1444
1157
  waitForSocketOpen,
@@ -1447,7 +1160,6 @@ export const makeSocket = (config) => {
1447
1160
  logout,
1448
1161
  end,
1449
1162
  registerSocketEndHandler,
1450
- registerSocketReconnectHandler,
1451
1163
  onUnexpectedError,
1452
1164
  uploadPreKeys,
1453
1165
  uploadPreKeysToServerIfRequired,