zapo-js 1.10.0 → 1.11.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 (61) hide show
  1. package/dist/auth/WaAuthClient.d.ts +5 -0
  2. package/dist/auth/WaAuthClient.js +5 -0
  3. package/dist/auth/pairing/WaPairingFlow.d.ts +11 -0
  4. package/dist/auth/pairing/WaPairingFlow.js +97 -40
  5. package/dist/client/WaClient.js +2 -0
  6. package/dist/client/WaClientFactory.js +6 -5
  7. package/dist/client/connection/WaConnectionManager.d.ts +14 -2
  8. package/dist/client/connection/WaConnectionManager.js +55 -22
  9. package/dist/client/coordinators/WaMessageCoordinator.d.ts +16 -4
  10. package/dist/client/coordinators/WaMessageCoordinator.js +4 -8
  11. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +2 -1
  12. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +4 -1
  13. package/dist/client/coordinators/WaProfileCoordinator.d.ts +5 -1
  14. package/dist/client/coordinators/WaStreamControlCoordinator.d.ts +2 -0
  15. package/dist/client/coordinators/WaStreamControlCoordinator.js +6 -1
  16. package/dist/client/messaging/messages.js +24 -8
  17. package/dist/client/persistence/history-sync.d.ts +5 -1
  18. package/dist/client/persistence/history-sync.js +172 -38
  19. package/dist/client/types.d.ts +65 -10
  20. package/dist/esm/auth/WaAuthClient.js +5 -0
  21. package/dist/esm/auth/pairing/WaPairingFlow.js +97 -40
  22. package/dist/esm/client/WaClient.js +2 -0
  23. package/dist/esm/client/WaClientFactory.js +6 -5
  24. package/dist/esm/client/connection/WaConnectionManager.js +55 -22
  25. package/dist/esm/client/coordinators/WaMessageCoordinator.js +4 -8
  26. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +4 -1
  27. package/dist/esm/client/coordinators/WaStreamControlCoordinator.js +6 -1
  28. package/dist/esm/client/messaging/messages.js +25 -9
  29. package/dist/esm/client/persistence/history-sync.js +172 -38
  30. package/dist/esm/infra/log/types.js +2 -1
  31. package/dist/esm/message/context-info.js +2 -0
  32. package/dist/esm/message/encode/content.js +16 -0
  33. package/dist/esm/message/primitives/incoming.js +12 -5
  34. package/dist/esm/protocol/defaults.js +6 -0
  35. package/dist/esm/protocol/message.js +2 -1
  36. package/dist/esm/signal/api/SignalDeviceSyncApi.js +64 -32
  37. package/dist/esm/transport/WaComms.js +94 -16
  38. package/dist/esm/transport/WaWebSocket.js +79 -32
  39. package/dist/esm/transport/node/builders/message.js +1 -1
  40. package/dist/esm/transport/noise/WaNoiseSession.js +6 -5
  41. package/dist/index.d.ts +3 -3
  42. package/dist/infra/log/types.d.ts +1 -0
  43. package/dist/infra/log/types.js +2 -1
  44. package/dist/message/context-info.js +2 -0
  45. package/dist/message/encode/content.d.ts +3 -2
  46. package/dist/message/encode/content.js +17 -0
  47. package/dist/message/primitives/incoming.js +12 -5
  48. package/dist/message/types.d.ts +21 -12
  49. package/dist/protocol/defaults.d.ts +6 -0
  50. package/dist/protocol/defaults.js +6 -0
  51. package/dist/protocol/message.d.ts +1 -0
  52. package/dist/protocol/message.js +2 -1
  53. package/dist/signal/api/SignalDeviceSyncApi.d.ts +27 -5
  54. package/dist/signal/api/SignalDeviceSyncApi.js +64 -32
  55. package/dist/transport/WaComms.d.ts +9 -1
  56. package/dist/transport/WaComms.js +94 -16
  57. package/dist/transport/WaWebSocket.d.ts +8 -2
  58. package/dist/transport/WaWebSocket.js +79 -32
  59. package/dist/transport/node/builders/message.js +1 -1
  60. package/dist/transport/noise/WaNoiseSession.js +6 -5
  61. package/package.json +1 -1
