@violetix/baileys 0.5.6 → 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 -346
  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,34 +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
- // A copy, and only when it carries an id: `creds.me` is replaced wholesale by some updates (a
396
- // `CB:success` or a pushName change can arrive with a lid-only object), and holding the same
397
- // reference would follow that replacement into the fallback.
398
- let lastMe = creds.me?.id ? { ...creds.me } : undefined;
399
- let userMissingLogged = false;
400
363
  // add transaction capability
401
364
  const keys = addTransactionCapability(authState.keys, logger, transactionOpts);
402
365
  const signalRepository = makeSignalRepository({ creds, keys }, logger, pnFromLIDUSync);
403
366
  let lastDateRecv;
404
- /** queries awaiting a reply; keep-alive skips its ping while these are flowing */
405
- let pendingQueries = 0;
406
367
  let epoch = 1;
407
368
  let keepAliveReq;
408
369
  let qrTimer;
409
370
  let closed = false;
410
- /** transparent transport reconnect state; see scheduleReconnect */
411
- let reconnecting = false;
412
- let reconnectAttempts = 0;
413
- let reconnectTimer;
414
- let reconnectWaitResolve;
415
371
  /** set once WhatsApp sends pair-device; requestPairingCode queues until then */
416
372
  let pairingReady = false;
417
373
  let pairingInProgress = false;
