zapo-js 1.1.2 → 1.1.3

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 (34) hide show
  1. package/dist/client/WaClientFactory.js +5 -4
  2. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +7 -3
  3. package/dist/client/coordinators/WaPresenceCoordinator.d.ts +6 -0
  4. package/dist/client/coordinators/WaPresenceCoordinator.js +8 -2
  5. package/dist/client/coordinators/WaProfileCoordinator.d.ts +7 -0
  6. package/dist/client/coordinators/WaProfileCoordinator.js +10 -4
  7. package/dist/client/coordinators/WaRetryCoordinator.js +1 -1
  8. package/dist/client/coordinators/WaTrustedContactTokenCoordinator.d.ts +9 -0
  9. package/dist/client/coordinators/WaTrustedContactTokenCoordinator.js +23 -7
  10. package/dist/esm/client/WaClientFactory.js +6 -5
  11. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +8 -4
  12. package/dist/esm/client/coordinators/WaPresenceCoordinator.js +8 -2
  13. package/dist/esm/client/coordinators/WaProfileCoordinator.js +10 -4
  14. package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -1
  15. package/dist/esm/client/coordinators/WaTrustedContactTokenCoordinator.js +23 -7
  16. package/dist/esm/message/primitives/incoming.js +63 -42
  17. package/dist/esm/protocol/jid.js +42 -0
  18. package/dist/esm/retry/reason.js +2 -2
  19. package/dist/esm/transport/node/WaNodeOrchestrator.js +6 -2
  20. package/dist/esm/transport/node/builders/presence.js +2 -1
  21. package/dist/esm/transport/node/builders/profile.js +3 -2
  22. package/dist/esm/transport/node/builders/retry.js +2 -4
  23. package/dist/message/primitives/incoming.d.ts +1 -0
  24. package/dist/message/primitives/incoming.js +62 -41
  25. package/dist/protocol/jid.d.ts +12 -0
  26. package/dist/protocol/jid.js +44 -0
  27. package/dist/retry/reason.js +2 -2
  28. package/dist/transport/node/WaNodeOrchestrator.js +6 -2
  29. package/dist/transport/node/builders/presence.d.ts +6 -0
  30. package/dist/transport/node/builders/presence.js +2 -1
  31. package/dist/transport/node/builders/profile.d.ts +1 -1
  32. package/dist/transport/node/builders/profile.js +3 -2
  33. package/dist/transport/node/builders/retry.js +2 -4
  34. package/package.json +2 -2
@@ -487,7 +487,8 @@ function buildWaClientDependencies(input) {
487
487
  });
488
488
  const presenceCoordinator = (0, WaPresenceCoordinator_1.createPresenceCoordinator)({
489
489
  sendNode: (node) => nodeOrchestrator.sendNode(node, false),
490
- getCurrentCredentials
490
+ getCurrentCredentials,
491
+ resolvePrivacyTokenNode: (jid) => trustedContactToken.resolveReceiverTokenNode(jid)
491
492
  });