@@ -146,6 +146,11 @@ export declare class WaAuthClient {
146
146
  * call this. The user then enters the returned code in WhatsApp on their
147
147
  * phone under *Linked Devices → Link with phone number instead*.
148
148
  *
149
+ * The server binds the code to the connection that requested it. If the
150
+ * socket drops and reconnects first, the client registers the same code on
151
+ * the new connection; if that fails, `auth_pairing_required` fires with
152
+ * `forceManual: true` and a new code must be requested.
153
+ *
149
154
  * @example
150
155
  * ```ts
151
156
  * // Start the handshake; do NOT await – connect() resolves only after pairing
@@ -307,6 +307,11 @@ class WaAuthClient {
307
307
  * call this. The user then enters the returned code in WhatsApp on their
308
308
  * phone under *Linked Devices → Link with phone number instead*.
309
309
  *
310
+ * The server binds the code to the connection that requested it. If the
311
+ * socket drops and reconnects first, the client registers the same code on
312
+ * the new connection; if that fails, `auth_pairing_required` fires with
313
+ * `forceManual: true` and a new code must be requested.
314
+ *
310
315
  * @example
311
316
  * ```ts
312
317
  * // Start the handshake; do NOT await – connect() resolves only after pairing
@@ -31,6 +31,7 @@ interface WaPairingFlowOptions {
31
31
  export declare class WaPairingFlow {
32
32
  private readonly opts;
33
33
  private pairingSession;
34
+ private rearmGeneration;
34
35
  private pairSuccessInFlight;
35
36
  constructor(options: WaPairingFlowOptions);
36
37
  hasPairingSession(): boolean;
@@ -48,6 +49,16 @@ export declare class WaPairingFlow {
48
49
  /** Mirrors the `not-authorized` IQ error WhatsApp Web returns on a failed pair-success. */
49
50
  private sendPairSuccessValidationError;
50
51
  private handlePrimaryHello;
52
+ private sendCompanionHello;
53
+ /**
54
+ * The server delivers `primary_hello` only to the connection that sent the
55
+ * `companion_hello`, so a code requested on a dropped connection is dead.
56
+ * Registers the same code again, without a push, like WA Web's code refresh.
57
+ * The ADV secret is not rotated: the QR just emitted for this connection carries it.
58
+ * Only the latest pair-device may settle the session, since an older attempt can
59
+ * outlive its dropped connection until the IQ times out.
60
+ */
61
+ private rearmPairingCode;
51
62
  private rotateAdvSecret;
52
63
  private requireCredentials;
53
64
  }
@@ -19,6 +19,7 @@ const PAIR_SUCCESS_DEVICE_IDENTITY_MIN_BYTES = 1;
19
19
  const PAIR_SUCCESS_DEVICE_IDENTITY_MAX_BYTES = 500;
20
20
  class WaPairingFlow {
21
21
  constructor(options) {
22
+ this.rearmGeneration = 0;
22
23
  this.pairSuccessInFlight = false;
23
24
  this.opts = options;
24
25
  this.pairingSession = null;
@@ -38,49 +39,17 @@ class WaPairingFlow {
38
39
  const normalizedCustomCode = customCode !== undefined ? (0, pairing_code_crypto_1.normalizeCustomPairingCode)(customCode) : undefined;
39
40
  const credentials = this.requireCredentials();
40
41
  const phoneJid = (0, jid_1.parsePhoneJid)(phoneNumber);
41
- const createdAtSeconds = Math.floor(Date.now() / 1000);
42
- const [companionHello, refreshedCredentials] = await Promise.all([
43
- (0, pairing_code_crypto_1.createCompanionHello)({ customCode: normalizedCustomCode }),
42
+ const [session] = await Promise.all([
43
+ this.sendCompanionHello(phoneJid, shouldShowPushNotification, normalizedCustomCode),
44
44
  this.rotateAdvSecret(credentials)
45
45
  ]);
46
- const browserDisplayName = (0, browser_1.getWaBrowserDisplayName)(this.opts.device.browser);
47
- const response = await this.opts.socket.query((0, pairing_1.buildCompanionHelloRequestNode)({
48
- phoneJid,
49
- shouldShowPushNotification,
50
- wrappedCompanionEphemeralPub: companionHello.wrappedCompanionEphemeralPub,
51
- companionServerAuthKeyPub: refreshedCredentials.noiseKeyPair.pubKey,
52
- companionPlatformId: this.opts.device.platform,
53
- companionPlatformDisplay: `${browserDisplayName} (${this.opts.device.osDisplayName})`
54
- }), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
55
- this.opts.logger.debug('pairing code request response received', {
56
- responseTag: response.tag,
57
- responseType: response.attrs.type
58
- });
59
- (0, query_1.assertIqResult)(response, 'companion hello');
60
- const linkCodeNode = (0, helpers_1.findNodeChild)(response, constants_1.WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
61
- if (!linkCodeNode) {
62
- throw new Error('companion hello response missing link_code_companion_reg');
63
- }
64
- const refNode = (0, helpers_1.findNodeChild)(linkCodeNode, constants_1.WA_NODE_TAGS.LINK_CODE_PAIRING_REF);
65
- if (!refNode) {
66
- throw new Error('companion hello response missing link_code_pairing_ref');
67
- }
68
- const ref = (0, helpers_1.decodeNodeContentUtf8OrBytes)(refNode.content, 'link_code_pairing_ref');
69
- this.pairingSession = {
70
- pairingCode: companionHello.pairingCode,
71
- phoneJid,
72
- ref,
73
- createdAtSeconds,
74
- companionEphemeralKeyPair: companionHello.companionEphemeralKeyPair,
75
- attempts: 0,
76
- finished: false
77
- };
78
- this.opts.callbacks.emitPairingCode(companionHello.pairingCode);
46
+ this.pairingSession = session;
47
+ this.opts.callbacks.emitPairingCode(session.pairingCode);
79
48
  this.opts.logger.debug('pairing code emitted', {
80
49
  phoneJid,
81
- createdAtSeconds: this.pairingSession.createdAtSeconds
50
+ createdAtSeconds: session.createdAtSeconds
82
51
  });
83
- return companionHello.pairingCode;
52
+ return session.pairingCode;
84
53
  }
85
54
  async fetchPairingCountryCodeIso() {
86
55
  this.opts.logger.trace('fetching pairing country code ISO');
@@ -170,6 +139,15 @@ class WaPairingFlow {
170
139
  await this.opts.socket.sendNode((0, global_1.buildIqResultNode)(iqNode));
171
140
  this.opts.qrFlow.setRefs(refs);
172
141
  this.opts.logger.debug('pair-device refs updated', { refsCount: refs.length });
142
+ // The server sends pair-device once per connection: a pending code belongs to an older one.
143
+ if (this.pairingSession) {
144
+ const generation = ++this.rearmGeneration;
145
+ void this.rearmPairingCode(this.pairingSession, generation).catch((error) => {
146
+ this.opts.logger.warn('pairing code re-registration failed unexpectedly', {
147
+ message: (0, primitives_1.toError)(error).message
148
+ });
149
+ });
150
+ }
173
151
  }
174
152
  async handlePairSuccess(iqNode, pairSuccessNode) {
175
153
  if (this.pairSuccessInFlight) {
@@ -231,7 +209,13 @@ class WaPairingFlow {
231
209
  await this.sendPairSuccessValidationError(iqNode);
232
210
  throw new Error('pair-success account signature validation failed');
233
211
  }
234
- const { signedIdentity, keyIndex, responseIdentityBytes } = built;
212
+ const { signedIdentity, keyIndex, responseIdentityBytes, advDeviceIdentity } = built;
213
+ this.opts.logger.debug('pair-success adv identity', {
214
+ envelopeAccountType: wrappedIdentity.accountType ?? null,
215
+ accountType: advDeviceIdentity.accountType ?? null,
216
+ deviceType: advDeviceIdentity.deviceType ?? null,
217
+ keyIndex: advDeviceIdentity.keyIndex ?? null
218
+ });
235
219
  const nextCredentials = {
236
220
  ...credentials,
237
221
  signedIdentity,
@@ -301,7 +285,8 @@ class WaPairingFlow {
301
285
  return {
302
286
  signedIdentity,
303
287
  keyIndex: advDeviceIdentity.keyIndex ?? 0,
304
- responseIdentityBytes
288
+ responseIdentityBytes,
289
+ advDeviceIdentity
305
290
  };
306
291
  }
307
292
  /** Mirrors the `not-authorized` IQ error WhatsApp Web returns on a failed pair-success. */
@@ -377,6 +362,78 @@ class WaPairingFlow {
377
362
  pairingSession.finished = true;
378
363
  this.opts.logger.debug('primary_hello completed with companion_finish success');
379
364
  }
365
+ async sendCompanionHello(phoneJid, shouldShowPushNotification, customCode) {
366
+ const credentials = this.requireCredentials();
367
+ const createdAtSeconds = Math.floor(Date.now() / 1000);
368
+ const companionHello = await (0, pairing_code_crypto_1.createCompanionHello)({ customCode });
369
+ const browserDisplayName = (0, browser_1.getWaBrowserDisplayName)(this.opts.device.browser);
370
+ const response = await this.opts.socket.query((0, pairing_1.buildCompanionHelloRequestNode)({
371
+ phoneJid,
372
+ shouldShowPushNotification,
373
+ wrappedCompanionEphemeralPub: companionHello.wrappedCompanionEphemeralPub,
374
+ companionServerAuthKeyPub: credentials.noiseKeyPair.pubKey,
375
+ companionPlatformId: this.opts.device.platform,
376
+ companionPlatformDisplay: `${browserDisplayName} (${this.opts.device.osDisplayName})`
377
+ }), constants_1.WA_DEFAULTS.IQ_TIMEOUT_MS);
378
+ this.opts.logger.debug('pairing code request response received', {
379
+ responseTag: response.tag,
380
+ responseType: response.attrs.type
381
+ });
382
+ (0, query_1.assertIqResult)(response, 'companion hello');
383
+ const linkCodeNode = (0, helpers_1.findNodeChild)(response, constants_1.WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
384
+ if (!linkCodeNode) {
385
+ throw new Error('companion hello response missing link_code_companion_reg');
386
+ }
387
+ const refNode = (0, helpers_1.findNodeChild)(linkCodeNode, constants_1.WA_NODE_TAGS.LINK_CODE_PAIRING_REF);
388
+ if (!refNode) {
389
+ throw new Error('companion hello response missing link_code_pairing_ref');
390
+ }
391
+ return {
392
+ pairingCode: companionHello.pairingCode,
393
+ phoneJid,
394
+ ref: (0, helpers_1.decodeNodeContentUtf8OrBytes)(refNode.content, 'link_code_pairing_ref'),
395
+ createdAtSeconds,
396
+ companionEphemeralKeyPair: companionHello.companionEphemeralKeyPair,
397
+ attempts: 0,
398
+ finished: false
399
+ };
400
+ }
401
+ /**
402
+ * The server delivers `primary_hello` only to the connection that sent the
403
+ * `companion_hello`, so a code requested on a dropped connection is dead.
404
+ * Registers the same code again, without a push, like WA Web's code refresh.
405
+ * The ADV secret is not rotated: the QR just emitted for this connection carries it.
406
+ * Only the latest pair-device may settle the session, since an older attempt can
407
+ * outlive its dropped connection until the IQ times out.
408
+ */
409
+ async rearmPairingCode(previous, generation) {
410
+ const isCurrent = () => this.pairingSession === previous && this.rearmGeneration === generation;
411
+ try {
412
+ const session = await this.sendCompanionHello(previous.phoneJid, false, previous.pairingCode);
413
+ if (!isCurrent()) {
414
+ this.opts.logger.debug('pairing code re-registration superseded');
415
+ return;
416
+ }
417
+ this.pairingSession = session;
418
+ this.opts.logger.info('pairing code re-registered on the new connection', {
419
+ phoneJid: session.phoneJid
420
+ });
421
+ }
422
+ catch (error) {
423
+ const message = (0, primitives_1.toError)(error).message;
424
+ if (!isCurrent()) {
425
+ this.opts.logger.debug('superseded pairing code re-registration failed', {
426
+ message
427
+ });
428
+ return;
429
+ }
430
+ this.pairingSession = null;
431
+ this.opts.logger.warn('failed to re-register pairing code on the new connection', {
432
+ message
433
+ });
434
+ this.opts.callbacks.emitPairingRefresh(true);
435
+ }
436
+ }
380
437
  async rotateAdvSecret(credentials) {
381
438
  const nextCredentials = {
382
439
  ...credentials,
@@ -275,6 +275,8 @@ class WaClientImpl extends node_events_1.EventEmitter {
275
275
  mediaTransfer: this.mediaTransfer,
276
276
  chatMetadataStore: this.stores.chatMetadata,
277
277
  writeBehind: this.writeBehind,
278
+ chunkId: event.key.id,
279
+ sink: this.options.history?.sink,
278
280
  emitEvent: this.emit.bind(this),
279
281
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
280
282
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
@@ -211,7 +211,7 @@ function buildWaClientDependencies(input) {
211
211
  let connectionManager = null;
212
212
  let passiveTasks = null;
213
213
  let mediaConnCacheFallback = null;
214
- let scheduleReconnectAfterPairing = () => undefined;
214
+ let awaitServerRestartAfterPairing = () => undefined;
215
215
  const serverClock = (0, clock_1.createServerClock)(() => connectionManager?.getClockSkewMs() ?? null);
216
216
  const nodeTransport = new WaNodeTransport_1.WaNodeTransport(logger);
217
217
  const nodeOrchestrator = new WaNodeOrchestrator_1.WaNodeOrchestrator({
@@ -338,7 +338,7 @@ function buildWaClientDependencies(input) {
338
338
  deviceOsDisplayName: options.deviceOsDisplayName,
339
339
  deviceOsVersion: options.deviceOsVersion,
340
340
  devicePlatform: options.devicePlatform,
341
- requireFullSync: options.requireFullSync,
341
+ requireFullSync: options.history?.requireFullSync ?? options.requireFullSync,
342
342
  version: options.version,
343
343
  dangerous: options.dangerous,
344
344
  mobileTransport: options.mobileTransport
@@ -358,7 +358,7 @@ function buildWaClientDependencies(input) {
358
358
  onPairingRefresh: (forceManual) => runtime.emitEvent('auth_pairing_required', { forceManual }),
359
359
  onPaired: (credentials) => {
360
360
  runtime.emitEvent('auth_paired', { credentials });
361
- scheduleReconnectAfterPairing();
361
+ awaitServerRestartAfterPairing();
362
362
  },
363
363
  onError: (error) => runtime.handleError(error),
364
364
  onPasskeyRequired: (hasSigner) => runtime.emitEvent('auth_passkey_required', { hasSigner }),
@@ -671,7 +671,7 @@ function buildWaClientDependencies(input) {
671
671
  if (mediaConnCacheFallback !== null) {
672
672
  connectionManager.setMediaConnCache(mediaConnCacheFallback);
673
673
  }
674
- scheduleReconnectAfterPairing = () => connectionManager?.scheduleReconnectAfterPairing();
674
+ awaitServerRestartAfterPairing = () => connectionManager?.awaitServerRestartAfterPairing();
675
675
  const disconnectWithClientSideEffects = async (reason, isLogout, code) => {
676
676
  abPropsCoordinator.reset();
677
677
  offlineResume.reset();
@@ -712,7 +712,8 @@ function buildWaClientDependencies(input) {
712
712
  },
713
713
  disconnect: disconnectWithClientSideEffects,
714
714
  clearStoredCredentials: clearStoredCredentialsWithClientSideEffects,
715
- connect: connectWithClientSideEffects
715
+ connect: connectWithClientSideEffects,
716
+ handlePairingRestart: () => connectionManager?.handleServerRestartRequest() ?? false
716
717
  });
717
718
  const incomingMessageAckOptions = {
718
719
  logger,
@@ -33,14 +33,24 @@ export declare class WaConnectionManager {
33
33
  private mediaConnCache;
34
34
  private connectPromise;
35
35
  private pairingReconnectPromise;
36
- private pairingReconnectTimeout;
36
+ private pairRestartTimer;
37
37
  private frameHandler;
38
38
  private pendingComms;
39
39
  private lifecycleGeneration;
40
40
  private lifecycleQueue;
41
41
  constructor(options: WaConnectionManagerOptions);
42
42
  connect(frameHandler: (frame: Uint8Array) => Promise<void>): Promise<void>;
43
- scheduleReconnectAfterPairing(): void;
43
+ /**
44
+ * Starts the post-pairing restart window: the server ends the unregistered connection
45
+ * itself (`stream:error` 515, as WA Web waits for), so the socket stays open until that
46
+ * signal, a server close, or the fallback timer, whichever comes first.
47
+ */
48
+ awaitServerRestartAfterPairing(): void;
49
+ /**
50
+ * Claims a `stream:error` 515 for the post-pairing restart. Returns `true` while the
51
+ * window is open or its restart is running, so the generic force-login path must skip it.
52
+ */
53
+ handleServerRestartRequest(): boolean;
44
54
  disconnect(): Promise<void>;
45
55
  clearStoredCredentials(): Promise<void>;
46
56
  isConnected(): boolean;
@@ -51,6 +61,8 @@ export declare class WaConnectionManager {
51
61
  updateClockSkewFromSuccess(serverUnixSeconds: number): void;
52
62
  setClockSkewMs(clockSkewMs: number, source: string): void;
53
63
  private connectInternal;
64
+ private startPairRestart;
65
+ private clearPairRestartTimer;
54
66
  private reconnectAsRegisteredAfterPairing;
55
67
  private reconnectAsRegisteredAfterPairingInternal;
56
68
  private disconnectInternal;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaConnectionManager = void 0;
4
+ const constants_1 = require("../../protocol/constants");
4
5
  const WaComms_1 = require("../../transport/WaComms");
5
6
  const primitives_1 = require("../../util/primitives");
6
7
  class WaConnectionManager {
@@ -19,7 +20,7 @@ class WaConnectionManager {
19
20
  this.mediaConnCache = null;
20
21
  this.connectPromise = null;
21
22
  this.pairingReconnectPromise = null;
22
- this.pairingReconnectTimeout = null;
23
+ this.pairRestartTimer = null;
23
24
  this.frameHandler = null;
24
25
  this.pendingComms = null;
25
26
  this.lifecycleGeneration = 0;
@@ -43,26 +44,37 @@ class WaConnectionManager {
43
44
  this.connectPromise = promise;
44
45
  return this.connectPromise;
45
46
  }
46
- scheduleReconnectAfterPairing() {
47
- this.logger.debug('wa client scheduling reconnect after pairing');
48
- if (this.pairingReconnectTimeout) {
49
- clearTimeout(this.pairingReconnectTimeout);
47
+ /**
48
+ * Starts the post-pairing restart window: the server ends the unregistered connection
49
+ * itself (`stream:error` 515, as WA Web waits for), so the socket stays open until that
50
+ * signal, a server close, or the fallback timer, whichever comes first.
51
+ */
52
+ awaitServerRestartAfterPairing() {
53
+ const comms = this.comms;
54
+ if (!comms) {
55
+ this.logger.trace('pairing restart wait skipped: no active comms');
56
+ return;
50
57
  }
51
- this.pairingReconnectTimeout = setTimeout(() => {
52
- this.pairingReconnectTimeout = null;
53
- void this.reconnectAsRegisteredAfterPairing().catch((error) => {
54
- if (this.isLifecycleSupersededError(error)) {
55
- this.logger.trace('pairing reconnect canceled by newer lifecycle');
56
- return;
57
- }
58
- this.logger.warn('failed to reconnect after pairing', {
59
- meJid: this.authClient.getCurrentCredentials()?.meJid,
60
- message: (0, primitives_1.toError)(error).message
61
- });
62
- });
63
- }, 0);
58
+ this.clearPairRestartTimer();
59
+ this.logger.debug('wa client waiting for server restart after pairing', {
60
+ fallbackMs: constants_1.WA_DEFAULTS.PAIRING_RESTART_WAIT_MS
61
+ });
62
+ this.pairRestartTimer = setTimeout(() => this.startPairRestart('fallback_timeout'), constants_1.WA_DEFAULTS.PAIRING_RESTART_WAIT_MS);
63
+ comms.holdForServerRestart(() => this.startPairRestart('socket_closed'));
64
+ }
65
+ /**
66
+ * Claims a `stream:error` 515 for the post-pairing restart. Returns `true` while the
67
+ * window is open or its restart is running, so the generic force-login path must skip it.
68
+ */
69
+ handleServerRestartRequest() {
70
+ if (this.pairRestartTimer) {
71
+ this.startPairRestart('stream_error_515');
72
+ return true;
73
+ }
74
+ return this.pairingReconnectPromise !== null;
64
75
  }
65
76
  async disconnect() {
77
+ this.clearPairRestartTimer();
66
78
  const lifecycleGeneration = this.nextLifecycleGeneration();
67
79
  this.invalidatePendingComms('failed to stop pending comms during disconnect');
68
80
  await this.runLifecycleOperation(async () => {
@@ -140,6 +152,30 @@ class WaConnectionManager {
140
152
  this.assertLifecycleCurrent(lifecycleGeneration, 'connect');
141
153
  this.logger.info('wa client connected');
142
154
  }
155
+ startPairRestart(trigger) {
156
+ if (!this.pairRestartTimer) {
157
+ return;
158
+ }
159
+ this.clearPairRestartTimer();
160
+ this.logger.debug('wa client restarting after pairing', { trigger });
161
+ void this.reconnectAsRegisteredAfterPairing().catch((error) => {
162
+ if (this.isLifecycleSupersededError(error)) {
163
+ this.logger.trace('pairing reconnect canceled by newer lifecycle');
164
+ return;
165
+ }
166
+ this.logger.warn('failed to reconnect after pairing', {
167
+ meJid: this.authClient.getCurrentCredentials()?.meJid,
168
+ message: (0, primitives_1.toError)(error).message
169
+ });
170
+ });
171
+ }
172
+ clearPairRestartTimer() {
173
+ if (!this.pairRestartTimer) {
174
+ return;
175
+ }
176
+ clearTimeout(this.pairRestartTimer);
177
+ this.pairRestartTimer = null;
178
+ }
143
179
  async reconnectAsRegisteredAfterPairing() {
144
180
  if (this.pairingReconnectPromise) {
145
181
  this.logger.trace('pairing reconnect already in-flight');
@@ -188,10 +224,7 @@ class WaConnectionManager {
188
224
  return;
189
225
  }
190
226
  this.logger.debug('wa client disconnect start');
191
- if (this.pairingReconnectTimeout) {
192
- clearTimeout(this.pairingReconnectTimeout);
193
- this.pairingReconnectTimeout = null;
194
- }
227
+ this.clearPairRestartTimer();
195
228
  this.keepAlive.stop();
196
229
  this.nodeOrchestrator.clearPending(new Error('client disconnected'));
197
230
  this.clockSkewMs = null;
@@ -110,6 +110,13 @@ export interface WaRequestHistorySyncInput {
110
110
  */
111
111
  readonly count?: number;
112
112
  }
113
+ /** Options for {@link WaMessageCoordinator.syncSignalSession}. */
114
+ export interface WaSyncSignalSessionOptions {
115
+ /** Drops the session, keeping the identity, and fetches a fresh one. */
116
+ readonly force?: boolean;
117
+ /** The identity changed: drops the session and fetches with `reason="identity"`. */
118
+ readonly reasonIdentity?: boolean;
119
+ }
113
120
  export interface WaReachoutTimelock {
114
121
  readonly isActive: boolean;
115
122
  readonly enforcementType: string | null;
@@ -239,11 +246,16 @@ export declare class WaMessageCoordinator {
239
246
  */
240
247
  getNewChatMessageCapping(type?: WaMessageCappingType): Promise<WaMessageCappingInfo>;
241
248
  /**
242
- * Force-refreshes the Signal session(s) for `jid`. Set `reasonIdentity` to
243
- * `true` when the trigger was an identity change – this also queues a
244
- * trusted-contact-token reissue.
249
+ * Syncs the Signal session of the device `jid` resolves to (a user JID is
250
+ * device 0). With no options it only ensures a session exists. `force`
251
+ * rebuilds it, keeping the identity. `reasonIdentity` rebuilds it as an
252
+ * identity change and reissues the trusted-contact token; it wins over
253
+ * `force`. Throws for group JIDs and when the key-bundle fetch fails, in
254
+ * which case the old session is already gone.
245
255
  */
246
- syncSignalSession(jid: string, reasonIdentity?: boolean): Promise<void>;
256
+ syncSignalSession(jid: string, options?: WaSyncSignalSessionOptions): Promise<void>;
257
+ /** @deprecated Pass `{ reasonIdentity: true }` instead. */
258
+ syncSignalSession(jid: string, reasonIdentity: boolean): Promise<void>;
247
259
  /**
248
260
  * Sends a message (any {@link WaSendMessageContent} kind – text, media,
249
261
  * poll, reaction, edit, revoke, etc.) to `to` and returns the publish
@@ -292,14 +292,10 @@ class WaMessageCoordinator {
292
292
  });
293
293
  return parseMessageCappingMexResponse(data);
294
294
  }
295
- /**
296
- * Force-refreshes the Signal session(s) for `jid`. Set `reasonIdentity` to
297
- * `true` when the trigger was an identity change – this also queues a
298
- * trusted-contact-token reissue.
299
- */
300
- async syncSignalSession(jid, reasonIdentity = false) {
301
- await this.messageDispatch.syncSignalSession(jid, reasonIdentity);
302
- if (reasonIdentity) {
295
+ async syncSignalSession(jid, options = {}) {
296
+ const normalized = typeof options === 'boolean' ? { reasonIdentity: options } : options;
297
+ await this.messageDispatch.syncSignalSession(jid, normalized);
298
+ if (normalized.reasonIdentity) {
303
299
  this.trustedContactToken.reissueOnIdentityChange(jid).catch((err) => this.logger.warn('tc token reissue on identity change failed', {
304
300
  jid,
305
301
  message: (0, primitives_1.toError)(err).message
@@ -1,5 +1,6 @@
1
1
  import type { WaAppStateSyncKey } from '../../appstate/types';
2
2
  import type { WaAuthCredentials } from '../../auth/types';
3
+ import type { WaSyncSignalSessionOptions } from '../coordinators/WaMessageCoordinator';
3
4
  import type { DeviceFanoutResolver } from '../messaging/fanout';
4
5
  import type { GroupMetadataCache } from '../messaging/group-metadata';
5
6
  import type { AppStateSyncKeyProtocol } from '../messaging/key-protocol';
@@ -156,7 +157,7 @@ export declare class WaMessageDispatchCoordinator {
156
157
  * LID destination.
157
158
  */
158
159
  private resolvePeerRecipientPn;
159
- syncSignalSession(jid: string, reasonIdentity?: boolean): Promise<void>;
160
+ syncSignalSession(jid: string, { force, reasonIdentity }?: WaSyncSignalSessionOptions): Promise<void>;
160
161
  sendReceipt(input: WaSendReceiptInput): Promise<void>;
161
162
  publishProtocolMessageToDevice(deviceJid: string, protocolMessage: Proto.Message.IProtocolMessage, options?: {
162
163
  readonly id?: string;
@@ -483,11 +483,14 @@ class WaMessageDispatchCoordinator {
483
483
  const pair = await this.deps.signalDeviceSync.resolveUserJidPair(directRecipientJid);
484
484
  return pair.pnJid ?? undefined;
485
485
  }
486
- async syncSignalSession(jid, reasonIdentity = false) {
486
+ async syncSignalSession(jid, { force = false, reasonIdentity = false } = {}) {
487
487
  const address = (0, jid_1.parseSignalAddressFromJid)(jid);
488
488
  if (address.server === constants_1.WA_DEFAULTS.GROUP_SERVER) {
489
489
  throw new Error('syncSignalSession supports only direct chats');
490
490
  }
491
+ if (force || reasonIdentity) {
492
+ await this.deps.sessionStore.deleteSession(address);
493
+ }
491
494
  await this.deps.sessionResolver.ensureSession(address, jid, undefined, reasonIdentity);
492
495
  }
493
496
  async sendReceipt(input) {
@@ -158,7 +158,11 @@ export interface WaProfileCoordinator {
158
158
  * @throws when `pin` is not exactly 4 digits, before any round-trip.
159
159
  */
160
160
  readonly setUsernameKey: (pin: string) => Promise<boolean>;
161
- /** Resolves LIDs for a list of phone numbers (handles normalization). */
161
+ /**
162
+ * Resolves LIDs for a list of phone numbers (handles normalization). Check
163
+ * `status`: `'out'` means no WhatsApp account, `'unanswered'` or `'error'` mean
164
+ * the server gave no verdict (see `error` for the reason).
165
+ */
162
166
  readonly getLidsByPhoneNumbers: (phoneNumbers: readonly string[]) => Promise<readonly SignalLidSyncResult[]>;
163
167
  }
164
168
  interface WaProfileCoordinatorOptions {
@@ -10,6 +10,8 @@ interface WaStreamControlCoordinatorOptions {
10
10
  readonly disconnect: (reason: WaDisconnectReason, isLogout: boolean, code: WaConnectionCode | null) => Promise<void>;
11
11
  readonly clearStoredCredentials: () => Promise<void>;
12
12
  readonly connect: (reason: WaConnectionOpenReason) => Promise<void>;
13
+ /** Returns `true` when a pairing restart already owns the force-login `stream:error`. */
14
+ readonly handlePairingRestart: () => boolean;
13
15
  }
14
16
  export interface WaStreamControlHandler {
15
17
  readonly handleStreamControlResult: (result: WaStreamControlNodeResult) => Promise<void>;
@@ -4,7 +4,7 @@ exports.createStreamControlHandler = createStreamControlHandler;
4
4
  const constants_1 = require("../../protocol/constants");
5
5
  const primitives_1 = require("../../util/primitives");
6
6
  function createStreamControlHandler(options) {
7
- const { logger, getComms, clearPendingQueries, clearMediaConnCache, disconnect, clearStoredCredentials, connect } = options;
7
+ const { logger, getComms, clearPendingQueries, clearMediaConnCache, disconnect, clearStoredCredentials, connect, handlePairingRestart } = options;
8
8
  let lifecyclePromise = null;
9
9
  const runStreamControlLifecycle = (reason, action) => {
10
10
  if (lifecyclePromise) {
@@ -88,6 +88,11 @@ function createStreamControlHandler(options) {
88
88
  logger.info('received xmlstreamend stanza');
89
89
  return;
90
90
  case 'stream_error_code':
91
+ if (result.code === constants_1.WA_STREAM_SIGNALING.FORCE_LOGIN_CODE &&
92
+ handlePairingRestart()) {
93
+ logger.info('stream:error force login claimed by the pairing restart');
94
+ return;
95
+ }
91
96
  logger.warn('received stream:error with code', { code: result.code });
92
97
  if (result.code >= 500 && result.code < 600) {
93
98
  if (result.code === constants_1.WA_STREAM_SIGNALING.FORCE_LOGIN_CODE) {
@@ -228,6 +228,27 @@ async function buildMediaMessageContent(options, content, ctx) {
228
228
  if (typeof content === 'string') {
229
229
  return { message: { conversation: content } };
230
230
  }
231
+ if ((0, content_1.isSendTypedMessage)(content)) {
232
+ return withMessageContextInfo(await buildTypedMessageContent(options, content, ctx), content.messageContextInfo);
233
+ }
234
+ if (!content || typeof content !== 'object') {
235
+ throw new Error('invalid message content');
236
+ }
237
+ return { message: content };
238
+ }
239
+ /** Adds the caller's `messageContextInfo` under the built one: fields the builder set win. */
240
+ function withMessageContextInfo(built, messageContextInfo) {
241
+ if (!messageContextInfo)
242
+ return built;
243
+ return {
244
+ ...built,
245
+ message: {
246
+ ...built.message,
247
+ messageContextInfo: { ...messageContextInfo, ...built.message.messageContextInfo }
248
+ }
249
+ };
250
+ }
251
+ async function buildTypedMessageContent(options, content, ctx) {
231
252
  if ((0, content_1.isSendTextMessage)(content)) {
232
253
  if (options.linkPreviewResolver) {
233
254
  try {
@@ -267,13 +288,7 @@ async function buildMediaMessageContent(options, content, ctx) {
267
288
  if ((0, content_1.isSendEventResponseMessage)(content)) {
268
289
  return { message: await buildEventResponseMessage(content, options.serverClock, ctx) };
269
290
  }
270
- if ((0, content_1.isSendMediaMessage)(content)) {
271
- return buildMediaMessage(options, content);
272
- }
273
- if (!content || typeof content !== 'object') {
274
- throw new Error('invalid message content');
275
- }
276
- return { message: content };
291
+ return buildMediaMessage(options, content);
277
292
  }
278
293
  async function getMediaConn(options, forceRefresh = false) {
279
294
  const cached = options.getMediaConnCache();
@@ -394,7 +409,8 @@ async function buildMediaMessage(options, content) {
394
409
  if (key !== 'type' &&
395
410
  key !== 'media' &&
396
411
  key !== 'fileLength' &&
397
- key !== 'mimetype') {
412
+ key !== 'mimetype' &&
413
+ key !== 'messageContextInfo') {
398
414
  result[key] = c[key];
399
415
  }
400
416
  }