zapo-js 1.1.0 → 1.1.2

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 (62) hide show
  1. package/README.md +5 -1
  2. package/dist/appstate/sync/WaAppStateSyncClient.d.ts +11 -1
  3. package/dist/appstate/sync/WaAppStateSyncClient.js +36 -15
  4. package/dist/auth/credentials-flow.js +3 -1
  5. package/dist/auth/pairing/WaPairingFlow.js +2 -0
  6. package/dist/client/WaClient.js +26 -20
  7. package/dist/client/WaClientFactory.js +28 -6
  8. package/dist/client/connection/WaConnectionManager.js +3 -0
  9. package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
  10. package/dist/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  11. package/dist/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
  12. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +6 -1
  13. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +5 -5
  14. package/dist/client/coordinators/WaPassiveTasksCoordinator.d.ts +6 -1
  15. package/dist/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  16. package/dist/client/coordinators/WaProfileCoordinator.d.ts +11 -6
  17. package/dist/client/coordinators/WaProfileCoordinator.js +4 -1
  18. package/dist/client/coordinators/WaRetryCoordinator.d.ts +18 -1
  19. package/dist/client/coordinators/WaRetryCoordinator.js +83 -30
  20. package/dist/client/messaging/ignore-key.js +4 -2
  21. package/dist/client/types.d.ts +13 -10
  22. package/dist/esm/appstate/sync/WaAppStateSyncClient.js +36 -15
  23. package/dist/esm/auth/credentials-flow.js +3 -1
  24. package/dist/esm/auth/pairing/WaPairingFlow.js +2 -0
  25. package/dist/esm/client/WaClient.js +26 -20
  26. package/dist/esm/client/WaClientFactory.js +28 -6
  27. package/dist/esm/client/connection/WaConnectionManager.js +3 -0
  28. package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
  29. package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
  30. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +6 -6
  31. package/dist/esm/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
  32. package/dist/esm/client/coordinators/WaProfileCoordinator.js +4 -1
  33. package/dist/esm/client/coordinators/WaRetryCoordinator.js +84 -31
  34. package/dist/esm/client/messaging/ignore-key.js +4 -2
  35. package/dist/esm/message/WaMessageClient.js +3 -0
  36. package/dist/esm/message/primitives/incoming.js +20 -5
  37. package/dist/esm/protocol/constants.js +1 -1
  38. package/dist/esm/protocol/message.js +22 -0
  39. package/dist/esm/retry/replay.js +36 -2
  40. package/dist/esm/signal/session/SignalRatchet.js +2 -2
  41. package/dist/esm/transport/WaComms.js +4 -0
  42. package/dist/esm/transport/keepalive/WaKeepAlive.js +4 -0
  43. package/dist/esm/transport/node/WaNodeOrchestrator.js +2 -2
  44. package/dist/esm/transport/node/builders/global.js +3 -0
  45. package/dist/message/WaMessageClient.js +3 -0
  46. package/dist/message/primitives/incoming.d.ts +7 -1
  47. package/dist/message/primitives/incoming.js +20 -5
  48. package/dist/message/types.d.ts +5 -0
  49. package/dist/protocol/constants.d.ts +1 -1
  50. package/dist/protocol/constants.js +3 -2
  51. package/dist/protocol/message.d.ts +22 -0
  52. package/dist/protocol/message.js +23 -1
  53. package/dist/retry/replay.d.ts +12 -0
  54. package/dist/retry/replay.js +36 -2
  55. package/dist/signal/session/SignalRatchet.js +2 -2
  56. package/dist/transport/WaComms.js +4 -0
  57. package/dist/transport/keepalive/WaKeepAlive.js +4 -0
  58. package/dist/transport/node/WaNodeOrchestrator.d.ts +6 -1
  59. package/dist/transport/node/WaNodeOrchestrator.js +2 -2
  60. package/dist/transport/node/builders/global.d.ts +1 -0
  61. package/dist/transport/node/builders/global.js +3 -0
  62. package/package.json +1 -1
@@ -180,6 +180,7 @@ export class WaAppStateMutationCoordinator {
180
180
  this.emitSnapshotMutations = options.emitSnapshotMutations === true;
181
181
  this.nctSaltSink = options.nctSaltSink;
182
182
  this.contactSink = options.contactSink;
183
+ this.pushNameSink = options.pushNameSink;
183
184
  this.pendingMutations = new Map();
184
185
  this.flushPromise = null;
185
186
  }