@@ -420,8 +376,6 @@ export const makeSocket = (config) => {
420
376
  let pendingPairingResolve;
421
377
  let pendingPairingReject;
422
378
  const socketEndHandlers = [];
423
- /** per-connection caches (media host auth, ...) that must be dropped when the transport reopens */
424
- const socketReconnectHandlers = [];
425
379
  const timers = new Set();
426
380
  const safeSetTimeout = (fn, ms) => { const id = setTimeout(fn, ms); timers.add(id); return id; };
427
381
  const safeSetInterval = (fn, ms) => { const id = setInterval(fn, ms); timers.add(id); return id; };
@@ -468,19 +422,13 @@ export const makeSocket = (config) => {
468
422
  const handshake = proto.HandshakeMessage.decode(result);
469
423
  logger.trace({ handshake }, 'handshake recv from WA');
470
424
  const keyEnc = noise.processHandshake(handshake, creds.noiseKey);
471
- // `version` may be a resolver (e.g. () => fetchLatestWaWebVersion().then(r => r.version));
472
- // a version the server already rejected as too old is replaced by the fetched one
473
- const resolvedVersion = typeof config.version === 'function'
474
- ? await config.version()
475
- : latestVersionOverride ?? config.version;
476
- const connectConfig = resolvedVersion === config.version ? config : { ...config, version: resolvedVersion };
477
425
  let node;
478
426
  if (!creds.me) {
479
- node = generateRegistrationNode(creds, connectConfig);
427
+ node = generateRegistrationNode(creds, config);
480
428
  logger.info({ node }, 'not logged in, attempting registration...');
481
429
  }
482
430
  else {
483
- node = generateLoginNode(creds.me.id, connectConfig);
431
+ node = generateLoginNode(creds.me.id, config);
484
432
  logger.info({ node }, 'logging in...');
485
433
  }
486
434
  const payloadEnc = noise.encrypt(proto.ClientPayload.encode(node).finish());
@@ -596,11 +544,6 @@ export const makeSocket = (config) => {
596
544
  // Don't throw - allow connection to continue even if pre-key check fails
597
545
  }
598
546
  };
599
- const ignoreKeyRegistry = makeIgnoreKeyRegistry({
600
- getMeId: () => authState.creds.me?.id,
601
- getMeLid: () => authState.creds.me?.lid,
602
- logger
603
- });
604
547
  const onMessageReceived = async (data) => {
605
548
  await noise.decodeFrame(data, frame => {
606
549
  lastDateRecv = new Date();
@@ -611,16 +554,6 @@ export const makeSocket = (config) => {
611
554
  if (logger.level === 'trace') {
612
555
  logger.trace({ xml: binaryNodeToString(frame), msg: 'recv xml' });
613
556
  }
614
- // Filters run before every other handler, so a dropped stanza never reaches the
615
- // decryption or persistence paths. The ack still goes out, otherwise the server
616
- // keeps re-delivering what the host just asked to ignore.
617
- if (ignoreKeyRegistry.shouldDrop(frame)) {
618
- logger.debug({ tag: frame.tag, id: msgId, from: frame.attrs.from }, 'stanza dropped by ignoreKey');
619
- if (ignoredStanzaNeedsAck(frame.tag)) {
620
- sendNode(buildAckStanza(frame, undefined, authState.creds.me?.id)).catch(err => logger.error({ err, id: msgId }, 'failed to ack ignored stanza'));
621
- }
622
- return;
623
- }
624
557
  anyTriggered = ws.emit(`${DEF_TAG_PREFIX}${msgId}`, frame) || anyTriggered;
625
558
  const l0 = frame.tag;
626
559
  const l1 = frame.attrs || {};
@@ -645,24 +578,6 @@ export const makeSocket = (config) => {
645
578
  return;
646
579
  }
647
580
  closed = true;
648
- // Name the disconnect reason in the message itself. The code was always on `error.output`,
649
- // but a host does not always receive the Boom: a wrapper that re-emits this close as its own
650
- // `error` event forwards `message` and nothing else, and the messages alone cannot be told
651
- // apart — `CB:failure` says the constant 'Connection Failure' for a 401 device-removal and
652
- // for a transient 503 alike. A host that cannot distinguish them keeps reconnecting a
653
- // session that is really gone, which is how an unattended fleet turns one logout into a
654
- // re-auth loop and gets the number banned.
655
- //
656
- // Done here rather than at each `end()` call site because there are a dozen of them and a
657
- // new one would silently miss it. The existing text is kept as a prefix, so message matching
658
- // that already works keeps working; the suffix is additive.
659
- const statusCode = error?.output?.statusCode;
660
- if (statusCode !== undefined && typeof error?.message === 'string' && !error.message.includes(`[${statusCode} `)) {
661
- try {
662
- error.message = `${error.message} [${statusCode} ${DisconnectReason[statusCode] || 'unknown'}]`;
663
- }
664
- catch { }
665
- }
666
581
  logger.info({ trace: error?.stack }, error ? 'connection errored' : 'connection closed');
667
582
  // Signal-key writes are debounced (addTransactionCapability). Nothing else in the library
668
583
  // called flush, so every ratchet advance from the last window was dropped on close and the
@@ -681,9 +596,6 @@ export const makeSocket = (config) => {
681
596
  keepAliveReq?.cancel();
682
597
  clearSafeTimer(qrTimer);
683
598
  clearAllTimers();
684
- // release a pending reconnect wait so scheduleReconnect cannot stay suspended
685
- reconnectWaitResolve?.();
686
- reconnectWaitResolve = undefined;
687
599
  // a queued requestPairingCode would hang forever otherwise
688
600
  pairingReady = false;
689
601
  pairingInProgress = false;
@@ -727,90 +639,6 @@ export const makeSocket = (config) => {
727
639
  logger.error({ err }, 'error while closing connection');
728
640
  }
729
641
  };
730
- /** rebuild the state that belongs to one transport connection, not to the socket object */
731
- const resetTransport = () => {
732
- keepAliveReq?.cancel();
733
- keepAliveReq = undefined;
734
- clearSafeTimer(qrTimer);
735
- qrTimer = undefined;
736
- lastDateRecv = undefined;
737
- ephemeralKeyPair = Curve.generateKeyPair();
738
- noise = makeNoiseHandler({
739
- keyPair: ephemeralKeyPair,
740
- NOISE_HEADER: NOISE_WA_HEADER,
741
- logger,
742
- routingInfo: authState?.creds?.routingInfo
743
- });
744
- // the media host token is issued per connection; a cached one fails every upload after this
745
- for (const handler of socketReconnectHandlers) {
746
- try {
747
- handler();
748
- }
749
- catch (err) {
750
- logger.error({ err }, 'error in socket reconnect handler');
751
- }
752
- }
753
- };
754
- /**
755
- * Transparent reconnect. The WebSocket client object is kept and only the raw socket and the
756
- * noise session under it are replaced, so every stanza listener registered by the layers above
757
- * (messages-recv, chats, the host) keeps working and the host never sees a `close` for a drop
758
- * that this socket can repair itself.
759
- */
760
- const scheduleReconnect = async (error) => {
761
- if (reconnecting || closed) {
762
- return;
763
- }
764
- reconnecting = true;
765
- const statusCode = error?.output?.statusCode;
766
- const maxAttempts = config.maxReconnectAttempts ?? 10;
767
- if (!config.autoReconnect || DEAD_DISCONNECT_REASONS.has(statusCode) || reconnectAttempts >= maxAttempts) {
768
- reconnecting = false;
769
- return end(error);
770
- }
771
- reconnectAttempts += 1;
772
- // 515 means the server wants the connection back now; backing off only delays it
773
- const immediate = statusCode === DisconnectReason.restartRequired;
774
- const base = config.reconnectDelayMs ?? 2000;
775
- const ceiling = config.maxReconnectDelayMs ?? 60000;
776
- const backoff = Math.min(ceiling, base * 2 ** (reconnectAttempts - 1));
777
- // jitter keeps a fleet from reconnecting in lockstep after a server-side blip
778
- const delayMs = immediate ? 0 : Math.round(backoff * (0.75 + Math.random() * 0.5));
779
- logger.info({ statusCode, attempt: reconnectAttempts, delayMs }, 'connection lost, reconnecting');
780
- // NOT 'close': a host reads close as "build a new socket", and a second socket for the same
781
- // account is what earns a 440 connectionReplaced
782
- ev.emit('connection.update', {
783
- connection: 'connecting',
784
- lastDisconnect: { error, date: new Date() }
785
- });
786
- await new Promise(resolve => {
787
- reconnectWaitResolve = resolve;
788
- reconnectTimer = safeSetTimeout(() => {
789
- reconnectWaitResolve = undefined;
790
- resolve();
791
- }, delayMs);
792
- });
793
- reconnectTimer = undefined;
794
- reconnecting = false;
795
- if (closed) {
796
- return;
797
- }
798
- try {
799
- resetTransport();
800
- ws.reconnect();
801
- }
802
- catch (err) {
803
- logger.error({ err }, 'failed to reopen the transport');
804
- return end(err);
805
- }
806
- };
807
- /** single entry point for every transport-level close */
808
- const handleDisconnect = (error) => {
809
- if (closed) {
810
- return;
811
- }
812
- return scheduleReconnect(error);
813
- };
814
642
  const waitForSocketOpen = async () => {
815
643
  if (ws.isOpen) {
816
644
  return;
@@ -832,89 +660,48 @@ export const makeSocket = (config) => {
832
660
  ws.off('error', onClose);
833
661
  });
834
662
  };
835
- /**
836
- * Keep-alive, modelled on zapo-js (src/transport/keepalive/WaKeepAlive.ts):
837
- * - self-rescheduling setTimeout with +-10% jitter (min 250ms), so a fleet of sockets in one
838
- * process drifts apart instead of pinging in the same tick
839
- * - at most one ping in flight; a slow pong never stacks more pings on a busy loop
840
- * - no ping while other queries are pending and data arrived recently: the link is
841
- * demonstrably alive and the ping would only add queue pressure
842
- * - "lost" is still decided by inbound silence (interval + grace), and skipped when the
843
- * timer itself fired late (blocked event loop: pongs may be unread in the socket buffer)
844
- */
845
663
  const startKeepAliveRequest = () => {
846
- let timer;
847
- let stopped = false;
848
- let pingInFlight = false;
849
- let lastCheckAt;
850
- let expectedDelayMs = 0;
851
- const nextDelay = () => {
852
- const jitter = Math.max(250, Math.floor(keepAliveIntervalMs * 0.1));
853
- return Math.max(1000, keepAliveIntervalMs + Math.floor((Math.random() * 2 - 1) * jitter));
854
- };
855
- const schedule = (delayMs) => {
856
- if (stopped) {
857
- return;
858
- }
859
- expectedDelayMs = delayMs;
860
- timer = safeSetTimeout(() => {
861
- timer = undefined;
862
- cb();
863
- schedule(nextDelay());
864
- }, delayMs);
865
- };
866
664
  const cb = () => {
867
665
  if (!lastDateRecv) {
868
666
  lastDateRecv = new Date();
869
667
  }
870
- const now = Date.now();
871
- const sinceRecvMs = now - lastDateRecv.getTime();
872
- const lagMs = lastCheckAt ? now - lastCheckAt - expectedDelayMs : 0;
873
- lastCheckAt = now;
874
- const loopStalled = lagMs > keepAliveGracePeriodMs;
875
- if (loopStalled) {
876
- 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 }));
877
671
  }
878
- if (sinceRecvMs > keepAliveIntervalMs + keepAliveGracePeriodMs && !loopStalled) {
879
- void handleDisconnect(new Boom('Connection was lost', { statusCode: DisconnectReason.connectionLost }));
880
- 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
+ });
881
685
  }
882
- if (!ws.isOpen) {
686
+ else {
883
687
  logger.warn('keep alive called when WS not open');
884
- return;
885
- }
886
- if (pingInFlight) {
887
- logger.trace('keep alive skipped: ping already in flight');
888
- return;
889
- }
890
- if (pendingQueries > 0 && sinceRecvMs < keepAliveGracePeriodMs) {
891
- logger.trace({ pendingQueries, sinceRecvMs }, 'keep alive skipped: queries pending with recent inbound');
892
- return;
893
688
  }
894
- pingInFlight = true;
895
- query({
896
- tag: 'iq',
897
- attrs: {
898
- id: generateMessageTag(),
899
- to: S_WHATSAPP_NET,
900
- type: 'get',
901
- xmlns: 'w:p'
902
- },
903
- content: [{ tag: 'ping', attrs: {} }]
904
- }, keepAliveIntervalMs + keepAliveGracePeriodMs)
905
- .catch(err => {
906
- // the verdict stays with inbound silence above; a failed ping alone is not "lost"
907
- logger.warn({ trace: err.stack }, 'error in sending keep alive');
908
- })
909
- .finally(() => { pingInFlight = false; });
910
689
  };
911
- // random first tick staggers sockets that connect together (reconnect storms)
912
- 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)));
913
700
  keepAliveReq = {
914
701
  cb,
915
702
  cancel: () => {
916
- stopped = true;
917
- clearSafeTimer(timer);
703
+ clearSafeTimer(starter);
704
+ clearSafeTimer(pinger);
918
705
  }
919
706
  };