492
493
  const peerDataOperation = (0, peer_data_operation_1.createPeerDataOperationRequester)({
493
494
  logger,
@@ -559,9 +560,7 @@ function buildWaClientDependencies(input) {
559
560
  const credentials = getCurrentCredentials();
560
561
  if (!credentials)
561
562
  return false;
562
- const candidateUser = (0, jid_1.toUserJid)(deviceJid);
563
- return ((!!credentials.meJid && (0, jid_1.toUserJid)(credentials.meJid) === candidateUser) ||
564
- (!!credentials.meLid && (0, jid_1.toUserJid)(credentials.meLid) === candidateUser));
563
+ return (0, jid_1.isOwnAccountJid)(deviceJid, credentials.meJid, credentials.meLid);
565
564
  },
566
565
  sendKeyShare: (toDeviceJid, keys, missingKeyIds) => messageDispatch.sendAppStateSyncKeyShare(toDeviceJid, keys, missingKeyIds),
567
566
  triggerSync: async () => {
@@ -604,6 +603,7 @@ function buildWaClientDependencies(input) {
604
603
  queryLidsByPhoneJids: (phoneJids) => signalDeviceSync.queryLidsByPhoneJids(phoneJids),
605
604
  mutations: appStateMutations,
606
605
  applyOwnPushName,
606
+ resolvePrivacyTokenNode: (jid) => trustedContactToken.resolveReceiverTokenNode(jid),
607
607
  logger
608
608
  });
609
609
  const statusCoordinator = (0, WaStatusCoordinator_1.createStatusCoordinator)({
@@ -684,6 +684,7 @@ function buildWaClientDependencies(input) {
684
684
  logger,
685
685
  sendNode: runtime.sendNode,
686
686
  getMeJid: () => getCurrentCredentials()?.meJid,
687
+ getMeLid: () => getCurrentCredentials()?.meLid,
687
688
  signalProtocol,
688
689
  senderKeyManager,
689
690
  onDecryptFailure: (context, error) => retryCoordinator.onDecryptFailure(context, error),
@@ -68,17 +68,20 @@ class WaMessageDispatchCoordinator {
68
68
  }
69
69
  async publishSignalMessage(input, options = {}) {
70
70
  this.requireCurrentMeJid('publishSignalMessage');
71
- const address = (0, jid_1.parseSignalAddressFromJid)(input.to);
71
+ const credentials = this.deps.getCurrentCredentials();
72
+ const sessionJid = (0, jid_1.canonicalizeOwnAccountJid)(input.to, credentials?.meJid, credentials?.meLid);
73
+ const address = (0, jid_1.parseSignalAddressFromJid)(sessionJid);
72
74
  if (address.server === constants_1.WA_DEFAULTS.GROUP_SERVER) {
73
75
  throw new Error('publishSignalMessage currently supports only direct chats; use sender-key flow for groups');
74
76
  }
75
77
  this.deps.logger.trace('wa client publish signal message', {
76
78
  to: input.to,
79
+ sessionJid,
77
80
  type: input.type
78
81
  });
79
82
  const [paddedPlaintext] = await Promise.all([
80
83
  (0, padding_1.writeRandomPadMax16)(input.plaintext),
81
- this.deps.sessionResolver.ensureSession(address, input.to, input.expectedIdentity)
84
+ this.deps.sessionResolver.ensureSession(address, sessionJid, input.expectedIdentity)
82
85
  ]);
83
86
  const encrypted = await this.deps.signalProtocol.encryptMessage(address, paddedPlaintext, input.expectedIdentity);
84
87
  const messageType = input.type ?? 'text';
@@ -325,7 +328,8 @@ class WaMessageDispatchCoordinator {
325
328
  await this.deps.messageClient.sendReceipt(input);
326
329
  }
327
330
  async publishProtocolMessageToDevice(deviceJid, protocolMessage, options) {
328
- const meJid = this.deps.getCurrentCredentials()?.meJid;
331
+ const credentials = this.deps.getCurrentCredentials();
332
+ const meJid = credentials?.meJid;
329
333
  const meParsed = meJid ? (0, jid_1.parseJidFull)(meJid) : undefined;
330
334
  const meUserJid = meParsed?.userJid;
331
335
  let senderIcdc = null;
@@ -21,6 +21,12 @@ export interface WaPresenceCoordinator {
21
21
  interface WaPresenceCoordinatorOptions {
22
22
  readonly sendNode: (node: BinaryNode) => Promise<void>;
23
23
  readonly getCurrentCredentials: () => WaAuthCredentials | null;
24
+ /**
25
+ * Resolves the receiver-mode `<tctoken>` node for a contact, echoed back
26
+ * on a user presence subscription to unlock the target's presence
27
+ * visibility. Returns `null` when no valid token is held.
28
+ */
29
+ readonly resolvePrivacyTokenNode: (jid: string) => Promise<BinaryNode | null>;
24
30
  }
25
31
  /** Builds a {@link WaPresenceCoordinator} from its node-send dependency. */
26
32
  export declare function createPresenceCoordinator(options: WaPresenceCoordinatorOptions): WaPresenceCoordinator;
@@ -1,11 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createPresenceCoordinator = createPresenceCoordinator;
4
+ const jid_1 = require("../../protocol/jid");
4
5
  const chatstate_1 = require("../../transport/node/builders/chatstate");
5
6
  const presence_1 = require("../../transport/node/builders/presence");
6
7
  /** Builds a {@link WaPresenceCoordinator} from its node-send dependency. */
7
8
  function createPresenceCoordinator(options) {
8
- const { sendNode, getCurrentCredentials } = options;
9
+ const { sendNode, getCurrentCredentials, resolvePrivacyTokenNode } = options;
9
10
  return {
10
11
  send: async (type) => {
11
12
  const credentials = getCurrentCredentials();
@@ -15,7 +16,12 @@ function createPresenceCoordinator(options) {
15
16
  await sendNode((0, chatstate_1.buildChatstateNode)({ jid, ...opts }));
16
17
  },
17
18
  subscribe: async (jid, opts) => {
18
- await sendNode((0, presence_1.buildPresenceSubscribeNode)({ jid, ...opts }));
19
+ const privacyTokenNode = (0, jid_1.isGroupJid)(jid) ? null : await resolvePrivacyTokenNode(jid);
20
+ await sendNode((0, presence_1.buildPresenceSubscribeNode)({
21
+ jid,
22
+ ...opts,
23
+ ...(privacyTokenNode ? { privacyTokenNode } : {})
24
+ }));
19
25
  }
20
26
  };
21
27
  }
@@ -149,6 +149,13 @@ interface WaProfileCoordinatorOptions {
149
149
  * idempotent (a no-op when the name already matches).
150
150
  */
151
151
  readonly applyOwnPushName: (name: string) => Promise<void>;
152
+ /**
153
+ * Resolves the receiver-mode `<tctoken>` node for a contact, echoed back on
154
+ * privacy-gated profile queries (picture get, about/status usync) to prove
155
+ * this account is a trusted contact. Returns `null` when no valid token is
156
+ * held for the JID.
157
+ */
158
+ readonly resolvePrivacyTokenNode: (jid: string) => Promise<BinaryNode | null>;
152
159
  readonly logger: Logger;
153
160
  }
154
161
  /** Builds a {@link WaProfileCoordinator} from its IQ/MEX/SID dependencies. */
@@ -195,10 +195,11 @@ function buildTextStatusMutationInput(input) {
195
195
  }
196
196
  /** Builds a {@link WaProfileCoordinator} from its IQ/MEX/SID dependencies. */
197
197
  function createProfileCoordinator(options) {
198
- const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, logger } = options;
198
+ const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, resolvePrivacyTokenNode, logger } = options;
199
199
  return {
200
200
  getProfilePicture: async (jid, type, existingId) => {
201
- const node = (0, profile_1.buildGetProfilePictureIq)(jid, type, existingId);
201
+ const privacyTokenNode = (await resolvePrivacyTokenNode(jid)) ?? undefined;
202
+ const node = (0, profile_1.buildGetProfilePictureIq)(jid, type, existingId, privacyTokenNode);
202
203
  const result = await queryWithContext('profile.getPicture', node, undefined, {
203
204
  jid,
204
205
  type: type ?? 'preview'
@@ -225,10 +226,11 @@ function createProfileCoordinator(options) {
225
226
  getStatus: async (jid) => {
226
227
  const sid = await generateSid();
227
228
  const queryNodes = (0, profile_1.buildGetStatusUsyncQueryNodes)();
229
+ const privacyTokenNode = await resolvePrivacyTokenNode(jid);
228
230
  const usyncNode = (0, usync_1.buildUsyncIq)({
229
231
  sid,
230
232
  queryProtocolNodes: [queryNodes[1]],
231
- users: [{ jid }]
233
+ users: [{ jid, ...(privacyTokenNode ? { content: [privacyTokenNode] } : {}) }]
232
234
  });
233
235
  const result = await queryWithContext('profile.getStatus', usyncNode, undefined, {
234
236
  jid
@@ -256,10 +258,14 @@ function createProfileCoordinator(options) {
256
258
  }
257
259
  const sid = await generateSid();
258
260
  const queryProtocolNodes = (0, profile_1.buildGetStatusUsyncQueryNodes)();
261
+ const users = await Promise.all(jids.map(async (jid) => {
262
+ const privacyTokenNode = await resolvePrivacyTokenNode(jid);
263
+ return { jid, ...(privacyTokenNode ? { content: [privacyTokenNode] } : {}) };
264
+ }));
259
265
  const usyncNode = (0, usync_1.buildUsyncIq)({
260
266
  sid,
261
267
  queryProtocolNodes,
262
- users: jids.map((jid) => ({ jid }))
268
+ users
263
269
  });
264
270
  const result = await queryWithContext('profile.getProfiles', usyncNode, undefined, {
265
271
  count: jids.length
@@ -502,7 +502,7 @@ class WaRetryCoordinator {
502
502
  id: request.keyBundle.key.id,
503
503
  publicKey: request.keyBundle.key.publicKey
504
504
  }
505
- });
505
+ }, { reuseExisting: true });
506
506
  return this.applySessionBaseKeyPolicy(request, requesterJid, requesterAddress, requesterNormalizedDeviceJid);
507
507
  }
508
508
  const sessionStillExists = currentSession !== null && !regIdMismatch;
@@ -41,6 +41,15 @@ export declare class WaTrustedContactTokenCoordinator {
41
41
  readonly getConfigOverrides?: () => Partial<WaTrustedContactTokenConfig>;
42
42
  });
43
43
  private resolveConfig;
44
+ /**
45
+ * Resolves the receiver-mode `<tctoken>` node to echo back on outbound
46
+ * queries against a contact's privacy-gated data (presence subscribe,
47
+ * profile-picture get, about/status usync). Returns the token only when a
48
+ * non-expired receiver token exists for `jid`; unlike
49
+ * {@link resolveTokenForMessage} it does **not** fall back to a `<cstoken>`
50
+ * (the gated query flows attach the trusted-contact token only).
51
+ */
52
+ resolveReceiverTokenNode(jid: string): Promise<BinaryNode | null>;
44
53
  resolveTokenForMessage(recipientJid: string): Promise<BinaryNode | null>;
45
54
  handleIncomingToken(fromJid: string, tokens: readonly ParsedPrivacyToken[]): Promise<void>;
46
55
  maybeIssueSenderToken(recipientJid: string): Promise<void>;
@@ -42,14 +42,30 @@ class WaTrustedContactTokenCoordinator {
42
42
  maxDurationS
43
43
  };
44
44
  }
45
- async resolveTokenForMessage(recipientJid) {
46
- const record = await this.store.getByJid(recipientJid);
47
- const nowS = this.serverClock.nowSeconds();
45
+ /**
46
+ * Resolves the receiver-mode `<tctoken>` node to echo back on outbound
47
+ * queries against a contact's privacy-gated data (presence subscribe,
48
+ * profile-picture get, about/status usync). Returns the token only when a
49
+ * non-expired receiver token exists for `jid`; unlike
50
+ * {@link resolveTokenForMessage} it does **not** fall back to a `<cstoken>`
51
+ * (the gated query flows attach the trusted-contact token only).
52
+ */
53
+ async resolveReceiverTokenNode(jid) {
54
+ const record = await this.store.getByJid(jid);
55
+ if (!record?.tcToken || record.tcTokenTimestamp === undefined) {
56
+ return null;
57
+ }
48
58
  const config = this.resolveConfig();
49
- if (record?.tcToken &&
50
- record.tcTokenTimestamp !== undefined &&
51
- !(0, tc_token_1.isTokenExpired)(record.tcTokenTimestamp, nowS, config.durationS, config.numBuckets)) {
52
- return (0, privacy_token_2.buildTcTokenMessageNode)(record.tcToken);
59
+ const nowS = this.serverClock.nowSeconds();
60
+ if ((0, tc_token_1.isTokenExpired)(record.tcTokenTimestamp, nowS, config.durationS, config.numBuckets)) {
61
+ return null;
62
+ }
63
+ return (0, privacy_token_2.buildTcTokenMessageNode)(record.tcToken);
64
+ }
65
+ async resolveTokenForMessage(recipientJid) {
66
+ const tcTokenNode = await this.resolveReceiverTokenNode(recipientJid);
67
+ if (tcTokenNode) {
68
+ return tcTokenNode;
53
69
  }
54
70
  const nctSalt = await this.getNctSalt();
55
71
  if (!nctSalt) {
@@ -39,7 +39,7 @@ import { handleIncomingMessageAck } from '../message/primitives/incoming.js';
39
39
  import { createPeerDataOperationRequester } from '../message/primitives/peer-data-operation.js';
40
40
  import { WaMessageClient } from '../message/WaMessageClient.js';
41
41
  import { getWaCompanionPlatformId, WA_DEFAULTS, WA_DISCONNECT_REASONS, WA_NEWSLETTER_NOTIFICATION_TAGS, WA_NODE_TAGS, WA_NOTIFICATION_TYPES, WA_PRIVACY_TOKEN_NOTIFICATION_TYPE } from '../protocol/constants.js';
42
- import { isNewsletterJid, parseSignalAddressFromJid, toUserJid } from '../protocol/jid.js';
42
+ import { isNewsletterJid, isOwnAccountJid, parseSignalAddressFromJid, toUserJid } from '../protocol/jid.js';
43
43
  import { WA_PRESENCE_TYPES } from '../protocol/presence.js';
44
44
  import { createOutboundRetryTracker } from '../retry/tracker.js';
45
45
  import { SignalDeviceSyncApi } from '../signal/api/SignalDeviceSyncApi.js';
@@ -483,7 +483,8 @@ export function buildWaClientDependencies(input) {
483
483
  });
484
484
  const presenceCoordinator = createPresenceCoordinator({
485
485
  sendNode: (node) => nodeOrchestrator.sendNode(node, false),
486
- getCurrentCredentials
486
+ getCurrentCredentials,
487
+ resolvePrivacyTokenNode: (jid) => trustedContactToken.resolveReceiverTokenNode(jid)
487
488
  });
488
489
  const peerDataOperation = createPeerDataOperationRequester({
489
490
  logger,
@@ -555,9 +556,7 @@ export function buildWaClientDependencies(input) {
555
556
  const credentials = getCurrentCredentials();
556
557
  if (!credentials)
557
558
  return false;
558
- const candidateUser = toUserJid(deviceJid);
559
- return ((!!credentials.meJid && toUserJid(credentials.meJid) === candidateUser) ||
560
- (!!credentials.meLid && toUserJid(credentials.meLid) === candidateUser));
559
+ return isOwnAccountJid(deviceJid, credentials.meJid, credentials.meLid);
561
560
  },
562
561
  sendKeyShare: (toDeviceJid, keys, missingKeyIds) => messageDispatch.sendAppStateSyncKeyShare(toDeviceJid, keys, missingKeyIds),
563
562
  triggerSync: async () => {
@@ -600,6 +599,7 @@ export function buildWaClientDependencies(input) {
600
599
  queryLidsByPhoneJids: (phoneJids) => signalDeviceSync.queryLidsByPhoneJids(phoneJids),
601
600
  mutations: appStateMutations,
602
601
  applyOwnPushName,
602
+ resolvePrivacyTokenNode: (jid) => trustedContactToken.resolveReceiverTokenNode(jid),
603
603
  logger
604
604
  });
605
605
  const statusCoordinator = createStatusCoordinator({
@@ -680,6 +680,7 @@ export function buildWaClientDependencies(input) {
680
680
  logger,
681
681
  sendNode: runtime.sendNode,
682
682
  getMeJid: () => getCurrentCredentials()?.meJid,
683
+ getMeLid: () => getCurrentCredentials()?.meLid,
683
684
  signalProtocol,
684
685
  senderKeyManager,
685
686
  onDecryptFailure: (context, error) => retryCoordinator.onDecryptFailure(context, error),
@@ -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, 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';
@@ -322,7 +325,8 @@ export class WaMessageDispatchCoordinator {
322
325
  await this.deps.messageClient.sendReceipt(input);
323
326
  }
324
327
  async publishProtocolMessageToDevice(deviceJid, protocolMessage, options) {
325
- const meJid = this.deps.getCurrentCredentials()?.meJid;
328
+ const credentials = this.deps.getCurrentCredentials();
329
+ const meJid = credentials?.meJid;
326
330
  const meParsed = meJid ? parseJidFull(meJid) : undefined;
327
331
  const meUserJid = meParsed?.userJid;
328
332
  let senderIcdc = null;
@@ -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) {
@@ -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': {
@@ -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()) {
@@ -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',
@@ -9,6 +9,7 @@ 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>;
@@ -31,6 +31,52 @@ function extractMessageIdentityAttrs(attrs) {
31
31
  pushName: attrs.notify
32
32
  };
33
33
  }
34
+ /**
35
+ * Self-authored 1:1 chat is the recipient, so its alternate addressing is the
36
+ * `recipient*` attrs (the `sender*` attrs describe me). Promotes `recipientAlt`
37
+ * to `remoteJidAlt` and drops the stale sender/recipient fields.
38
+ */
39
+ function promoteRecipientAddressing(identity) {
40
+ return {
41
+ ...(identity.recipientAlt ? { remoteJidAlt: identity.recipientAlt } : {}),
42
+ ...(identity.senderUsername !== undefined
43
+ ? { senderUsername: identity.senderUsername }
44
+ : {})
45
+ };
46
+ }
47
+ /**
48
+ * Resolves the {@link WaIncomingMessageKey} for a decrypted stanza. `remoteJid`
49
+ * is the chat: the `from`, or the recipient (`recipient` attr, then the
50
+ * deviceSentMessage `destinationJid`) when the message is `fromMe`.
51
+ */
52
+ function buildIncomingMessageKey(node, sender, options, destinationJid) {
53
+ const fromUserJid = node.attrs.from ? (0, jid_1.toUserJid)(node.attrs.from) : node.attrs.from;
54
+ const isGroup = fromUserJid ? (0, jid_1.isGroupJid)(fromUserJid) : false;
55
+ const isBroadcast = fromUserJid ? (0, jid_1.isBroadcastJid)(fromUserJid) : false;
56
+ const fromMe = sender
57
+ ? (0, jid_1.isOwnAccountJid)(sender.userJid, options.getMeJid?.(), options.getMeLid?.())
58
+ : false;
59
+ const selfSentChat = fromMe && !isGroup && !isBroadcast
60
+ ? (node.attrs.recipient ?? destinationJid ?? undefined)
61
+ : undefined;
62
+ const chatJid = selfSentChat ? (0, jid_1.toUserJid)(selfSentChat) : fromUserJid;
63
+ const { pushName, ...identity } = extractMessageIdentityAttrs(node.attrs);
64
+ const keyIdentity = selfSentChat ? promoteRecipientAddressing(identity) : identity;
65
+ return {
66
+ pushName,
67
+ key: {
68
+ remoteJid: chatJid ?? '',
69
+ id: node.attrs.id ?? '',
70
+ fromMe,
71
+ isGroup,
72
+ isBroadcast,
73
+ isNewsletter: false,
74
+ ...keyIdentity,
75
+ senderDevice: sender?.device ?? 0,
76
+ ...((isGroup || isBroadcast) && sender ? { participant: sender.userJid } : {})
77
+ }
78
+ };
79
+ }
34
80
  function pickNextRetryCount(node) {
35
81
  const retryNode = (0, helpers_1.findNodeChild)(node, 'retry');
36
82
  const parsed = (0, primitives_1.parseOptionalInt)(retryNode?.attrs.count);
@@ -244,26 +290,11 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
244
290
  encPayload: decoded.encPayload
245
291
  }
246
292
  };
247
- const chatJid = node.attrs.from ? (0, jid_1.toUserJid)(node.attrs.from) : node.attrs.from;
248
293
  const sender = senderJid ? (0, jid_1.parseJidFull)(senderJid) : null;
249
- const isGroup = chatJid ? (0, jid_1.isGroupJid)(chatJid) : false;
250
- const isBroadcast = chatJid ? (0, jid_1.isBroadcastJid)(chatJid) : false;
251
- const { pushName, ...keyIdentity } = extractMessageIdentityAttrs(node.attrs);
294
+ const { key, pushName } = buildIncomingMessageKey(node, sender ? { userJid: sender.userJid, device: sender.address.device } : null, options);
252
295
  options.emitIncomingMessage?.({
253
296
  rawNode: buildIncomingEventRawNode(node),
254
- key: {
255
- remoteJid: chatJid ?? '',
256
- id: node.attrs.id ?? '',
257
- fromMe: false,
258
- isGroup,
259
- isBroadcast,
260
- isNewsletter: false,
261
- ...keyIdentity,
262
- ...((isGroup || isBroadcast) && sender?.userJid
263
- ? { participant: sender.userJid }
264
- : {}),
265
- senderDevice: sender?.address.device ?? 0
266
- },
297
+ key,
267
298
  stanzaType: node.attrs.type,
268
299
  offline: node.attrs.offline !== undefined,
269
300
  timestampSeconds: (0, primitives_1.parseOptionalInt)(node.attrs.t),
@@ -317,29 +348,14 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
317
348
  }
318
349
  }
319
350
  if (shouldEmitIncomingMessage(message)) {
320
- // remoteJid is the chat identity, which is deviceless: the device
321
- // lives in senderDevice (from senderAddress), so strip any `:device`
322
- // segment the `from` attr carries for 1:1 chats.
323
- const fromAttr = node.attrs.from;
324
- const chatJid = fromAttr ? (0, jid_1.toUserJid)(fromAttr) : fromAttr;
325
- const isGroup = chatJid ? (0, jid_1.isGroupJid)(chatJid) : false;
326
- const isBroadcast = chatJid ? (0, jid_1.isBroadcastJid)(chatJid) : false;
327
- const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
328
- const { pushName, ...keyIdentity } = extractMessageIdentityAttrs(node.attrs);
351
+ const { key, pushName } = buildIncomingMessageKey(node, {
352
+ userJid: `${senderAddress.user}@${senderAddress.server}`,
353
+ device: senderAddress.device
354
+ }, options, decodedMessage.deviceSentMessage?.destinationJid ?? undefined);
329
355
  const expirationSeconds = (0, context_info_1.pickIncomingExpirationSeconds)(message);
330
356
  options.emitIncomingMessage?.({
331
357
  rawNode: buildIncomingEventRawNode(node),
332
- key: {
333
- remoteJid: chatJid ?? '',
334
- id: node.attrs.id ?? '',
335
- fromMe: false,
336
- isGroup,
337
- isBroadcast,
338
- isNewsletter: false,
339
- ...keyIdentity,
340
- senderDevice: senderAddress.device,
341
- ...(isGroup || isBroadcast ? { participant: senderUserJid } : {})
342
- },
358
+ key,
343
359
  stanzaType: node.attrs.type,
344
360
  offline: node.attrs.offline !== undefined,
345
361
  timestampSeconds: (0, primitives_1.parseOptionalInt)(node.attrs.t),
@@ -418,10 +434,15 @@ async function handleIncomingMessageAck(node, options) {
418
434
  continue;
419
435
  }
420
436
  const encType = child.attrs.type === 'msg' ? 'msg' : 'pkmsg';
421
- result = await decryptAndProcessEncNode(node, child, encType, senderJid, options, (ciphertext, senderAddress) => options.signalProtocol.decryptMessage(senderAddress, {
422
- type: encType,
423
- ciphertext
424
- }));
437
+ result = await decryptAndProcessEncNode(node, child, encType, senderJid, options, (ciphertext, senderAddress) => {
438
+ const sessionJid = (0, jid_1.canonicalizeOwnAccountJid)(senderJid, options.getMeJid?.(), options.getMeLid?.());
439
+ return options.signalProtocol.decryptMessage(sessionJid === senderJid
440
+ ? senderAddress
441
+ : (0, jid_1.parseSignalAddressFromJid)(sessionJid), {
442
+ type: encType,
443
+ ciphertext
444
+ });
445
+ });
425
446
  break;
426
447
  }
427
448
  case 'msmsg': {
@@ -66,6 +66,18 @@ export declare function toUserJid(jid: string, options?: {
66
66
  readonly canonicalizeSignalServer?: boolean;
67
67
  readonly hostDomain?: string;
68
68
  }): string;
69
+ /**
70
+ * True when `jid` is the account's own user, matching the `meJid` (pn) or
71
+ * `meLid` (lid) identity device-insensitively. Mirrors WhatsApp Web's
72
+ * `isMeAccount`.
73
+ */
74
+ export declare function isOwnAccountJid(jid: string, meJid: string | null | undefined, meLid: string | null | undefined): boolean;
75
+ /**
76
+ * Rewrites the account's own PN device JID to its LID equivalent so self traffic
77
+ * keys one LID session instead of forking into separate PN and LID ratchets.
78
+ * No-op for other users, already-LID/unparseable JIDs, or unknown identity.
79
+ */
80
+ export declare function canonicalizeOwnAccountJid(jid: string, meJid: string | null | undefined, meLid: string | null | undefined): string;
69
81
  /**
70
82
  * Returns the JID in its full device form. JIDs with `device === 0` lose the
71
83
  * device segment (`user@server`); all others keep `user:device@server`.
@@ -15,6 +15,8 @@ exports.parseJidFull = parseJidFull;
15
15
  exports.canonicalizeSignalServer = canonicalizeSignalServer;
16
16
  exports.canonicalizeSignalJid = canonicalizeSignalJid;
17
17
  exports.toUserJid = toUserJid;
18
+ exports.isOwnAccountJid = isOwnAccountJid;
19
+ exports.canonicalizeOwnAccountJid = canonicalizeOwnAccountJid;
18
20
  exports.normalizeDeviceJid = normalizeDeviceJid;
19
21
  exports.applyDeviceToJid = applyDeviceToJid;
20
22
  exports.isHostedDeviceId = isHostedDeviceId;
@@ -217,6 +219,48 @@ function toUserJid(jid, options = {}) {
217
219
  : address.server;
218
220
  return `${address.user}@${server}`;
219
221
  }
222
+ /**
223
+ * True when `jid` is the account's own user, matching the `meJid` (pn) or
224
+ * `meLid` (lid) identity device-insensitively. Mirrors WhatsApp Web's
225
+ * `isMeAccount`.
226
+ */
227
+ function isOwnAccountJid(jid, meJid, meLid) {
228
+ const candidateUser = toUserJid(jid);
229
+ return ((!!meJid && toUserJid(meJid) === candidateUser) ||
230
+ (!!meLid && toUserJid(meLid) === candidateUser));
231
+ }
232
+ /**
233
+ * Rewrites the account's own PN device JID to its LID equivalent so self traffic
234
+ * keys one LID session instead of forking into separate PN and LID ratchets.
235
+ * No-op for other users, already-LID/unparseable JIDs, or unknown identity.
236
+ */
237
+ function canonicalizeOwnAccountJid(jid, meJid, meLid) {
238
+ if (!meJid || !meLid)
239
+ return jid;
240
+ let address;
241
+ try {
242
+ address = parseSignalAddressFromJid(jid);
243
+ }
244
+ catch {
245
+ return jid;
246
+ }
247
+ if ((address.server ?? constants_1.WA_DEFAULTS.HOST_DOMAIN) !== constants_1.WA_DEFAULTS.HOST_DOMAIN)
248
+ return jid;
249
+ let mePnUser;
250
+ let meLidUser;
251
+ try {
252
+ mePnUser = parseSignalAddressFromJid(meJid).user;
253
+ meLidUser = parseSignalAddressFromJid(meLid).user;
254
+ }
255
+ catch {
256
+ return jid;
257
+ }
258
+ if (address.user !== mePnUser)
259
+ return jid;
260
+ return address.device === 0
261
+ ? `${meLidUser}@${constants_1.WA_DEFAULTS.LID_SERVER}`
262
+ : `${meLidUser}:${address.device}@${constants_1.WA_DEFAULTS.LID_SERVER}`;
263
+ }
220
264
  /**
221
265
  * Returns the JID in its full device form. JIDs with `device === 0` lose the
222
266
  * device segment (`user@server`); all others keep `user:device@server`.
@@ -20,10 +20,10 @@ const RETRY_REASON_MATCHERS = [
20
20
  },
21
21
  { matches: ['invalid signature'], code: constants_1.RETRY_REASON.SignalErrorInvalidSignature },
22
22
  {
23
- matches: ['too many messages in future', 'future message'],
23
+ matches: ['too far in future', 'too many messages in future', 'future message'],
24
24
  code: constants_1.RETRY_REASON.SignalErrorFutureMessage
25
25
  },
26
- { matches: ['invalid mac'], code: constants_1.RETRY_REASON.SignalErrorBadMac },
26
+ { matches: ['invalid message mac', 'invalid mac'], code: constants_1.RETRY_REASON.SignalErrorBadMac },
27
27
  { matches: ['invalid session'], code: constants_1.RETRY_REASON.SignalErrorInvalidSession },
28
28
  { matches: ['invalid message key'], code: constants_1.RETRY_REASON.SignalErrorInvalidMsgKey },
29
29
  {
@@ -24,8 +24,12 @@ class WaNodeOrchestrator {
24
24
  return this.pendingQueries.size > 0;
25
25
  }
26
26
  clearPending(reason) {
27
- this.logger.warn('clearing pending node queries', {
28
- count: this.pendingQueries.size,
27
+ const count = this.pendingQueries.size;
28
+ if (count === 0) {
29
+ return;
30
+ }
31
+ this.logger.debug('clearing pending node queries', {
32
+ count,
29
33
  reason: reason.message
30
34
  });
31
35
  for (const pending of this.pendingQueries.values()) {
@@ -9,5 +9,11 @@ export interface BuildPresenceSubscribeNodeInput {
9
9
  readonly jid: string;
10
10
  readonly name?: string;
11
11
  readonly context?: string;
12
+ /**
13
+ * Receiver-mode `<tctoken>` node echoed back to prove this account is a
14
+ * trusted contact, gating the target's presence/last-seen visibility.
15
+ * Attached as a child of the `<presence>` stanza when present.
16
+ */
17
+ readonly privacyTokenNode?: BinaryNode;
12
18
  }
13
19
  export declare function buildPresenceSubscribeNode(input: BuildPresenceSubscribeNodeInput): BinaryNode;
@@ -38,6 +38,7 @@ function buildPresenceSubscribeNode(input) {
38
38
  }
39
39
  return {
40
40
  tag: nodes_1.WA_NODE_TAGS.PRESENCE,
41
- attrs
41
+ attrs,
42
+ ...(input.privacyTokenNode ? { content: [input.privacyTokenNode] } : {})
42
43
  };
43
44
  }
@@ -1,6 +1,6 @@
1
1
  import type { BinaryNode } from '../../types';
2
2
  export type WaProfilePictureType = 'preview' | 'image';
3
- export declare function buildGetProfilePictureIq(targetJid: string, type?: WaProfilePictureType, existingId?: string): BinaryNode;
3
+ export declare function buildGetProfilePictureIq(targetJid: string, type?: WaProfilePictureType, existingId?: string, privacyTokenNode?: BinaryNode): BinaryNode;
4
4
  export declare function buildSetProfilePictureIq(imageBytes: Uint8Array, targetJid?: string): BinaryNode;
5
5
  export declare function buildDeleteProfilePictureIq(targetJid?: string): BinaryNode;
6
6
  export declare function buildSetStatusIq(text: string): BinaryNode;
@@ -11,7 +11,7 @@ exports.buildGetUsernameUsyncQueryNode = buildGetUsernameUsyncQueryNode;
11
11
  exports.buildGetStatusUsyncQueryNodes = buildGetStatusUsyncQueryNodes;
12
12
  const constants_1 = require("../../../protocol/constants");
13
13
  const query_1 = require("../../node/query");
14
- function buildGetProfilePictureIq(targetJid, type = 'preview', existingId) {
14
+ function buildGetProfilePictureIq(targetJid, type = 'preview', existingId, privacyTokenNode) {
15
15
  const pictureAttrs = {
16
16
  type,
17
17
  query: 'url'
@@ -22,7 +22,8 @@ function buildGetProfilePictureIq(targetJid, type = 'preview', existingId) {
22
22
  return (0, query_1.buildIqNode)(constants_1.WA_IQ_TYPES.GET, constants_1.WA_DEFAULTS.HOST_DOMAIN, constants_1.WA_XMLNS.PROFILE_PICTURE, [
23
23
  {
24
24
  tag: constants_1.WA_NODE_TAGS.PICTURE,
25
- attrs: pictureAttrs
25
+ attrs: pictureAttrs,
26
+ ...(privacyTokenNode ? { content: [privacyTokenNode] } : {})
26
27
  }
27
28
  ], {
28
29
  target: targetJid
@@ -76,11 +76,9 @@ function buildRetryReceiptNode(input) {
76
76
  v: constants_2.RETRY_RECEIPT_VERSION,
77
77
  count: String(input.retryCount),
78
78
  id: input.originalMsgId,
79
- t: input.t
79
+ t: input.t,
80
+ error: String(input.error ?? 0)
80
81
  };
81
- if (input.error !== undefined && input.error !== 0) {
82
- retryAttrs.error = String(input.error);
83
- }
84
82
  const content = [
85
83
  {
86
84
  tag: 'retry',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.1.2",
3
+ "version": "1.1.3",
4
4
  "description": "High-performance WhatsApp Web TypeScript library",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",
@@ -124,7 +124,7 @@
124
124
  },
125
125
  "scripts": {
126
126
  "preinstall": "node ./scripts/check-node-version.cjs",
127
- "prepack": "npm run build",
127
+ "prepare": "npm run build",
128
128
  "changeset": "changeset",
129
129
  "changeset:status": "changeset status --verbose",
130
130
  "version:packages": "changeset version",