zapo-js 1.9.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 (100) hide show
  1. package/README.md +14 -13
  2. package/dist/auth/WaAuthClient.d.ts +5 -0
  3. package/dist/auth/WaAuthClient.js +5 -0
  4. package/dist/auth/pairing/WaPairingFlow.d.ts +11 -0
  5. package/dist/auth/pairing/WaPairingFlow.js +97 -40
  6. package/dist/client/WaClient.d.ts +4 -0
  7. package/dist/client/WaClient.js +7 -1
  8. package/dist/client/WaClientFactory.js +6 -5
  9. package/dist/client/connection/WaConnectionManager.d.ts +14 -2
  10. package/dist/client/connection/WaConnectionManager.js +55 -22
  11. package/dist/client/coordinators/WaMessageCoordinator.d.ts +27 -8
  12. package/dist/client/coordinators/WaMessageCoordinator.js +4 -8
  13. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +13 -1
  14. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +19 -6
  15. package/dist/client/coordinators/WaProfileCoordinator.d.ts +5 -1
  16. package/dist/client/coordinators/WaStreamControlCoordinator.d.ts +2 -0
  17. package/dist/client/coordinators/WaStreamControlCoordinator.js +6 -1
  18. package/dist/client/messaging/ignore-key.d.ts +2 -1
  19. package/dist/client/messaging/ignore-key.js +24 -3
  20. package/dist/client/messaging/messages.js +24 -8
  21. package/dist/client/persistence/history-sync.d.ts +5 -1
  22. package/dist/client/persistence/history-sync.js +172 -38
  23. package/dist/client/types.d.ts +65 -10
  24. package/dist/esm/auth/WaAuthClient.js +5 -0
  25. package/dist/esm/auth/pairing/WaPairingFlow.js +97 -40
  26. package/dist/esm/client/WaClient.js +7 -1
  27. package/dist/esm/client/WaClientFactory.js +6 -5
  28. package/dist/esm/client/connection/WaConnectionManager.js +55 -22
  29. package/dist/esm/client/coordinators/WaMessageCoordinator.js +4 -8
  30. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +18 -6
  31. package/dist/esm/client/coordinators/WaStreamControlCoordinator.js +6 -1
  32. package/dist/esm/client/messaging/ignore-key.js +25 -4
  33. package/dist/esm/client/messaging/messages.js +25 -9
  34. package/dist/esm/client/persistence/history-sync.js +172 -38
  35. package/dist/esm/infra/log/types.js +2 -1
  36. package/dist/esm/message/context-info.js +2 -0
  37. package/dist/esm/message/encode/content.js +16 -0
  38. package/dist/esm/message/primitives/incoming.js +12 -5
  39. package/dist/esm/protocol/defaults.js +6 -0
  40. package/dist/esm/protocol/message.js +2 -1
  41. package/dist/esm/signal/api/SignalDeviceSyncApi.js +64 -32
  42. package/dist/esm/store/cache/identity.cache.js +30 -5
  43. package/dist/esm/store/cache/privacy-token.cache.js +30 -5
  44. package/dist/esm/store/cache/sender-key.cache.js +38 -7
  45. package/dist/esm/store/cache/session.cache.js +30 -4
  46. package/dist/esm/store/createStore.js +39 -4
  47. package/dist/esm/store/memory/identity.store.js +28 -4
  48. package/dist/esm/store/memory/privacy-token.store.js +24 -7
  49. package/dist/esm/store/memory/sender-key.store.js +36 -7
  50. package/dist/esm/store/memory/session.store.js +41 -7
  51. package/dist/esm/transport/WaComms.js +94 -16
  52. package/dist/esm/transport/WaWebSocket.js +79 -32
  53. package/dist/esm/transport/node/builders/message.js +1 -1
  54. package/dist/esm/transport/noise/WaNoiseSession.js +6 -5
  55. package/dist/esm/util/collections.js +94 -0
  56. package/dist/esm/util/index.js +2 -2
  57. package/dist/index.d.ts +3 -3
  58. package/dist/infra/log/types.d.ts +1 -0
  59. package/dist/infra/log/types.js +2 -1
  60. package/dist/message/context-info.js +2 -0
  61. package/dist/message/encode/content.d.ts +3 -2
  62. package/dist/message/encode/content.js +17 -0
  63. package/dist/message/primitives/incoming.js +12 -5
  64. package/dist/message/primitives/media-retry.d.ts +11 -0
  65. package/dist/message/types.d.ts +21 -12
  66. package/dist/protocol/defaults.d.ts +6 -0
  67. package/dist/protocol/defaults.js +6 -0
  68. package/dist/protocol/message.d.ts +1 -0
  69. package/dist/protocol/message.js +2 -1
  70. package/dist/signal/api/SignalDeviceSyncApi.d.ts +27 -5
  71. package/dist/signal/api/SignalDeviceSyncApi.js +64 -32
  72. package/dist/store/cache/identity.cache.d.ts +6 -4
  73. package/dist/store/cache/identity.cache.js +30 -5
  74. package/dist/store/cache/privacy-token.cache.d.ts +6 -4
  75. package/dist/store/cache/privacy-token.cache.js +30 -5
  76. package/dist/store/cache/sender-key.cache.d.ts +5 -3
  77. package/dist/store/cache/sender-key.cache.js +38 -7
  78. package/dist/store/cache/session.cache.d.ts +6 -3
  79. package/dist/store/cache/session.cache.js +30 -4
  80. package/dist/store/createStore.js +39 -4
  81. package/dist/store/memory/identity.store.d.ts +7 -1
  82. package/dist/store/memory/identity.store.js +28 -4
  83. package/dist/store/memory/privacy-token.store.d.ts +7 -1
  84. package/dist/store/memory/privacy-token.store.js +24 -7
  85. package/dist/store/memory/sender-key.store.d.ts +12 -2
  86. package/dist/store/memory/sender-key.store.js +36 -7
  87. package/dist/store/memory/session.store.d.ts +7 -1
  88. package/dist/store/memory/session.store.js +41 -7
  89. package/dist/store/types.d.ts +43 -4
  90. package/dist/transport/WaComms.d.ts +9 -1
  91. package/dist/transport/WaComms.js +94 -16
  92. package/dist/transport/WaWebSocket.d.ts +8 -2
  93. package/dist/transport/WaWebSocket.js +79 -32
  94. package/dist/transport/node/builders/message.js +1 -1
  95. package/dist/transport/noise/WaNoiseSession.js +6 -5
  96. package/dist/util/collections.d.ts +46 -0
  97. package/dist/util/collections.js +98 -0
  98. package/dist/util/index.d.ts +2 -2
  99. package/dist/util/index.js +3 -1
  100. package/package.json +1 -1
