zapo-js 1.1.2 → 1.2.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 (45) hide show
  1. package/dist/client/WaClient.js +1 -7
  2. package/dist/client/WaClientFactory.js +7 -4
  3. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +16 -0
  4. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +43 -6
  5. package/dist/client/coordinators/WaPresenceCoordinator.d.ts +6 -0
  6. package/dist/client/coordinators/WaPresenceCoordinator.js +8 -2
  7. package/dist/client/coordinators/WaProfileCoordinator.d.ts +7 -0
  8. package/dist/client/coordinators/WaProfileCoordinator.js +10 -4
  9. package/dist/client/coordinators/WaRetryCoordinator.js +1 -1
  10. package/dist/client/coordinators/WaTrustedContactTokenCoordinator.d.ts +9 -0
  11. package/dist/client/coordinators/WaTrustedContactTokenCoordinator.js +23 -7
  12. package/dist/client/persistence/mailbox.d.ts +6 -0
  13. package/dist/client/persistence/mailbox.js +2 -2
  14. package/dist/client/types.d.ts +49 -0
  15. package/dist/esm/client/WaClient.js +1 -7
  16. package/dist/esm/client/WaClientFactory.js +8 -5
  17. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +44 -7
  18. package/dist/esm/client/coordinators/WaPresenceCoordinator.js +8 -2
  19. package/dist/esm/client/coordinators/WaProfileCoordinator.js +10 -4
  20. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -1
  21. package/dist/esm/client/coordinators/WaTrustedContactTokenCoordinator.js +23 -7
  22. package/dist/esm/client/persistence/mailbox.js +2 -2
  23. package/dist/esm/message/primitives/incoming.js +99 -42
  24. package/dist/esm/protocol/jid.js +42 -0
  25. package/dist/esm/retry/reason.js +2 -2
  26. package/dist/esm/transport/node/WaNodeOrchestrator.js +6 -2
  27. package/dist/esm/transport/node/builders/message.js +3 -0
  28. package/dist/esm/transport/node/builders/presence.js +2 -1
  29. package/dist/esm/transport/node/builders/profile.js +3 -2
  30. package/dist/esm/transport/node/builders/retry.js +2 -4
  31. package/dist/index.d.ts +1 -1
  32. package/dist/message/primitives/incoming.d.ts +3 -1
  33. package/dist/message/primitives/incoming.js +98 -41
  34. package/dist/protocol/jid.d.ts +12 -0
  35. package/dist/protocol/jid.js +44 -0
  36. package/dist/retry/reason.js +2 -2
  37. package/dist/transport/node/WaNodeOrchestrator.js +6 -2
  38. package/dist/transport/node/builders/message.d.ts +1 -0
  39. package/dist/transport/node/builders/message.js +3 -0
  40. package/dist/transport/node/builders/presence.d.ts +6 -0
  41. package/dist/transport/node/builders/presence.js +2 -1
  42. package/dist/transport/node/builders/profile.d.ts +1 -1
  43. package/dist/transport/node/builders/profile.js +3 -2
  44. package/dist/transport/node/builders/retry.js +2 -4
  45. package/package.json +2 -2