@@ -227,11 +228,10 @@ export class WaAppStateMutationCoordinator {
227
228
  emitEventsFromSyncResult(syncResult) {
228
229
  for (const collectionResult of syncResult.collections) {
229
230
  const mutations = collectionResult.mutations ?? [];
230
- // Persistence sinks (contact store, ...): run on the last-wins
231
- // mutation per key INCLUDING snapshot sources, so pair-time
232
- // bootstrap of the address book always lands in the store even
233
- // when public events are suppressed for snapshot mutations.
234
- if (this.contactSink) {
231
+ // Persistence sinks (contact store, own pushName): run on the
232
+ // last-wins mutation per key INCLUDING snapshot sources, so
233
+ // pair-time bootstrap lands even when snapshot events are suppressed.
234
+ if (this.contactSink || this.pushNameSink) {
235
235
  const sinkLastIndex = new Map();
236
236
  for (let i = 0; i < mutations.length; i += 1) {
237
237
  const m = mutations[i];
@@ -252,6 +252,16 @@ export class WaAppStateMutationCoordinator {
252
252
  message: toError(error).message
253
253
  });
254
254
  }
255
+ try {
256
+ this.handlePushNameMutation(m);
257
+ }
258
+ catch (error) {
259
+ this.logger.debug('pushName sink failed', {
260
+ collection: m.collection,
261
+ index: m.index,
262
+ message: toError(error).message
263
+ });
264
+ }
255
265
  }
256
266
  }
257
267
  const lastMutationIndexByKey = new Map();
@@ -356,6 +366,20 @@ export class WaAppStateMutationCoordinator {
356
366
  lastUpdatedMs
357
367
  });
358
368
  }
369
+ handlePushNameMutation(mutation) {
370
+ if (!this.pushNameSink)
371
+ return;
372
+ // A `set` under the literal index ["setting_pushName"]; cheap reject
373
+ // before reading the value.
374
+ if (mutation.operation !== 'set')
375
+ return;
376
+ if (!mutation.index.includes('setting_pushName'))
377
+ return;
378
+ const name = mutation.value?.pushNameSetting?.name;
379
+ if (typeof name !== 'string')
380
+ return;
381
+ this.pushNameSink(name);
382
+ }
359
383
  /**
360
384
  * Mutes or unmutes a chat. `muteEndTimestampMs` is required when
361
385
  * `muted` is `true` and must be a non-negative safe-integer epoch.
@@ -388,7 +388,7 @@ export class WaIncomingNodeCoordinator {
388
388
  try {
389
389
  const routingInfo = decodeNodeContentBase64OrBytes(routingInfoNode.content, `ib.${WA_NODE_TAGS.EDGE_ROUTING}.${WA_NODE_TAGS.ROUTING_INFO}`);
390
390
  await this.runtime.persistRoutingInfo(routingInfo);
391
- this.logger.info('updated routing info from info bulletin', {
391
+ this.logger.debug('updated routing info from info bulletin', {
392
392
  byteLength: routingInfo.byteLength
393
393
  });
394
394
  }
@@ -11,7 +11,7 @@ import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
11
11
  import { writeRandomPadMax16 } from '../../message/encode/padding.js';
12
12
  import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
13
13
  import { proto } from '../../proto.js';
14
- import { WA_DEFAULTS } from '../../protocol/constants.js';
14
+ import { WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
15
15
  import { isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
16
16
  import { encodeBinaryNode } from '../../transport/binary/index.js';
17
17
  import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
@@ -23,7 +23,7 @@ export class WaMessageDispatchCoordinator {
23
23
  this.privacyTokenDedup = new PromiseDedup();
24
24
  this.distributionDedup = new PromiseDedup();
25
25
  this.deps = options;
26
- this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? false;
26
+ this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? (() => false);
27
27
  this.serverClock = options.serverClock;
28
28
  }
29
29
  async publishMessageNode(node, options = {}) {
@@ -654,7 +654,7 @@ export class WaMessageDispatchCoordinator {
654
654
  const serverAddressingMode = result.ack.addressingMode;
655
655
  const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
656
656
  const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
657
- const hasAddressingError = ackError === 421;
657
+ const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
658
658
  if (!retryContext.retried &&
659
659
  (hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
660
660
  this.deps.logger.warn('group direct publish acknowledged with mismatch metadata', {
@@ -829,7 +829,7 @@ export class WaMessageDispatchCoordinator {
829
829
  const serverAddressingMode = result.ack.addressingMode;
830
830
  const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
831
831
  const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
832
- const hasAddressingError = ackError === 421;
832
+ const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
833
833
  if (!retryContext.retried &&
834
834
  (hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
835
835
  this.deps.logger.warn('group message publish acknowledged with mismatch metadata', {
@@ -1191,7 +1191,7 @@ export class WaMessageDispatchCoordinator {
1191
1191
  const meUserJid = toUserJid(this.requireCurrentMeJid('sendMessage'));
1192
1192
  const timestampBytes = new Uint8Array(8);
1193
1193
  const dv = new DataView(timestampBytes.buffer, timestampBytes.byteOffset, timestampBytes.byteLength);
1194
- if (this.mobileMessageIdFormat) {
1194
+ if (this.mobileMessageIdFormat()) {
1195
1195
  dv.setBigUint64(0, BigInt(Date.now()), false);
1196
1196
  const digest = md5Bytes([
1197
1197
  timestampBytes,
@@ -1213,7 +1213,7 @@ export class WaMessageDispatchCoordinator {
1213
1213
  this.deps.logger.warn('failed to generate message id, falling back to random', {
1214
1214
  message: toError(error).message
1215
1215
  });
1216
- if (this.mobileMessageIdFormat) {
1216
+ if (this.mobileMessageIdFormat()) {
1217
1217
  const bytes = await randomBytesAsync(16);
1218
1218
  bytes[0] = 0xac;
1219
1219
  return bytesToHex(bytes).toUpperCase();
@@ -16,7 +16,7 @@ export class WaPassiveTasksCoordinator {
16
16
  this.signedPreKeyServerErrorBackoffMs =
17
17
  options.signedPreKeyServerErrorBackoffMs ?? SIGNAL_SIGNED_PREKEY_SERVER_ERROR_BACKOFF_MS;
18
18
  this.runtime = options.runtime;
19
- this.mobilePrimary = options.mobilePrimary ?? false;
19
+ this.mobilePrimary = options.mobilePrimary ?? (() => false);
20
20
  this.appStateSync = options.appStateSync;
21
21
  this.passiveTasksPromise = null;
22
22
  }
@@ -59,7 +59,7 @@ export class WaPassiveTasksCoordinator {
59
59
  return;
60
60
  }
61
61
  this.runtime.syncAbProps();
62
- if (this.mobilePrimary && this.appStateSync) {
62
+ if (this.mobilePrimary() && this.appStateSync) {
63
63
  await this.appStateSync.ensureInitialSyncKey().catch((error) => {
64
64
  this.logger.warn('app-state initial key generation failed', {
65
65
  message: toError(error).message
@@ -146,7 +146,7 @@ export class WaPassiveTasksCoordinator {
146
146
  const response = await this.runtime.queryWithContext('prekeys.upload', uploadNode, WA_DEFAULTS.IQ_TIMEOUT_MS, {
147
147
  count: preKeys.length,
148
148
  lastPreKeyId
149
- }, this.mobilePrimary ? { useSystemId: true } : undefined);
149
+ }, this.mobilePrimary() ? { useSystemId: true } : undefined);
150
150
  if (response.attrs.type === WA_IQ_TYPES.RESULT) {
151
151
  // Mark uploaded key first so the serverHasPreKeys flag never commits ahead of local key progress.
152
152
  await this.preKeyStore.markKeyAsUploaded(lastPreKeyId);
@@ -192,7 +192,7 @@ function buildTextStatusMutationInput(input) {
192
192
  }
193
193
  /** Builds a {@link WaProfileCoordinator} from its IQ/MEX/SID dependencies. */