920
707
  };
@@ -1027,14 +814,14 @@ export const makeSocket = (config) => {
1027
814
  if (customPairingCode && customPairingCode.length !== 8) {
1028
815
  throw new Error('Custom pairing code must be exactly 8 chars');
1029
816
  }
1030
- // WhatsApp Web rejects these characters up front: the phone normalizes 0/I/O/U before it
1031
- // derives its key, so a code containing them fails on the phone ("Couldn't link device")
1032
- // 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.
1033
820
  const wantedCode = customPairingCode ? normalizePairingCode(customPairingCode) : undefined;
1034
821
  if (wantedCode) {
1035
822
  const invalid = invalidPairingCodeChars(wantedCode);
1036
823
  if (invalid.length) {
1037
- 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');
1038
825
  }
1039
826
  }
1040
827
  if (pairingInProgress) {
@@ -1106,13 +893,13 @@ export const makeSocket = (config) => {
1106
893
  }
1107
894
  catch (err) {
1108
895
  logger.error({ err }, 'error in validating connection');
1109
- void handleDisconnect(err);
896
+ void end(err);
1110
897
  }
1111
898
  });
1112
- ws.on('error', mapWebSocketError(handleDisconnect));
1113
- 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 })));
1114
901
  // the server terminated the connection
1115
- 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 })));
1116
903
  // QR gen