@@ -12,7 +12,7 @@ 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
14
  import { WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
15
- import { isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
15
+ import { canonicalizeOwnAccountJid, isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, isUserJid, 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';
18
18
  import { bytesToHex, TEXT_ENCODER } from '../../util/bytes.js';
@@ -65,17 +65,20 @@ export class WaMessageDispatchCoordinator {
65
65
  }
66
66
  async publishSignalMessage(input, options = {}) {
67
67
  this.requireCurrentMeJid('publishSignalMessage');
68
- const address = parseSignalAddressFromJid(input.to);
68
+ const credentials = this.deps.getCurrentCredentials();
69
+ const sessionJid = canonicalizeOwnAccountJid(input.to, credentials?.meJid, credentials?.meLid);
70
+ const address = parseSignalAddressFromJid(sessionJid);
69
71
  if (address.server === WA_DEFAULTS.GROUP_SERVER) {
70
72
  throw new Error('publishSignalMessage currently supports only direct chats; use sender-key flow for groups');
71
73
  }
72
74
  this.deps.logger.trace('wa client publish signal message', {
73
75
  to: input.to,
76
+ sessionJid,
74
77
  type: input.type
75
78
  });
76
79
  const [paddedPlaintext] = await Promise.all([
77
80
  writeRandomPadMax16(input.plaintext),
78
- this.deps.sessionResolver.ensureSession(address, input.to, input.expectedIdentity)
81
+ this.deps.sessionResolver.ensureSession(address, sessionJid, input.expectedIdentity)
79
82
  ]);
80
83
  const encrypted = await this.deps.signalProtocol.encryptMessage(address, paddedPlaintext, input.expectedIdentity);
81
84
  const messageType = input.type ?? 'text';
@@ -211,7 +214,7 @@ export class WaMessageDispatchCoordinator {
211
214
  if (rawSecret &&
212
215
  rawSecret.length > 0 &&
213
216
  sendOptions.id &&
214
- needsSecretPersistence(messageWithSecret)) {
217
+ (this.deps.persistAllMessageSecrets || needsSecretPersistence(messageWithSecret))) {
215
218
  const meJid = this.deps.getCurrentCredentials()?.meJid ?? '';
216
219
  void this.deps.messageSecretStore
217
220
  .set(sendOptions.id, { secret: rawSecret, senderJid: meJid })
@@ -272,11 +275,14 @@ export class WaMessageDispatchCoordinator {
272
275
  const directRecipientJid = isGroup
273
276
  ? recipientJid
274
277
  : await this.resolveDirectRecipientLid(toUserJid(recipientJid));
278
+ const peerRecipientPn = isGroup
279
+ ? undefined
280
+ : await this.resolvePeerRecipientPn(toUserJid(recipientJid), directRecipientJid);
275
281
  const publishResult = isGroup
276
282
  ? this.shouldUseGroupDirectPath(messageWithIcdc)
277
283
  ? await this.publishGroupDirectMessage(recipientJid, envelope)
278
284
  : await this.publishGroupSenderKeyMessage(recipientJid, envelope)
279
- : await this.publishDirectSignalMessageWithFanout(directRecipientJid, envelope);
285
+ : await this.publishDirectSignalMessageWithFanout(directRecipientJid, envelope, peerRecipientPn);
280
286
  return upload ? { ...publishResult, upload } : publishResult;
281
287
  }
282
288
  /**
@@ -311,6 +317,35 @@ export class WaMessageDispatchCoordinator {
311
317
  }
312
318
  return pnUserJid;
313
319
  }
320
+ /**
321
+ * Resolves the `peer_recipient_pn` cross-reference for a 1:1 send, or
322
+ * `undefined` to drop the attribute. Only set when the envelope is
323
+ * LID-addressed (`directRecipientJid` is a LID): the PN is the caller's own
324
+ * JID when they passed a PN (zapo switched it to LID for sending), else the
325
+ * device-list snapshot counterpart for a recipient passed in LID form.
326
+ * Mirrors wa-web, which sets `peer_recipient_pn = getPhoneNumber($)` for a
327
+ * LID destination.
328
+ */
329
+ async resolvePeerRecipientPn(recipientUserJid, directRecipientJid) {
330
+ if (!isLidJid(directRecipientJid))
331
+ return undefined;
332
+ if (isUserJid(recipientUserJid))
333
+ return recipientUserJid;
334
+ try {
335
+ const snapshot = await this.deps.deviceListStore.findByAnyUserJid(directRecipientJid);
336
+ if (snapshot?.userJid && isUserJid(snapshot.userJid))
337
+ return snapshot.userJid;
338
+ if (snapshot?.altUserJid && isUserJid(snapshot.altUserJid))
339
+ return snapshot.altUserJid;
340
+ }
341
+ catch (error) {
342
+ this.deps.logger.trace('peer_recipient_pn store lookup failed', {
343
+ lid: directRecipientJid,
344
+ message: toError(error).message
345
+ });
346
+ }
347
+ return undefined;
348
+ }
314
349
  async syncSignalSession(jid, reasonIdentity = false) {
315
350
  const address = parseSignalAddressFromJid(jid);
316
351
  if (address.server === WA_DEFAULTS.GROUP_SERVER) {
@@ -322,7 +357,8 @@ export class WaMessageDispatchCoordinator {
322
357
  await this.deps.messageClient.sendReceipt(input);
323
358
  }
324
359
  async publishProtocolMessageToDevice(deviceJid, protocolMessage, options) {
325
- const meJid = this.deps.getCurrentCredentials()?.meJid;
360
+ const credentials = this.deps.getCurrentCredentials();
361
+ const meJid = credentials?.meJid;
326
362
  const meParsed = meJid ? parseJidFull(meJid) : undefined;
327
363
  const meUserJid = meParsed?.userJid;
328
364
  let senderIcdc = null;
@@ -990,7 +1026,7 @@ export class WaMessageDispatchCoordinator {
990
1026
  distributionParticipants
991
1027
  };
992
1028
  }
993
- async publishDirectSignalMessageWithFanout(recipientJid, envelope) {
1029
+ async publishDirectSignalMessageWithFanout(recipientJid, envelope, peerRecipientPn) {
994
1030
  const { message, plaintext, type, edit, mediatype, sendOptions } = envelope;
995
1031
  const meJid = this.requireCurrentMeJid('sendMessage');
996
1032
  const meLid = this.deps.getCurrentCredentials()?.meLid;
@@ -1153,6 +1189,7 @@ export class WaMessageDispatchCoordinator {
1153
1189
  customNodes: customNodes.length > 0 ? customNodes : undefined,
1154
1190
  mediatype,
1155
1191
  decryptFail: envelope.decryptFail,
1192
+ peerRecipientPn,
1156
1193
  additionalAttributes: sendOptions.additionalAttributes
1157
1194
  });
1158
1195
  const replayPayload = {
@@ -1,8 +1,9 @@
1
+ import { isGroupJid } from '../../protocol/jid.js';
1
2
  import { buildChatstateNode } from '../../transport/node/builders/chatstate.js';
2
3
  import { buildPresenceNode, buildPresenceSubscribeNode } from '../../transport/node/builders/presence.js';
3
4
  /** Builds a {@link WaPresenceCoordinator} from its node-send dependency. */
4
5
  export function createPresenceCoordinator(options) {
5
- const { sendNode, getCurrentCredentials } = options;
6
+ const { sendNode, getCurrentCredentials, resolvePrivacyTokenNode } = options;
6
7
  return {
7
8
  send: async (type) => {
8
9
  const credentials = getCurrentCredentials();
@@ -12,7 +13,12 @@ export function createPresenceCoordinator(options) {
12
13
  await sendNode(buildChatstateNode({ jid, ...opts }));
13
14
  },
14
15
  subscribe: async (jid, opts) => {
15
- await sendNode(buildPresenceSubscribeNode({ jid, ...opts }));
16
+ const privacyTokenNode = isGroupJid(jid) ? null : await resolvePrivacyTokenNode(jid);
17
+ await sendNode(buildPresenceSubscribeNode({
18
+ jid,
19
+ ...opts,
20
+ ...(privacyTokenNode ? { privacyTokenNode } : {})
21
+ }));
16
22
  }
17
23
  };
18
24
  }
@@ -192,10 +192,11 @@ 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, applyOwnPushName, logger } = options;
195
+ const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, resolvePrivacyTokenNode, logger } = options;
196
196
  return {
197
197
  getProfilePicture: async (jid, type, existingId) => {
198
- const node = buildGetProfilePictureIq(jid, type, existingId);
198
+ const privacyTokenNode = (await resolvePrivacyTokenNode(jid)) ?? undefined;
199
+ const node = buildGetProfilePictureIq(jid, type, existingId, privacyTokenNode);
199
200
  const result = await queryWithContext('profile.getPicture', node, undefined, {
200
201
  jid,
201
202
  type: type ?? 'preview'
@@ -222,10 +223,11 @@ export function createProfileCoordinator(options) {
222
223
  getStatus: async (jid) => {
223
224
  const sid = await generateSid();
224
225
  const queryNodes = buildGetStatusUsyncQueryNodes();
226
+ const privacyTokenNode = await resolvePrivacyTokenNode(jid);
225
227
  const usyncNode = buildUsyncIq({
226
228
  sid,
227
229
  queryProtocolNodes: [queryNodes[1]],
228
- users: [{ jid }]
230
+ users: [{ jid, ...(privacyTokenNode ? { content: [privacyTokenNode] } : {}) }]
229
231
  });
230
232
  const result = await queryWithContext('profile.getStatus', usyncNode, undefined, {
231
233
  jid
@@ -253,10 +255,14 @@ export function createProfileCoordinator(options) {
253
255
  }
254
256
  const sid = await generateSid();
255
257
  const queryProtocolNodes = buildGetStatusUsyncQueryNodes();
258
+ const users = await Promise.all(jids.map(async (jid) => {
259
+ const privacyTokenNode = await resolvePrivacyTokenNode(jid);
260
+ return { jid, ...(privacyTokenNode ? { content: [privacyTokenNode] } : {}) };
261
+ }));
256
262
  const usyncNode = buildUsyncIq({
257
263
  sid,
258
264
  queryProtocolNodes,
259
- users: jids.map((jid) => ({ jid }))
265
+ users
260
266
  });
261
267
  const result = await queryWithContext('profile.getProfiles', usyncNode, undefined, {
262
268
  count: jids.length
@@ -499,7 +499,7 @@ export class WaRetryCoordinator {
499
499
  id: request.keyBundle.key.id,
500
500
  publicKey: request.keyBundle.key.publicKey
501
501
  }
502
- });
502
+ }, { reuseExisting: true });
503
503
  return this.applySessionBaseKeyPolicy(request, requesterJid, requesterAddress, requesterNormalizedDeviceJid);
504
504
  }
505
505
  const sessionStillExists = currentSession !== null && !regIdMismatch;
@@ -39,14 +39,30 @@ export class WaTrustedContactTokenCoordinator {
39
39
  maxDurationS
40
40
  };
41
41
  }
42
- async resolveTokenForMessage(recipientJid) {
43
- const record = await this.store.getByJid(recipientJid);
44
- const nowS = this.serverClock.nowSeconds();
42
+ /**
43
+ * Resolves the receiver-mode `<tctoken>` node to echo back on outbound
44
+ * queries against a contact's privacy-gated data (presence subscribe,
45
+ * profile-picture get, about/status usync). Returns the token only when a
46
+ * non-expired receiver token exists for `jid`; unlike
47
+ * {@link resolveTokenForMessage} it does **not** fall back to a `<cstoken>`
48
+ * (the gated query flows attach the trusted-contact token only).
49
+ */
50
+ async resolveReceiverTokenNode(jid) {
51
+ const record = await this.store.getByJid(jid);
52
+ if (!record?.tcToken || record.tcTokenTimestamp === undefined) {
53
+ return null;
54
+ }
45
55
  const config = this.resolveConfig();
46
- if (record?.tcToken &&
47
- record.tcTokenTimestamp !== undefined &&
48
- !isTokenExpired(record.tcTokenTimestamp, nowS, config.durationS, config.numBuckets)) {
49
- return buildTcTokenMessageNode(record.tcToken);
56
+ const nowS = this.serverClock.nowSeconds();
57
+ if (isTokenExpired(record.tcTokenTimestamp, nowS, config.durationS, config.numBuckets)) {
58
+ return null;
59
+ }
60
+ return buildTcTokenMessageNode(record.tcToken);
61
+ }
62
+ async resolveTokenForMessage(recipientJid) {
63
+ const tcTokenNode = await this.resolveReceiverTokenNode(recipientJid);
64
+ if (tcTokenNode) {
65
+ return tcTokenNode;
50
66
  }
51
67
  const nctSalt = await this.getNctSalt();
52
68
  if (!nctSalt) {
@@ -53,7 +53,7 @@ function persistContacts(writeBehind, event, nowMs) {
53
53
  }
54
54
  }
55
55
  export function persistIncomingMailboxEntities(options) {
56
- const { logger, writeBehind, messageSecretStore, event } = options;
56
+ const { logger, writeBehind, messageSecretStore, persistAllSecrets, event } = options;
57
57
  const stanzaId = event.key.id;
58
58
  const chatJid = event.key.remoteJid;
59
59
  if (!stanzaId || !chatJid) {
@@ -78,7 +78,7 @@ export function persistIncomingMailboxEntities(options) {
78
78
  if (rawSecret &&
79
79
  rawSecret.length > 0 &&
80
80
  event.message &&
81
- needsSecretPersistence(event.message)) {
81
+ (persistAllSecrets || needsSecretPersistence(event.message))) {
82
82
  const rawSender = event.key.participant ?? event.rawNode.attrs.participant ?? event.key.remoteJid;
83
83
  const senderJid = rawSender ? toUserJid(rawSender) : '';
84
84
  void messageSecretStore
@@ -4,7 +4,7 @@ import { unpadPkcs7 } from '../encode/padding.js';
4
4
  import { processIncomingNewsletterMessage } from '../kinds/newsletter.js';
5
5
  import { proto } from '../../proto.js';
6
6
  import { WA_MESSAGE_TAGS, WA_MESSAGE_TYPES } from '../../protocol/constants.js';
7
- import { isBroadcastJid, isGroupJid, isNewsletterJid, parseJidFull, parseSignalAddressFromJid, toUserJid } from '../../protocol/jid.js';
7
+ import { canonicalizeOwnAccountJid, isBroadcastJid, isGroupJid, isNewsletterJid, isOwnAccountJid, parseJidFull, parseSignalAddressFromJid, toUserJid } from '../../protocol/jid.js';
8
8
  import { buildAckNode, buildReceiptNode } from '../../transport/node/builders/global.js';
9
9
  import { decodeNodeContentBase64OrBytes, findNodeChild } from '../../transport/node/helpers.js';
10
10
  import { longToNumber, parseOptionalInt, toError } from '../../util/primitives.js';
@@ -27,6 +27,52 @@ function extractMessageIdentityAttrs(attrs) {
27
27
  pushName: attrs.notify
28
28
  };
29
29
  }
30
+ /**
31
+ * Self-authored 1:1 chat is the recipient, so its alternate addressing is the
32
+ * `recipient*` attrs (the `sender*` attrs describe me). Promotes `recipientAlt`
33
+ * to `remoteJidAlt` and drops the stale sender/recipient fields.
34
+ */
35
+ function promoteRecipientAddressing(identity) {
36
+ return {
37
+ ...(identity.recipientAlt ? { remoteJidAlt: identity.recipientAlt } : {}),
38
+ ...(identity.senderUsername !== undefined
39
+ ? { senderUsername: identity.senderUsername }
40
+ : {})
41
+ };
42
+ }
43
+ /**
44
+ * Resolves the {@link WaIncomingMessageKey} for a decrypted stanza. `remoteJid`
45
+ * is the chat: the `from`, or the recipient (`recipient` attr, then the
46
+ * deviceSentMessage `destinationJid`) when the message is `fromMe`.
47
+ */
48
+ function buildIncomingMessageKey(node, sender, options, destinationJid) {
49
+ const fromUserJid = node.attrs.from ? toUserJid(node.attrs.from) : node.attrs.from;
50
+ const isGroup = fromUserJid ? isGroupJid(fromUserJid) : false;
51
+ const isBroadcast = fromUserJid ? isBroadcastJid(fromUserJid) : false;
52
+ const fromMe = sender
53
+ ? isOwnAccountJid(sender.userJid, options.getMeJid?.(), options.getMeLid?.())
54
+ : false;
55
+ const selfSentChat = fromMe && !isGroup && !isBroadcast
56
+ ? (node.attrs.recipient ?? destinationJid ?? undefined)
57
+ : undefined;
58
+ const chatJid = selfSentChat ? toUserJid(selfSentChat) : fromUserJid;
59
+ const { pushName, ...identity } = extractMessageIdentityAttrs(node.attrs);
60
+ const keyIdentity = selfSentChat ? promoteRecipientAddressing(identity) : identity;
61
+ return {
62
+ pushName,
63
+ key: {
64
+ remoteJid: chatJid ?? '',
65
+ id: node.attrs.id ?? '',
66
+ fromMe,
67
+ isGroup,
68
+ isBroadcast,
69
+ isNewsletter: false,
70
+ ...keyIdentity,
71
+ senderDevice: sender?.device ?? 0,
72
+ ...((isGroup || isBroadcast) && sender ? { participant: sender.userJid } : {})
73
+ }
74
+ };
75
+ }
30
76
  function pickNextRetryCount(node) {
31
77
  const retryNode = findNodeChild(node, 'retry');
32
78
  const parsed = parseOptionalInt(retryNode?.attrs.count);
@@ -240,26 +286,11 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
240
286
  encPayload: decoded.encPayload
241
287
  }
242
288
  };
243
- const chatJid = node.attrs.from ? toUserJid(node.attrs.from) : node.attrs.from;
244
289
  const sender = senderJid ? parseJidFull(senderJid) : null;
245
- const isGroup = chatJid ? isGroupJid(chatJid) : false;
246
- const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
247
- const { pushName, ...keyIdentity } = extractMessageIdentityAttrs(node.attrs);
290
+ const { key, pushName } = buildIncomingMessageKey(node, sender ? { userJid: sender.userJid, device: sender.address.device } : null, options);
248
291
  options.emitIncomingMessage?.({
249
292
  rawNode: buildIncomingEventRawNode(node),
250
- key: {
251
- remoteJid: chatJid ?? '',
252
- id: node.attrs.id ?? '',
253
- fromMe: false,
254
- isGroup,
255
- isBroadcast,
256
- isNewsletter: false,
257
- ...keyIdentity,
258
- ...((isGroup || isBroadcast) && sender?.userJid
259
- ? { participant: sender.userJid }
260
- : {}),
261
- senderDevice: sender?.address.device ?? 0
262
- },
293
+ key,
263
294
  stanzaType: node.attrs.type,
264
295
  offline: node.attrs.offline !== undefined,
265
296
  timestampSeconds: parseOptionalInt(node.attrs.t),
@@ -313,29 +344,14 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
313
344
  }
314
345
  }
315
346
  if (shouldEmitIncomingMessage(message)) {
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;
321
- const isGroup = chatJid ? isGroupJid(chatJid) : false;
322
- const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
323
- const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
324
- const { pushName, ...keyIdentity } = extractMessageIdentityAttrs(node.attrs);
347
+ const { key, pushName } = buildIncomingMessageKey(node, {
348
+ userJid: `${senderAddress.user}@${senderAddress.server}`,
349
+ device: senderAddress.device
350
+ }, options, decodedMessage.deviceSentMessage?.destinationJid ?? undefined);
325
351
  const expirationSeconds = pickIncomingExpirationSeconds(message);
326
352
  options.emitIncomingMessage?.({
327
353
  rawNode: buildIncomingEventRawNode(node),
328
- key: {
329
- remoteJid: chatJid ?? '',
330
- id: node.attrs.id ?? '',
331
- fromMe: false,
332
- isGroup,
333
- isBroadcast,
334
- isNewsletter: false,
335
- ...keyIdentity,
336
- senderDevice: senderAddress.device,
337
- ...(isGroup || isBroadcast ? { participant: senderUserJid } : {})
338
- },
354
+ key,
339
355
  stanzaType: node.attrs.type,
340
356
  offline: node.attrs.offline !== undefined,
341
357
  timestampSeconds: parseOptionalInt(node.attrs.t),
@@ -414,10 +430,15 @@ export async function handleIncomingMessageAck(node, options) {
414
430
  continue;
415
431
  }
416
432
  const encType = child.attrs.type === 'msg' ? 'msg' : 'pkmsg';
417
- result = await decryptAndProcessEncNode(node, child, encType, senderJid, options, (ciphertext, senderAddress) => options.signalProtocol.decryptMessage(senderAddress, {
418
- type: encType,
419
- ciphertext
420
- }));
433
+ result = await decryptAndProcessEncNode(node, child, encType, senderJid, options, (ciphertext, senderAddress) => {
434
+ const sessionJid = canonicalizeOwnAccountJid(senderJid, options.getMeJid?.(), options.getMeLid?.());
435
+ return options.signalProtocol.decryptMessage(sessionJid === senderJid
436
+ ? senderAddress
437
+ : parseSignalAddressFromJid(sessionJid), {
438
+ type: encType,
439
+ ciphertext
440
+ });
441
+ });
421
442
  break;
422
443
  }
423
444
  case 'msmsg': {
@@ -475,6 +496,42 @@ export async function handleIncomingMessageAck(node, options) {
475
496
  await options.sendNode(ackNode);
476
497
  return true;
477
498
  }
499
+ const unavailableNode = findNodeChild(node, 'unavailable');
500
+ if (unavailableNode) {
501
+ const kind = unavailableNode.attrs.hosted === 'true'
502
+ ? 'hosted'
503
+ : unavailableNode.attrs.type === 'view_once'
504
+ ? 'view_once'
505
+ : 'other';
506
+ const senderJid = node.attrs.participant ?? node.attrs.from;
507
+ const sender = senderJid ? parseJidFull(senderJid) : null;
508
+ const { key, pushName } = buildIncomingMessageKey(node, sender ? { userJid: sender.userJid, device: sender.address.device } : null, options);
509
+ options.emitUnavailableMessage?.({
510
+ rawNode: buildIncomingEventRawNode(node),
511
+ key,
512
+ kind,
513
+ stanzaType: node.attrs.type,
514
+ offline: node.attrs.offline !== undefined,
515
+ timestampSeconds: parseOptionalInt(node.attrs.t),
516
+ pushName
517
+ });
518
+ const ackNode = buildAckNode({
519
+ kind: 'message',
520
+ node,
521
+ id,
522
+ to: from,
523
+ from: options.getMeJid?.()
524
+ });
525
+ options.logger.trace('acking unavailable incoming message', {
526
+ id,
527
+ to: from,
528
+ type: ackNode.attrs.type,
529
+ participant: ackNode.attrs.participant,
530
+ unavailableKind: kind
531
+ });
532
+ await options.sendNode(ackNode);
533
+ return true;
534
+ }
478
535
  if (!shouldSendStandardReceipt) {
479
536
  return true;
480
537
  }
@@ -191,6 +191,48 @@ export function toUserJid(jid, options = {}) {
191
191
  : address.server;
192
192
  return `${address.user}@${server}`;
193
193
  }
194
+ /**
195
+ * True when `jid` is the account's own user, matching the `meJid` (pn) or
196
+ * `meLid` (lid) identity device-insensitively. Mirrors WhatsApp Web's
197
+ * `isMeAccount`.
198
+ */
199
+ export function isOwnAccountJid(jid, meJid, meLid) {
200
+ const candidateUser = toUserJid(jid);
201
+ return ((!!meJid && toUserJid(meJid) === candidateUser) ||
202
+ (!!meLid && toUserJid(meLid) === candidateUser));
203
+ }
204
+ /**
205
+ * Rewrites the account's own PN device JID to its LID equivalent so self traffic
206
+ * keys one LID session instead of forking into separate PN and LID ratchets.
207
+ * No-op for other users, already-LID/unparseable JIDs, or unknown identity.
208
+ */
209
+ export function canonicalizeOwnAccountJid(jid, meJid, meLid) {
210
+ if (!meJid || !meLid)
211
+ return jid;
212
+ let address;
213
+ try {
214
+ address = parseSignalAddressFromJid(jid);
215
+ }
216
+ catch {
217
+ return jid;
218
+ }
219
+ if ((address.server ?? WA_DEFAULTS.HOST_DOMAIN) !== WA_DEFAULTS.HOST_DOMAIN)
220
+ return jid;
221
+ let mePnUser;
222
+ let meLidUser;
223
+ try {
224
+ mePnUser = parseSignalAddressFromJid(meJid).user;
225
+ meLidUser = parseSignalAddressFromJid(meLid).user;
226
+ }
227
+ catch {
228
+ return jid;
229
+ }
230
+ if (address.user !== mePnUser)
231
+ return jid;
232
+ return address.device === 0
233
+ ? `${meLidUser}@${WA_DEFAULTS.LID_SERVER}`
234
+ : `${meLidUser}:${address.device}@${WA_DEFAULTS.LID_SERVER}`;
235
+ }
194
236
  /**
195
237
  * Returns the JID in its full device form. JIDs with `device === 0` lose the
196
238
  * device segment (`user@server`); all others keep `user:device@server`.
@@ -17,10 +17,10 @@ const RETRY_REASON_MATCHERS = [
17
17
  },
18
18
  { matches: ['invalid signature'], code: RETRY_REASON.SignalErrorInvalidSignature },
19
19
  {
20
- matches: ['too many messages in future', 'future message'],
20
+ matches: ['too far in future', 'too many messages in future', 'future message'],
21
21
  code: RETRY_REASON.SignalErrorFutureMessage
22
22
  },
23
- { matches: ['invalid mac'], code: RETRY_REASON.SignalErrorBadMac },
23
+ { matches: ['invalid message mac', 'invalid mac'], code: RETRY_REASON.SignalErrorBadMac },
24
24
  { matches: ['invalid session'], code: RETRY_REASON.SignalErrorInvalidSession },
25
25
  { matches: ['invalid message key'], code: RETRY_REASON.SignalErrorInvalidMsgKey },
26
26
  {
@@ -21,8 +21,12 @@ export class WaNodeOrchestrator {
21
21
  return this.pendingQueries.size > 0;
22
22
  }
23
23
  clearPending(reason) {
24
- this.logger.warn('clearing pending node queries', {
25
- count: this.pendingQueries.size,
24
+ const count = this.pendingQueries.size;
25
+ if (count === 0) {
26
+ return;
27
+ }
28
+ this.logger.debug('clearing pending node queries', {
29
+ count,
26
30
  reason: reason.message
27
31
  });
28
32
  for (const pending of this.pendingQueries.values()) {
@@ -32,6 +32,9 @@ function buildMessageAttrs(input) {
32
32
  if (input.addressingMode) {
33
33
  attrs.addressing_mode = input.addressingMode;
34
34
  }
35
+ if (input.peerRecipientPn) {
36
+ attrs.peer_recipient_pn = input.peerRecipientPn;
37
+ }
35
38
  if (input.additionalAttributes) {
36
39
  Object.assign(attrs, input.additionalAttributes);
37
40
  }
@@ -34,6 +34,7 @@ export function buildPresenceSubscribeNode(input) {
34
34
  }
35
35
  return {
36
36
  tag: WA_NODE_TAGS.PRESENCE,
37
- attrs
37
+ attrs,
38
+ ...(input.privacyTokenNode ? { content: [input.privacyTokenNode] } : {})
38
39
  };
39
40
  }
@@ -1,6 +1,6 @@
1
1
  import { WA_DEFAULTS, WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from '../../../protocol/constants.js';
2
2
  import { buildIqNode } from '../../node/query.js';
3
- export function buildGetProfilePictureIq(targetJid, type = 'preview', existingId) {
3
+ export function buildGetProfilePictureIq(targetJid, type = 'preview', existingId, privacyTokenNode) {
4
4
  const pictureAttrs = {
5
5
  type,
6
6
  query: 'url'
@@ -11,7 +11,8 @@ export function buildGetProfilePictureIq(targetJid, type = 'preview', existingId
11
11
  return buildIqNode(WA_IQ_TYPES.GET, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.PROFILE_PICTURE, [
12
12
  {
13
13
  tag: WA_NODE_TAGS.PICTURE,
14
- attrs: pictureAttrs
14
+ attrs: pictureAttrs,
15
+ ...(privacyTokenNode ? { content: [privacyTokenNode] } : {})
15
16
  }
16
17
  ], {
17
18
  target: targetJid
@@ -73,11 +73,9 @@ export function buildRetryReceiptNode(input) {
73
73
  v: RETRY_RECEIPT_VERSION,
74
74
  count: String(input.retryCount),
75
75
  id: input.originalMsgId,
76
- t: input.t
76
+ t: input.t,
77
+ error: String(input.error ?? 0)
77
78
  };
78
- if (input.error !== undefined && input.error !== 0) {
79
- retryAttrs.error = String(input.error);
80
- }
81
79
  const content = [
82
80
  {
83
81
  tag: 'retry',
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { WaClient } from './client';
2
2
  export type { WaClientEventMap, WaClientOptions, WaClientProxyOptions, WaDownloadMediaOptions, WaHistorySyncChunkEvent, WaHistorySyncOptions, WaWriteBehindOptions } from './client/types';
3
3
  export type { WaMessageCoordinator } from './client/coordinators/WaMessageCoordinator';
4
- export type { WaAccountTakeoverNoticeEvent, WaAppStateMutationEvent, WaAppStateMutationSource, WaBusinessEvent, WaBusinessEventAction, WaBusinessProfileResult, WaConnectionEvent, WaGroupEvent, WaGroupEventAction, WaGroupEventLinkedGroup, WaGroupEventMembershipRequest, WaGroupEventParticipant, WaGroupEventSubgroupSuggestion, WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIgnoreStanzaKind, WaIncomingAddonEvent, WaIncomingBaseEvent, WaIncomingBotChunkEvent, WaIncomingCallEvent, WaIncomingChatstateEvent, WaIncomingErrorStanzaEvent, WaIncomingFailureEvent, WaIncomingMessageEvent, WaIncomingMessageKey, WaIncomingNewsletterEvent, WaIncomingNewsletterMessageUpdateEvent, WaIncomingNodeHandler, WaIncomingNodeHandlerRegistration, WaIncomingNotificationEvent, WaIncomingPresenceEvent, WaIncomingProtocolMessageEvent, WaIncomingReceiptEvent, WaIncomingStanzaFilter, WaIncomingUnhandledStanzaEvent, WaMexLidChangeEvent, WaMexMessageCappingEvent, WaMexMessageCappingStatus, WaMexNotificationEvent, WaMexNotificationGraphQlError, WaMexNotificationOperationName, WaMexNotificationUnknownEvent, WaMexOwnUsernameSyncEvent, WaMexTextStatusUpdateEvent, WaMexTextStatusUpdateHintEvent, WaMexUsernameDeleteEvent, WaMexUsernameSetEvent, WaMexUsernameUpdateHintEvent, WaOfflineResumeEvent, WaPictureEvent, WaPictureEventAction, WaPrivacyTokenUpdateEvent, WaReceiptStatus, WaRegistrationCodeEvent, WaSendMessageOptions, WaVerifiedNameResult, WaAddonKind, WaNewsletterEventAction, WaNewsletterMessageUpdate, WaNewsletterPollVoteEntry, WaNewsletterReactionEntry } from './client/types';
4
+ export type { WaAccountTakeoverNoticeEvent, WaAppStateMutationEvent, WaAppStateMutationSource, WaBusinessEvent, WaBusinessEventAction, WaBusinessProfileResult, WaConnectionEvent, WaGroupEvent, WaGroupEventAction, WaGroupEventLinkedGroup, WaGroupEventMembershipRequest, WaGroupEventParticipant, WaGroupEventSubgroupSuggestion, WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIgnoreStanzaKind, WaIncomingAddonEvent, WaIncomingBaseEvent, WaIncomingBotChunkEvent, WaIncomingCallEvent, WaIncomingChatstateEvent, WaIncomingErrorStanzaEvent, WaIncomingFailureEvent, WaIncomingMessageEvent, WaIncomingMessageKey, WaIncomingNewsletterEvent, WaIncomingNewsletterMessageUpdateEvent, WaIncomingNodeHandler, WaIncomingNodeHandlerRegistration, WaIncomingNotificationEvent, WaIncomingPresenceEvent, WaIncomingProtocolMessageEvent, WaIncomingReceiptEvent, WaIncomingStanzaFilter, WaIncomingUnavailableMessageEvent, WaIncomingUnhandledStanzaEvent, WaMexLidChangeEvent, WaMexMessageCappingEvent, WaMexMessageCappingStatus, WaMexNotificationEvent, WaMexNotificationGraphQlError, WaMexNotificationOperationName, WaMexNotificationUnknownEvent, WaMexOwnUsernameSyncEvent, WaMexTextStatusUpdateEvent, WaMexTextStatusUpdateHintEvent, WaMexUsernameDeleteEvent, WaMexUsernameSetEvent, WaMexUsernameUpdateHintEvent, WaOfflineResumeEvent, WaPictureEvent, WaPictureEventAction, WaPrivacyTokenUpdateEvent, WaReceiptStatus, WaRegistrationCodeEvent, WaSendMessageOptions, WaUnavailableMessageKind, WaVerifiedNameResult, WaAddonKind, WaNewsletterEventAction, WaNewsletterMessageUpdate, WaNewsletterPollVoteEntry, WaNewsletterReactionEntry } from './client/types';
5
5
  export type { WaAppStateMutationCoordinator, WaBroadcastListParticipant, WaSetBroadcastListInput, WaSetStatusPrivacyInput } from './client/coordinators/WaAppStateMutationCoordinator';
6
6
  export type { WaBotCoordinator, WaBotInfo, WaBotPosingAsProfessional, WaBotProfileCommand, WaBotProfilePrompt, WaBotProfileResult, WaBotPromptOptions, WaGetBotProfileOptions } from './client/coordinators/WaBotCoordinator';
7
7
  export type { WaBroadcastListCoordinator, WaSendBroadcastListMessageInput } from './client/coordinators/WaBroadcastListCoordinator';
@@ -1,4 +1,4 @@
1
- import type { WaIncomingMessageEvent, WaIncomingNewsletterMessageUpdateEvent, WaIncomingUnhandledStanzaEvent } from '../../client/types';
1
+ import type { WaIncomingMessageEvent, WaIncomingNewsletterMessageUpdateEvent, WaIncomingUnavailableMessageEvent, WaIncomingUnhandledStanzaEvent } from '../../client/types';
2
2
  import type { Logger } from '../../infra/log/types';
3
3
  import { proto } from '../../proto';
4
4
  import type { WaRetryDecryptFailureContext } from '../../retry/types';
@@ -9,11 +9,13 @@ interface WaIncomingMessageAckHandlerOptions {
9
9
  readonly logger: Logger;
10
10
  readonly sendNode: (node: BinaryNode) => Promise<void>;
11
11
  readonly getMeJid?: () => string | null | undefined;
12
+ readonly getMeLid?: () => string | null | undefined;
12
13
  readonly signalProtocol?: SignalProtocol;
13
14
  readonly senderKeyManager?: SenderKeyManager;
14
15
  readonly onDecryptFailure?: (context: WaRetryDecryptFailureContext, error: unknown) => Promise<boolean>;
15
16
  readonly emitIncomingMessage?: (event: WaIncomingMessageEvent) => void;
16
17
  readonly emitNewsletterMessageUpdate?: (event: WaIncomingNewsletterMessageUpdateEvent) => void;
18
+ readonly emitUnavailableMessage?: (event: WaIncomingUnavailableMessageEvent) => void;
17
19
  readonly emitUnhandledStanza?: (event: WaIncomingUnhandledStanzaEvent) => void;
18
20
  }
19
21
  /**