194
194
  export function createProfileCoordinator(options) {
195
- const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, logger } = options;
195
+ const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, logger } = options;
196
196
  return {
197
197
  getProfilePicture: async (jid, type, existingId) => {
198
198
  const node = buildGetProfilePictureIq(jid, type, existingId);
@@ -242,6 +242,9 @@ export function createProfileCoordinator(options) {
242
242
  assertIqResult(result, 'profile.setStatus');
243
243
  },
244
244
  setPushName: async (name) => {
245
+ // Local apply first: the app-state echo of this same write then
246
+ // collapses into a no-op via applyOwnPushName's idempotency guard.
247
+ await applyOwnPushName(name);
245
248
  await mutations.set({ schema: 'SettingPushName', name });
246
249
  },
247
250
  getProfiles: async (jids) => {
@@ -1,6 +1,8 @@
1
+ import { toRawPubKey } from '../../crypto/core/keys.js';
2
+ import { BoundedTaskQueue, BoundedTaskQueueFullError } from '../../infra/perf/BoundedTaskQueue.js';
1
3
  import { buildRecoveredIncomingEvent } from '../../message/primitives/incoming.js';
2
4
  import { proto } from '../../proto.js';
3
- import { WA_MESSAGE_TAGS, WA_MESSAGE_TYPES } from '../../protocol/constants.js';
5
+ import { WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_NACK_REASONS } from '../../protocol/constants.js';
4
6
  import { isGroupOrBroadcastJid, isHostedDeviceJid, normalizeDeviceJid, parseJidFull, parseSignalAddressFromJid, toUserJid } from '../../protocol/jid.js';
5
7
  import { MAX_RETRY_ATTEMPTS, RETRY_KEYS_MIN_COUNT, RETRY_OUTBOUND_TTL_MS, RETRY_REASON } from '../../retry/constants.js';
6
8
  import { parseRetryReceiptRequest, pickRetryStateMax } from '../../retry/parse.js';
@@ -14,6 +16,10 @@ import { setBoundedMapEntry } from '../../util/collections.js';
14
16
  import { toError } from '../../util/primitives.js';
15
17
  const RETRY_CLEANUP_INTERVAL_MS = 30000;
16
18
  const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
19
+ // Decrypt-failure handling runs off the inbound pipeline (keys-section builds
20
+ // serialize on the prekey lock and hit the store); excess under flood is dropped.
21
+ const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
22
+ const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
17
23
  const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
18
24
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
19
25
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
@@ -55,22 +61,46 @@ export class WaRetryCoordinator {
55
61
  signalProtocol: options.signalProtocol,
56
62
  sessionResolver: options.sessionResolver,
57
63
  getCurrentCredentials: options.getCurrentCredentials,
58
- resolveUserIcdc: options.resolveUserIcdc
64
+ resolveUserIcdc: options.resolveUserIcdc,
65
+ resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
59
66
  });
60
67
  this.retryProcessingByMessageId = new Map();
61
68
  this.retrySessionBaseKeys = new Map();
69
+ this.decryptFailureQueue = new BoundedTaskQueue(DECRYPT_FAILURE_QUEUE_MAX_SIZE, DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY);
62
70
  }
63
- async onDecryptFailure(context, error) {
71
+ onDecryptFailure(context, error) {
72
+ // Deferred to the bounded queue: building the receipt inline would stall
73
+ // the inbound node pipeline.
74
+ void this.decryptFailureQueue
75
+ .enqueue(() => this.handleDecryptFailure(context, error))
76
+ .catch((queueError) => {
77
+ if (queueError instanceof BoundedTaskQueueFullError) {
78
+ this.deps.logger.warn('decrypt-failure retry dropped: queue saturated', {
79
+ id: context.stanzaId,
80
+ from: context.from,
81
+ participant: context.participant
82
+ });
83
+ return;
84
+ }
85
+ this.deps.logger.warn('failed to schedule decrypt-failure retry', {
86
+ id: context.stanzaId,
87
+ from: context.from,
88
+ participant: context.participant,
89
+ message: toError(queueError).message
90
+ });
91
+ });
92
+ return Promise.resolve(true);
93
+ }
94
+ async handleDecryptFailure(context, error) {
64
95
  try {
65
96
  const prepared = await this.prepareDecryptFailureRetry(context, error);
66
- if (!prepared) {
67
- return false;
97
+ if (prepared && !prepared.delegatedToPlaceholderResend) {
98
+ await this.sendDecryptFailureRetryReceipt(context, prepared);
68
99
  }
69
- if (prepared.delegatedToPlaceholderResend) {
70
- return true;
71
- }
72
- await this.sendDecryptFailureRetryReceipt(context, prepared);
73
- return true;
100
+ // Ack the failed stanza even on the give-up path: the retry receipt
101
+ // asks for a resend but does not consume the message, so without the
102
+ // ack it is redelivered on every offline resume.
103
+ await this.sendDecryptFailureAck(context);
74
104
  }
75
105
  catch (sendError) {
76
106
  this.deps.logger.warn('failed to send retry receipt for decrypt failure', {
@@ -79,7 +109,6 @@ export class WaRetryCoordinator {
79
109
  participant: context.participant,
80
110
  message: toError(sendError).message
81
111
  });
82
- return false;
83
112
  }
84
113
  }
85
114
  async handleIncomingRetryReceipt(receiptNode) {
@@ -138,6 +167,17 @@ export class WaRetryCoordinator {
138
167
  const requester = context.participant ?? context.from;
139
168
  const expiresAtMs = nowMs + this.retryTtlMs;
140
169
  const retryCount = await this.deps.retryStore.incrementInboundCounter(context.stanzaId, requester, nowMs, expiresAtMs);
170
+ if (retryCount > MAX_RETRY_ATTEMPTS) {
171
+ // Give up past the ceiling: each attempt rebuilds an expensive keys
172
+ // section, so an uncapped retry on redelivered backlog hammers the store.
173
+ this.deps.logger.debug('retry receipt skipped: inbound retry limit exceeded', {
174
+ id: context.stanzaId,
175
+ from: context.from,
176
+ participant: context.participant,
177
+ retryCount
178
+ });
179
+ return null;
180
+ }
141
181
  const delegatedToPlaceholderResend = retryCount >= PLACEHOLDER_RESEND_RETRY_THRESHOLD &&
142
182
  this.enqueuePlaceholderResend(context);
143
183
  if (delegatedToPlaceholderResend) {
@@ -166,7 +206,7 @@ export class WaRetryCoordinator {
166
206
  };
167
207
  }
168
208
  async sendDecryptFailureRetryReceipt(context, prepared) {
169
- const recipient = context.recipient ?? this.resolvePeerRetryRecipient(context);
209
+ const { recipient } = context;
170
210
  const retryReceiptNode = buildRetryReceiptNode({
171
211
  stanzaId: context.stanzaId,
172
212
  to: context.from,
@@ -191,24 +231,33 @@ export class WaRetryCoordinator {
191
231
  withKeys: prepared.retryKeys !== undefined
192
232
  });
193
233
  }
194
- resolvePeerRetryRecipient(context) {
195
- if (!context.participant) {
196
- return undefined;
197
- }
198
- const meLid = this.deps.getCurrentCredentials()?.meLid;
199
- if (!meLid) {
200
- return undefined;
234
+ async sendDecryptFailureAck(context) {
235
+ if (!context.stanzaId || !context.from) {
236
+ return;
201
237
  }
202
238
  try {
203
- const participantUser = toUserJid(context.participant);
204
- const meUserLid = toUserJid(meLid);
205
- if (participantUser !== meUserLid) {
206
- return undefined;
207
- }
208
- return meUserLid;
239
+ await this.deps.sendNode(buildAckNode({
240
+ kind: 'message',
241
+ node: context.messageNode,
242
+ id: context.stanzaId,
243
+ to: context.from,
244
+ participant: context.participant,
245
+ from: this.deps.getCurrentCredentials()?.meJid ?? undefined,
246
+ error: WA_NACK_REASONS.UNHANDLED_ERROR
247
+ }));
248
+ this.deps.logger.trace('acked undecryptable stanza', {
249
+ id: context.stanzaId,
250
+ from: context.from,
251
+ participant: context.participant
252
+ });
209
253
  }
210
- catch {
211
- return undefined;
254
+ catch (error) {
255
+ this.deps.logger.warn('failed to ack undecryptable stanza', {
256
+ id: context.stanzaId,
257
+ from: context.from,
258
+ participant: context.participant,
259
+ message: toError(error).message
260
+ });
212
261
  }
213
262
  }
214
263
  async handleParsedRetryRequest(receiptNode, request) {
@@ -384,14 +433,14 @@ export class WaRetryCoordinator {
384
433
  await this.deps.preKeyStore.markKeyAsUploaded(preKey.keyId);
385
434
  const signedIdentity = this.deps.getCurrentCredentials()?.signedIdentity;
386
435
  return {
387
- identity,
436
+ identity: toRawPubKey(identity),
388
437
  key: {
389
438
  id: preKey.keyId,
390
- publicKey: preKey.keyPair.pubKey
439
+ publicKey: toRawPubKey(preKey.keyPair.pubKey)
391
440
  },
392
441
  skey: {
393
442
  id: signedPreKey.keyId,
394
- publicKey: signedPreKey.keyPair.pubKey,
443
+ publicKey: toRawPubKey(signedPreKey.keyPair.pubKey),
395
444
  signature: signedPreKey.signature
396
445
  },
397
446
  deviceIdentity: signedIdentity
@@ -719,6 +768,9 @@ export class WaRetryCoordinator {
719
768
  if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
720
769
  return false;
721
770
  }
771
+ if (this.deps.isMobilePrimary?.()) {
772
+ return false;
773
+ }
722
774
  const subtype = context.messageNode.attrs.subtype;
723
775
  if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
724
776
  return false;
@@ -776,6 +828,7 @@ export class WaRetryCoordinator {
776
828
  }
777
829
  }))
778
830
  });
831
+ const meJid = this.deps.getCurrentCredentials()?.meJid;
779
832
  for (const result of results) {
780
833
  const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
781
834
  if (!bytes) {
@@ -783,7 +836,7 @@ export class WaRetryCoordinator {
783
836
  }
784
837
  try {
785
838
  const recovered = proto.WebMessageInfo.decode(bytes);
786
- emitIncomingMessage(buildRecoveredIncomingEvent(recovered));
839
+ emitIncomingMessage(buildRecoveredIncomingEvent(recovered, meJid));
787
840
  }
788
841
  catch (error) {
789
842
  this.deps.logger.warn('placeholder resend: failed to decode WebMessageInfo', {
@@ -82,12 +82,14 @@ export function extractIgnoreKeyContext(node, meJid) {
82
82
  const me = tryParseJid(meJid);
83
83
  const fromCandidates = collectFromCandidates(kind, a);
84
84
  const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
85
+ // Device-stripped to match the JID form used by events/keys; a userless
86
+ // server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
85
87
  return {
86
88
  kind,
87
- remoteJid: a.from ?? null,
89
+ remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
88
90
  fromMe,
89
91
  id: a.id,
90
- participant: a.participant ?? null
92
+ participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
91
93
  };
92
94
  }
93
95
  /** Pure matcher. Exported for direct testing without a coordinator. */
@@ -188,6 +188,9 @@ export class WaMessageClient {
188
188
  if (input.metaNode) {
189
189
  content.push(input.metaNode);
190
190
  }
191
+ if (input.privacyTokenNode) {
192
+ content.push(input.privacyTokenNode);
193
+ }
191
194
  const node = {
192
195
  tag: WA_MESSAGE_TAGS.MESSAGE,
193
196
  attrs,
@@ -69,13 +69,22 @@ function buildIncomingEventRawNode(node) {
69
69
  content: children
70
70
  };
71
71
  }
72
- export function buildRecoveredIncomingEvent(webMessageInfo) {
72
+ /**
73
+ * Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
74
+ * (placeholder resend). `meJid` is the current account user JID, used as the
75
+ * author fallback for self-sent group messages when the proto carries no
76
+ * participant and no `originalSelfAuthorUserJidString`.
77
+ */
78
+ export function buildRecoveredIncomingEvent(webMessageInfo, meJid) {
73
79
  const key = webMessageInfo.key ?? {};
74
80
  const chatJid = key.remoteJid ?? undefined;
75
81
  const fromMe = key.fromMe === true;
76
- const participant = key.participant ?? undefined;
77
82
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
78
83
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
84
+ const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
85
+ const selfAuthor = fromMe && (isGroup || isBroadcast) && rawSelfAuthor ? toUserJid(rawSelfAuthor) : undefined;
86
+ const participant = webMessageInfo.participant ?? key.participant ?? selfAuthor;
87
+ const pushName = webMessageInfo.pushName ?? undefined;
79
88
  const rawSender = fromMe ? undefined : isGroup || isBroadcast ? participant : chatJid;
80
89
  const sender = rawSender ? parseJidFull(rawSender) : null;
81
90
  const senderDevice = sender?.address.device;
@@ -90,7 +99,8 @@ export function buildRecoveredIncomingEvent(webMessageInfo) {
90
99
  attrs: {
91
100
  ...(stanzaId !== undefined ? { id: stanzaId } : {}),
92
101
  ...(chatJid !== undefined ? { from: chatJid } : {}),
93
- ...(participant !== undefined ? { participant } : {})
102
+ ...(participant !== undefined ? { participant } : {}),
103
+ ...(pushName !== undefined ? { notify: pushName } : {})
94
104
  }
95
105
  };
96
106
  return {
@@ -107,6 +117,7 @@ export function buildRecoveredIncomingEvent(webMessageInfo) {
107
117
  },
108
118
  timestampSeconds,
109
119
  ...(expirationSeconds !== undefined ? { expirationSeconds } : {}),
120
+ ...(pushName !== undefined ? { pushName } : {}),
110
121
  message
111
122
  };
112
123
  }
@@ -229,7 +240,7 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
229
240
  encPayload: decoded.encPayload
230
241
  }
231
242
  };
232
- const chatJid = node.attrs.from;
243
+ const chatJid = node.attrs.from ? toUserJid(node.attrs.from) : node.attrs.from;
233
244
  const sender = senderJid ? parseJidFull(senderJid) : null;
234
245
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
235
246
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
@@ -302,7 +313,11 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
302
313
  }
303
314
  }
304
315
  if (shouldEmitIncomingMessage(message)) {
305
- const chatJid = node.attrs.from;
316
+ // remoteJid is the chat identity, which is deviceless: the device
317
+ // lives in senderDevice (from senderAddress), so strip any `:device`
318
+ // segment the `from` attr carries for 1:1 chats.
319
+ const fromAttr = node.attrs.from;
320
+ const chatJid = fromAttr ? toUserJid(fromAttr) : fromAttr;
306
321
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
307
322
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
308
323
  const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
@@ -3,7 +3,7 @@ export { WA_SIGNALING, WA_PAIRING_KDF_INFO } from './auth.js';
3
3
  export { WA_CONNECTION_REASONS, WA_DISCONNECT_REASONS, WA_FAILURE_REASONS, WA_LOGOUT_REASONS, WA_READY_STATES, WA_STREAM_SIGNALING } from './stream.js';
4
4
  export { WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from './nodes.js';
5
5
  export { WA_CALL_CHILD_TAGS, WA_CALL_NODE_ATTRS, WA_CALL_PAYLOAD_TAGS, WA_CALL_RECEIPT_PAYLOAD_TAGS } from './call.js';
6
- export { WA_EDIT_ATTRS, WA_ENC_MEDIA_TYPES, WA_EVENT_META_TYPES, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_POLL_META_TYPES, WA_RETRYABLE_ACK_CODES, WA_STANZA_MSG_TYPES } from './message.js';
6
+ export { WA_EDIT_ATTRS, WA_ENC_MEDIA_TYPES, WA_EVENT_META_TYPES, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_NACK_REASONS, WA_POLL_META_TYPES, WA_RETRYABLE_ACK_CODES, WA_STANZA_MSG_TYPES } from './message.js';
7
7
  export { WA_APP_STATE_COLLECTIONS, WA_APP_STATE_COLLECTION_STATES, WA_APP_STATE_ERROR_CODES, WA_APP_STATE_KDF_INFO, WA_APP_STATE_KEY_TYPES, WA_APP_STATE_SYNC_DATA_TYPE } from './appstate.js';
8
8
  export { getWaMediaHkdfInfo, WA_MEDIA_HKDF_INFO, WA_PREVIEW_MEDIA_HKDF_INFO } from './media.js';
9
9
  export { WA_ACCOUNT_SYNC_PROTOCOLS, WA_DIRTY_PROTOCOLS, WA_DIRTY_TYPES, WA_SUPPORTED_DIRTY_TYPES } from './dirty.js';
@@ -27,6 +27,28 @@ export const WA_MESSAGE_TYPES = Object.freeze({
27
27
  RECEIPT_TYPE_VIEW: 'view'
28
28
  });
29
29
  export const WA_RETRYABLE_ACK_CODES = Object.freeze(['408', '429', '500', '503']);
30
+ /**
31
+ * Stanza-ack `error` attr codes (nack reasons): sent when nacking a stanza
32
+ * that could not be handled, and matched against the `error` carried by an
33
+ * inbound publish ack (e.g. stale group addressing mode).
34
+ */
35
+ export const WA_NACK_REASONS = Object.freeze({
36
+ STALE_GROUP_ADDRESSING_MODE: 421,
37
+ NEW_CHAT_MESSAGES_CAPPED: 475,
38
+ PARSING_ERROR: 487,
39
+ UNRECOGNIZED_STANZA: 488,
40
+ UNRECOGNIZED_STANZA_CLASS: 489,
41
+ UNRECOGNIZED_STANZA_TYPE: 490,
42
+ INVALID_PROTOBUF: 491,
43
+ INVALID_HOSTED_COMPANION_STANZA: 493,
44
+ MISSING_MESSAGE_SECRET: 495,
45
+ SIGNAL_ERROR_OLD_COUNTER: 496,
46
+ MESSAGE_DELETED_ON_PEER: 499,
47
+ UNHANDLED_ERROR: 500,
48
+ UNSUPPORTED_ADMIN_REVOKE: 550,
49
+ UNSUPPORTED_LID_GROUP: 551,
50
+ DB_OPERATION_FAILED: 552
51
+ });
30
52
  export const WA_STANZA_MSG_TYPES = Object.freeze({
31
53
  TEXT: 'text',
32
54
  MEDIA: 'media',
@@ -66,6 +66,9 @@ export class WaRetryReplayService {
66
66
  const metaNode = isHostedDeviceJid(requesterJid)
67
67
  ? buildMetaNode({ sender_intent: 'hosted' })
68
68
  : undefined;
69
+ const privacyTokenNode = requesterIsSelf
70
+ ? undefined
71
+ : await this.resolvePrivacyToken(requesterJid);
69
72
  await this.options.messageClient.sendEncrypted({
70
73
  to: requesterJid,
71
74
  encType: encrypted.type,
@@ -74,7 +77,8 @@ export class WaRetryReplayService {
74
77
  id: outbound.messageId,
75
78
  type: payload.type,
76
79
  deviceIdentity,
77
- metaNode
80
+ metaNode,
81
+ privacyTokenNode
78
82
  });
79
83
  return 'resent';
80
84
  }
@@ -88,6 +92,32 @@ export class WaRetryReplayService {
88
92
  }
89
93
  return proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
90
94
  }
95
+ /**
96
+ * Resolves the trusted-contact token node for the requester's user jid. A
97
+ * failure (or absent resolver) yields no node and the resend still goes out.
98
+ */
99
+ async resolvePrivacyToken(requesterJid) {
100
+ if (!this.options.resolvePrivacyTokenNode) {
101
+ return undefined;
102
+ }
103
+ let recipientUserJid;
104
+ try {
105
+ recipientUserJid = toUserJid(requesterJid);
106
+ }
107
+ catch {
108
+ return undefined;
109
+ }
110
+ try {
111
+ return (await this.options.resolvePrivacyTokenNode(recipientUserJid)) ?? undefined;
112
+ }
113
+ catch (error) {
114
+ this.options.logger.warn('retry resend privacy token resolution failed', {
115
+ to: recipientUserJid,
116
+ message: toError(error).message
117
+ });
118
+ return undefined;
119
+ }
120
+ }
91
121
  async refreshRetryPlaintext(payload, options) {
92
122
  if (!options.wrapAsDeviceSent && !this.options.resolveUserIcdc) {
93
123
  return null;
@@ -199,6 +229,9 @@ export class WaRetryReplayService {
199
229
  const metaNode = isHostedDeviceJid(requesterJid)
200
230
  ? buildMetaNode({ sender_intent: 'hosted' })
201
231
  : undefined;
232
+ const privacyTokenNode = this.isRequesterCurrentAccount(requesterJid)
233
+ ? undefined
234
+ : await this.resolvePrivacyToken(requesterJid);
202
235
  await this.options.messageClient.sendEncrypted({
203
236
  to: requesterJid,
204
237
  encType: payload.encType,
@@ -208,7 +241,8 @@ export class WaRetryReplayService {
208
241
  type: payload.type,
209
242
  participant: payload.participant,
210
243
  deviceIdentity,
211
- metaNode
244
+ metaNode,
245
+ privacyTokenNode
212
246
  });
213
247
  return 'resent';
214
248
  }
@@ -156,9 +156,9 @@ export async function decryptMsg(session, parsed, onPrevSessionDecryptError) {
156
156
  }
157
157
  catch (error) {
158
158
  for (let i = 0; i < session.prevSessions.length; i += 1) {
159
- const decodedPrev = decodeSignalSessionSnapshot(session.prevSessions[i], `prevSessions[${i}]`);
160
- const prevSession = snapshotToRecord(decodedPrev);
161
159
  try {
160
+ const decodedPrev = decodeSignalSessionSnapshot(session.prevSessions[i], `prevSessions[${i}]`);
161
+ const prevSession = snapshotToRecord(decodedPrev);
162
162
  const [updatedPrev, plaintext] = await decryptMsgFromSession(prevSession, parsed);
163
163
  const updatedSession = {
164
164
  ...updatedPrev,
@@ -169,6 +169,10 @@ export class WaComms {
169
169
  await this.socket.close(1000, 'stop_comms');
170
170
  }
171
171
  async closeSocketAndResume() {
172
+ if (!this.started || this.preventRetry) {
173
+ this.logger.debug('comms resume skipped: comms stopped or retry disabled');
174
+ return;
175
+ }
172
176
  this.logger.debug('comms close socket and resume requested');
173
177
  this.resetConnectionState({
174
178
  started: true,
@@ -101,6 +101,10 @@ export class WaKeepAlive {
101
101
  }
102
102
  }
103
103
  catch (error) {
104
+ if (generation !== this.generation) {
105
+ this.logger.trace('keepalive stopped during in-flight ping, not resuming');
106
+ return;
107
+ }
104
108
  this.logger.warn('keepalive ping failed, reconnecting socket', {
105
109
  message: toError(error).message
106
110
  });
@@ -12,7 +12,7 @@ export class WaNodeOrchestrator {
12
12
  this.sendNodeFn = options.sendNode;
13
13
  this.defaultTimeoutMs = options.defaultTimeoutMs ?? WA_DEFAULTS.NODE_QUERY_TIMEOUT_MS;
14
14
  this.hostDomain = options.hostDomain ?? WA_DEFAULTS.HOST_DOMAIN;
15
- this.mobileIqIdFormat = options.mobileIqIdFormat === true;
15
+ this.mobileIqIdFormat = options.mobileIqIdFormat ?? (() => false);
16
16
  this.idGenerator = null;
17
17
  this.idGeneratorReady = null;
18
18
  this.pendingQueries = new Map();
@@ -134,7 +134,7 @@ export class WaNodeOrchestrator {
134
134
  if (this.idGenerator) {
135
135
  return this.idGenerator;
136
136
  }
137
- if (this.mobileIqIdFormat) {
137
+ if (this.mobileIqIdFormat()) {
138
138
  this.idGenerator = createMobileNodeIdGenerator();
139
139
  this.logger.debug('generated stanza prefix (mobile)', {
140
140
  prefix: this.idGenerator.prefix
@@ -57,6 +57,9 @@ export function buildAckNode(input) {
57
57
  to: input.to,
58
58
  class: WA_MESSAGE_TYPES.ACK_CLASS_MESSAGE
59
59
  };
60
+ if (input.error !== undefined) {
61
+ attrs.error = String(input.error);
62
+ }
60
63
  const type = input.typeOverride ?? input.node.attrs.type;
61
64
  if (type) {
62
65
  attrs.type = type;