package/README.md CHANGED
@@ -127,19 +127,20 @@ The core lives at the repo root (`zapo-js`). Optional packages live in
127
127
  [`packages/`](packages/) and ship under the `@zapo-js/*` scope. Install
128
128
  only what you need.
129
129
 
130
- | Package | Peer dependency | Purpose |
131
- | ---------------------------------------------------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
132
- | [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
133
- | [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
134
- | [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
135
- | [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
136
- | [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
137
- | [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
138
- | [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
139
- | [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
140
- | [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
141
- | [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
142
- | [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
130
+ | Package | Peer dependency | Purpose |
131
+ | ---------------------------------------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
132
+ | [`@zapo-js/store-sqlite`](packages/store-sqlite) | `better-sqlite3` | SQLite persistent store (single-process bots, dev sessions, small-to-medium prod). |
133
+ | [`@zapo-js/store-redis`](packages/store-redis) | `ioredis` | Redis-backed store with native TTL eviction. |
134
+ | [`@zapo-js/store-mongo`](packages/store-mongo) | `mongodb` | MongoDB-backed store with TTL-index eviction. |
135
+ | [`@zapo-js/store-mysql`](packages/store-mysql) | `mysql2` | MySQL / MariaDB-backed store with background cleanup poller. |
136
+ | [`@zapo-js/store-postgres`](packages/store-postgres) | `pg` | PostgreSQL-backed store with background cleanup poller. |
137
+ | [`@zapo-js/media-utils`](packages/media-utils) | `sharp` + `file-type` + `ffmpeg` | `WaMediaProcessor`: thumbnails, waveforms, voice-note normalization. |
138
+ | [`@zapo-js/native`](packages/native) | (none) | Rust crypto accelerator for the messaging hot path (X25519 ECDH, XEdDSA sign / verify). Auto-detected once installed; ships as a WASM build. |
139
+ | [`@zapo-js/voip`](packages/voip) | `@roamhq/wrtc` + `libmlow-wasm-fork` | `client.voip` plugin for calls: MLow/WASM codec, SRTP, STUN, WebRTC/SCTP relay transport. |
140
+ | [`@zapo-js/voip-media`](packages/voip-media) | `libmlow-wasm-fork` (optional, to carry media) + `@roamhq/wrtc` (optional, Node host) | Call media plane with no Node or `zapo-js` dependency, so it runs in the browser: relay transport, STUN, SRTP, RTP/RTCP, MLow codec. |
141
+ | [`@zapo-js/wam`](packages/wam) | (none) | `client.wam` plugin emitting the client-side WAM telemetry batches WA Web sends, for wire parity and anti-fingerprinting. |
142
+ | [`@zapo-js/fake-server`](packages/fake-server) | (none) | In-process fake WhatsApp Web server for end-to-end testing. |
143
+ | [`@zapo-js/mcp-server`](packages/mcp-server) | `@modelcontextprotocol/sdk` | **Dev-only.** MCP server exposing multi-session `WaClient`s as dynamic tools for an LLM agent (Claude Code / Cursor / etc.). Not for production. |
143
144
 
144
145
  Each package's README has the install + config + integration notes.
145
146
 
@@ -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,
@@ -132,6 +132,10 @@ declare class WaClientImpl extends EventEmitter {
132
132
  * arrays are empty. The predicate receives a parsed
133
133
  * {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
134
134
  *
135
+ * `category='peer'` messages from our own account (app-state key share,
136
+ * history sync, PDO responses) are never dropped: they are protocol
137
+ * traffic between our own devices, not chat messages.
138
+ *
135
139
  * @example
136
140
  * ```ts
137
141
  * client.ignoreKey({ remoteJid: spammerJid })
@@ -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),
@@ -458,6 +460,10 @@ class WaClientImpl extends node_events_1.EventEmitter {
458
460
  * arrays are empty. The predicate receives a parsed
459
461
  * {@link WaIgnoreKeyContext} and returns `true` to drop the stanza.
460
462
  *
463
+ * `category='peer'` messages from our own account (app-state key share,
464
+ * history sync, PDO responses) are never dropped: they are protocol
465
+ * traffic between our own devices, not chat messages.
466
+ *
461
467
  * @example
462
468
  * ```ts
463
469
  * client.ignoreKey({ remoteJid: spammerJid })
@@ -469,7 +475,7 @@ class WaClientImpl extends node_events_1.EventEmitter {
469
475
  if (typeof input !== 'function') {
470
476
  (0, ignore_key_1.validateIgnoreKey)(input);
471
477
  }
472
- const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials()?.meJid);
478
+ const filter = (0, ignore_key_1.createIgnoreKeyFilter)(input, () => this.deps.authClient.getCurrentCredentials());
473
479
  return this.deps.incomingNode.registerIncomingStanzaFilter(filter);
474
480
  }
475
481
  /** Auth client: pairing, credentials, registration state. */
@@ -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
@@ -384,10 +396,17 @@ export declare class WaMessageCoordinator {
384
396
  * the sealed request and resolves once the server answers with a
385
397
  * `mediaretry` notification, usually within a couple of seconds.
386
398
  *
387
- * On `result: 'success'` only the `directPath` changes - the media key,
388
- * hashes, and length of the original message stay valid, so patch the path
389
- * into the message and download again. A second call for a message already
390
- * in flight joins the first request instead of sending another receipt.
399
+ * On `result: 'success'` the media key, hashes, and length of the original
400
+ * message normally stay valid, so patch the fresh `directPath` into the
401
+ * message and download again. A second call for a message already in
402
+ * flight joins the first request instead of sending another receipt.
403
+ *
404
+ * A `success` is not a guarantee that the original key still opens the
405
+ * blob. Some primaries re-encrypt the file on every re-upload, and the
406
+ * re-served bytes then fail `fileEncSha256` and make `downloadBytes()`
407
+ * throw a MAC mismatch. Nothing in the round-trip carries key material for
408
+ * the new ciphertext, so the message cannot be recovered through this API
409
+ * and requesting another reupload will not help.
391
410
  *
392
411
  * The other three `result` values are answers too, not thrown errors:
393
412
  * `not_found` means the sender no longer holds the file and nothing can
@@ -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';
@@ -7,6 +8,7 @@ import type { WaGroupEvent, WaOutgoingMessageEvent, WaSendMessageOptions, WaSign
7
8
  import type { Logger } from '../../infra/log/types';
8
9
  import { type WaSendContextInfo } from '../../message/context-info';
9
10
  import { type IcdcMeta } from '../../message/crypto/icdc';
11
+ import { type WaOutboundMessageAttrs } from '../../message/encode/content';
10
12
  import type { WaEncryptedMessageInput, WaMessageBuildResult, WaMessagePublishOptions, WaMessagePublishResult, WaSendMessageContent, WaSendReceiptInput } from '../../message/types';
11
13
  import type { WaMessageClient } from '../../message/WaMessageClient';
12
14
  import { type Proto } from '../../proto';
@@ -96,6 +98,16 @@ export interface WaGroupHistoryAudience {
96
98
  * while nobody received anything.
97
99
  */
98
100
  export declare function restrictGroupHistoryTargets(message: Proto.IMessage, participantUserJids: readonly string[], senderJid: string, onDropped?: (droppedJids: readonly string[], totalExpected: number) => void): readonly string[];
101
+ /**
102
+ * Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
103
+ * `<biz>` companion rides a type=text stanza with no mediatype, except on a
104
+ * group list: there the server nacks with 479 (SMAX_INVALID) unless
105
+ * type=media, mediatype=list and `<biz><list>` travel together.
106
+ */
107
+ export declare function resolveOutboundStanzaShape(attrs: Pick<WaOutboundMessageAttrs, 'buttonAddonKind' | 'typeAttr' | 'mediatype'>, isGroup: boolean): {
108
+ readonly type: string;
109
+ readonly mediatype: string | undefined;
110
+ };
99
111
  export declare class WaMessageDispatchCoordinator {
100
112
  private readonly deps;
101
113
  private readonly mobileMessageIdFormat;
@@ -145,7 +157,7 @@ export declare class WaMessageDispatchCoordinator {
145
157
  * LID destination.
146
158
  */
147
159
  private resolvePeerRecipientPn;
148
- syncSignalSession(jid: string, reasonIdentity?: boolean): Promise<void>;
160
+ syncSignalSession(jid: string, { force, reasonIdentity }?: WaSyncSignalSessionOptions): Promise<void>;
149
161
  sendReceipt(input: WaSendReceiptInput): Promise<void>;
150
162
  publishProtocolMessageToDevice(deviceJid: string, protocolMessage: Proto.Message.IProtocolMessage, options?: {
151
163
  readonly id?: string;