1117
904
  ws.on('CB:iq,type:set,pair-device', async (stanza) => {
1118
905
  const iq = {
@@ -1177,9 +964,6 @@ export const makeSocket = (config) => {
1177
964
  });
1178
965
  // login complete
1179
966
  ws.on('CB:success', async (node) => {
1180
- // a completed handshake clears the reconnect budget: the next drop starts from the base delay
1181
- reconnectAttempts = 0;
1182
- reconnecting = false;
1183
967
  try {
1184
968
  updateServerTimeOffset(node);
1185
969
  await uploadPreKeysToServerIfRequired();
@@ -1198,11 +982,6 @@ export const makeSocket = (config) => {
1198
982
  logger.info('opened connection to WA');
1199
983
  clearTimeout(qrTimer); // will never happen in all likelyhood -- but just in case WA sends success on first try
1200
984
  ev.emit('creds.update', { me: { ...authState.creds.me, lid: node.attrs.lid } });
1201
- if (!authState.creds.me?.id) {
1202
- // `me` absent: the spread above produced a lid-only identity, which hosts then read as
1203
- // `sock.user.id === undefined`. Say so once per socket instead of letting it look healthy.
1204
- 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');
1205
- }
1206
985
  ev.emit('connection.update', { connection: 'open' });
1207
986
  void sendUnifiedSession();
1208
987
  if (node.attrs.lid && authState.creds.me?.id) {
@@ -1233,62 +1012,23 @@ export const makeSocket = (config) => {
1233
1012
  const [reasonNode] = getAllBinaryNodeChildren(node);
1234
1013
  logger.error({ reasonNode, fullErrorNode: node }, 'stream errored out');
1235
1014
  const { reason, statusCode } = getErrorCodeFromStreamError(node);
1236
- void handleDisconnect(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }));
1015
+ void end(new Boom(`Stream Errored (${reason})`, { statusCode, data: reasonNode || node }));
1237
1016
  });
1238
1017
  // stream fail, possible logout
1239
- ws.on('CB:failure', async (node) => {
1018
+ ws.on('CB:failure', (node) => {
1240
1019
  const reason = +(node.attrs.reason || 500);
1241
- if (reason === 405 && typeof config.version !== 'function') {
1242
- // client_too_old: fetch the live WA Web build so the caller's reconnect uses it
1243
- const latest = await fetchLatestWaWebVersion({});
1244
- if (latest.isLatest) {
1245
- latestVersionOverride = latest.version;
1246
- 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');
1247
- }
1248
- else {
1249
- 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`');
1250
- }
1251
- }
1252
- void handleDisconnect(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }));
1020
+ void end(new Boom('Connection Failure', { statusCode: reason, data: node.attrs }));
1253
1021
  });
1254
1022
  ws.on('CB:ib,,downgrade_webclient', () => {
1255
- 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 }));
1256
1024
  });
1257
- // The server delivers the offline queue in windows and waits for another <offline_batch/>
1258
- // before sending the next one. A single request only drained the first 100 stanzas, so a
1259
- // larger queue never finished and grew on every reconnect. Request the next window each
1260
- // time a queued stanza (attrs.offline) arrives, debounced so a burst yields one request.
1261
- // ponytail: no stanza timeout; the terminal 'offline' bulletin ends the loop.
1262
- let offlineResuming = false;
1263
- let offlineBatchInFlight = false;
1264
- let offlineBatchTimer;
1265
- const requestOfflineBatch = () => {
1266
- if (!offlineResuming || offlineBatchInFlight) {
1267
- return;
1268
- }
1269
- offlineBatchInFlight = true;
1270
- sendNode({
1025
+ ws.on('CB:ib,,offline_preview', async (node) => {
1026
+ logger.info('offline preview received', JSON.stringify(node));
1027
+ await sendNode({
1271
1028
  tag: 'ib',
1272
1029
  attrs: {},
1273
1030
  content: [{ tag: 'offline_batch', attrs: { count: '100' } }]
1274
- }).catch(err => {
1275
- offlineBatchInFlight = false;
1276
- logger.debug({ err }, 'offline batch request failed');
1277
1031
  });
1278
- };
1279
- ws.on('CB:ib,,offline_preview', (node) => {
1280
- logger.info('offline preview received', JSON.stringify(node));
1281
- offlineResuming = true;
1282
- offlineBatchInFlight = false;
1283
- requestOfflineBatch();
1284
- });
1285
- ws.on('frame', (frame) => {
1286
- if (!offlineResuming || frame instanceof Uint8Array || frame.attrs?.offline === undefined) {
1287
- return;
1288
- }
1289
- offlineBatchInFlight = false;
1290
- clearTimeout(offlineBatchTimer);
1291
- offlineBatchTimer = setTimeout(requestOfflineBatch, 100);
1292
1032
  });
1293
1033
  ws.on('CB:ib,,edge_routing', (node) => {
1294
1034
  const edgeRoutingNode = getBinaryNodeChild(node, 'edge_routing');
@@ -1310,8 +1050,6 @@ export const makeSocket = (config) => {
1310
1050
  });
1311
1051
  // called when all offline notifs are handled
1312
1052
  ws.on('CB:ib,,offline', (node) => {
1313
- offlineResuming = false;
1314
- clearTimeout(offlineBatchTimer);
1315
1053
  const child = getBinaryNodeChild(node, 'offline');
1316
1054
  const offlineNotifs = +(child?.attrs.count || 0);
1317
1055
  logger.info(`handled ${offlineNotifs} offline messages/notifications`);
@@ -1335,9 +1073,6 @@ export const makeSocket = (config) => {
1335
1073
  });
1336
1074
  }
1337
1075
  Object.assign(creds, update);
1338
- if (creds.me?.id) {
1339
- lastMe = { ...creds.me };
1340
- }
1341
1076
  });
1342
1077
  const updateServerTimeOffset = ({ attrs }) => {
1343
1078
  const tValue = attrs?.t;
@@ -1384,9 +1119,6 @@ export const makeSocket = (config) => {
1384
1119
  const registerSocketEndHandler = (handler) => {
1385
1120
  socketEndHandlers.push(handler);
1386
1121
  };
1387
- const registerSocketReconnectHandler = (handler) => {
1388
- socketReconnectHandlers.push(handler);
1389
- };
1390
1122
  /**
1391
1123
  * Fetches your account's standing when it comes to restrictions.
1392
1124
  * @returns Returns the state of the restrictions.
@@ -1417,33 +1149,9 @@ export const makeSocket = (config) => {
1417
1149
  authState: { creds, keys },
1418
1150
  signalRepository,
1419
1151
  get user() {
1420
- // `.id`, not just presence: a lid-only `me` is truthy and would be handed to hosts that
1421
- // read `.id` unguarded, which is the crash this fallback exists to prevent.
1422
- const me = authState.creds.me?.id ? authState.creds.me : lastMe;
1423
- if (!me && !userMissingLogged) {
1424
- userMissingLogged = true;
1425
- logger.error(
1426
- {
1427
- credsKeys: Object.keys(authState.creds),
1428
- registered: authState.creds.registered,
1429
- hasAccount: !!authState.creds.account,
1430
- sameAuthObject: authState === config.auth
1431
- },
1432
- 'sock.user is undefined: creds.me never arrived on this socket (host reads sock.user.id unguarded)'
1433
- );
1434
- }
1435
- return me;
1436
- },
1437
- /**
1438
- * True while the transport is repairing itself. A host that reads this does not build a
1439
- * second socket for the same account, which is what a `ws.isOpen === false` check would
1440
- * make it do during the backoff window and the handshake that follows.
1441
- */
1442
- get isReconnecting() {
1443
- return reconnecting || (reconnectAttempts > 0 && !closed && !ws.isOpen);
1152
+ return authState.creds.me;
1444
1153
  },
1445
1154
  generateMessageTag,
1446
- ignoreKey: ignoreKeyRegistry.register,
1447
1155
  query,
1448
1156
  waitForMessage,
1449
1157
  waitForSocketOpen,
@@ -1452,7 +1160,6 @@ export const makeSocket = (config) => {
1452
1160
  logout,
1453
1161
  end,
1454
1162
  registerSocketEndHandler,
1455
- registerSocketReconnectHandler,
1456
1163
  onUnexpectedError,
1457
1164
  uploadPreKeys,
1458
1165
  uploadPreKeysToServerIfRequired,