zapo-js 1.6.0 → 1.6.1

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.
@@ -375,7 +375,8 @@ function buildWaClientDependencies(input) {
375
375
  logger
376
376
  });
377
377
  const privacyCoordinator = (0, WaPrivacyCoordinator_1.createPrivacyCoordinator)({
378
- queryWithContext: runtime.queryWithContext
378
+ queryWithContext: runtime.queryWithContext,
379
+ resolveUserJidPair: (userJid) => signalDeviceSync.resolveUserJidPair(userJid)
379
380
  });
380
381
  const businessCoordinator = (0, WaBusinessCoordinator_1.createBusinessCoordinator)({
381
382
  queryWithContext: runtime.queryWithContext,
@@ -80,8 +80,8 @@ export declare class WaMessageDispatchCoordinator {
80
80
  sendMessage(to: string, content: WaSendMessageContent, options?: WaSendMessageOptions): Promise<WaMessagePublishResult>;
81
81
  /**
82
82
  * For a 1:1 recipient passed in PN form, returns the LID-addressed user JID
83
- * (cache-first; falls back to a one-shot `queryLidsByPhoneJids`). Switching
84
- * to LID before fanout ensures the envelope, eligible-requester list, and
83
+ * (via {@link SignalDeviceSyncApi.resolveUserJidPair}). Switching to LID
84
+ * before fanout ensures the envelope, eligible-requester list, and
85
85
  * retry-receipt addressing all agree, which keeps the retry tracker from
86
86
  * rejecting receipts that arrive in LID form. Returns the original PN if
87
87
  * no LID is known/resolvable. Inputs already in LID form pass through.
@@ -300,8 +300,8 @@ class WaMessageDispatchCoordinator {
300
300
  }
301
301
  /**
302
302
  * For a 1:1 recipient passed in PN form, returns the LID-addressed user JID
303
- * (cache-first; falls back to a one-shot `queryLidsByPhoneJids`). Switching
304
- * to LID before fanout ensures the envelope, eligible-requester list, and
303
+ * (via {@link SignalDeviceSyncApi.resolveUserJidPair}). Switching to LID
304
+ * before fanout ensures the envelope, eligible-requester list, and
305
305
  * retry-receipt addressing all agree, which keeps the retry tracker from
306
306
  * rejecting receipts that arrive in LID form. Returns the original PN if
307
307
  * no LID is known/resolvable. Inputs already in LID form pass through.
@@ -309,26 +309,8 @@ class WaMessageDispatchCoordinator {
309
309
  async resolveDirectRecipientLid(pnUserJid) {
310
310
  if ((0, jid_1.isLidJid)(pnUserJid))
311
311
  return pnUserJid;
312
- const cached = await this.deps.deviceListStore.findByAnyUserJid(pnUserJid);
313
- if (cached) {
314
- if ((0, jid_1.isLidJid)(cached.userJid))
315
- return cached.userJid;
316
- if (cached.altUserJid && (0, jid_1.isLidJid)(cached.altUserJid))
317
- return cached.altUserJid;
318
- }
319
- try {
320
- const results = await this.deps.signalDeviceSync.queryLidsByPhoneJids([pnUserJid]);
321
- const match = results.find((entry) => entry.queriedJid === pnUserJid);
322
- if (match?.lidJid)
323
- return match.lidJid;
324
- }
325
- catch (error) {
326
- this.deps.logger.debug('lid resolution failed for direct recipient', {
327
- pnUserJid,
328
- message: (0, primitives_2.toError)(error).message
329
- });
330
- }
331
- return pnUserJid;
312
+ const pair = await this.deps.signalDeviceSync.resolveUserJidPair(pnUserJid);
313
+ return pair.lidJid ?? pnUserJid;
332
314
  }
333
315
  /**
334
316
  * Resolves the `peer_recipient_pn` cross-reference for a 1:1 send, or
@@ -344,20 +326,8 @@ class WaMessageDispatchCoordinator {
344
326
  return undefined;
345
327
  if ((0, jid_1.isUserJid)(recipientUserJid))
346
328
  return recipientUserJid;
347
- try {
348
- const snapshot = await this.deps.deviceListStore.findByAnyUserJid(directRecipientJid);
349
- if (snapshot?.userJid && (0, jid_1.isUserJid)(snapshot.userJid))
350
- return snapshot.userJid;
351
- if (snapshot?.altUserJid && (0, jid_1.isUserJid)(snapshot.altUserJid))
352
- return snapshot.altUserJid;
353
- }
354
- catch (error) {
355
- this.deps.logger.trace('peer_recipient_pn store lookup failed', {
356
- lid: directRecipientJid,
357
- message: (0, primitives_2.toError)(error).message
358
- });
359
- }
360
- return undefined;
329
+ const pair = await this.deps.signalDeviceSync.resolveUserJidPair(directRecipientJid);
330
+ return pair.pnJid ?? undefined;
361
331
  }
362
332
  async syncSignalSession(jid, reasonIdentity = false) {
363
333
  const address = (0, jid_1.parseSignalAddressFromJid)(jid);
@@ -1,4 +1,5 @@
1
1
  import { type WaPrivacyDisallowedListSettingName, type WaPrivacySettingName, type WaPrivacySettingValueMap } from '../../protocol/privacy';
2
+ import type { SignalUserJidPair } from '../../signal/api/SignalDeviceSyncApi';
2
3
  import type { BinaryNode } from '../../transport/types';
3
4
  export type WaPrivacySettings = {
4
5
  readonly [K in WaPrivacySettingName]?: WaPrivacySettingValueMap[K];
@@ -36,17 +37,29 @@ export interface WaPrivacyCoordinator {
36
37
  /** Returns the current account-wide blocklist. */
37
38
  readonly getBlocklist: () => Promise<WaBlocklistResult>;
38
39
  /**
39
- * Blocks `jid` (account-wide blocklist). After this, the peer can no
40
- * longer message/call you and cannot see your last seen/online/photo/
41
- * status. The block is symmetric only from the peer's read perspective -
42
- * they don't get an explicit "you were blocked" notification.
40
+ * Blocks a user (account-wide blocklist). Accepts a phone-number jid, a
41
+ * LID jid, or a bare phone number (digits only). After this, the peer can
42
+ * no longer
43
+ * message/call you and cannot see your last seen/online/photo/status. The
44
+ * block is symmetric only from the peer's read perspective - they don't
45
+ * get an explicit "you were blocked" notification.
46
+ *
47
+ * The server keys blocklist entries by LID for migrated accounts, so a
48
+ * phone-number input is resolved to its LID first (device-list cache,
49
+ * then a usync query). Non-migrated accounts fall back to the plain
50
+ * phone-jid form.
43
51
  */
44
52
  readonly blockUser: (jid: string) => Promise<void>;
45
- /** Removes `jid` from the blocklist. */
53
+ /**
54
+ * Removes a user from the blocklist. Accepts the same inputs as
55
+ * {@link blockUser} and performs the same LID resolution - unblocking a
56
+ * migrated entry by phone jid is rejected by the server.
57
+ */
46
58
  readonly unblockUser: (jid: string) => Promise<void>;
47
59
  }
48
60
  interface WaPrivacyCoordinatorOptions {
49
61
  readonly queryWithContext: (context: string, node: BinaryNode, timeoutMs?: number, contextData?: Readonly<Record<string, unknown>>) => Promise<BinaryNode>;
62
+ readonly resolveUserJidPair: (userJid: string) => Promise<SignalUserJidPair>;
50
63
  }
51
64
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
52
65
  export declare function createPrivacyCoordinator(options: WaPrivacyCoordinatorOptions): WaPrivacyCoordinator;
@@ -1,6 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.createPrivacyCoordinator = createPrivacyCoordinator;
4
+ const jid_1 = require("../../protocol/jid");
4
5
  const nodes_1 = require("../../protocol/nodes");
5
6
  const privacy_1 = require("../../protocol/privacy");
6
7
  const privacy_2 = require("../../transport/node/builders/privacy");
@@ -88,6 +89,23 @@ function parseBlocklist(result) {
88
89
  jids.length = jidsCount;
89
90
  return { jids, dhash };
90
91
  }
92
+ /**
93
+ * Resolves a blocklist input into both addressing forms via
94
+ * `resolveUserJidPair` (device-list cache first, usync fallback for phone
95
+ * jids). Resolution failures degrade to the single known form instead of
96
+ * throwing - the server then decides whether that form is acceptable.
97
+ */
98
+ async function resolveBlocklistTarget(options, jid) {
99
+ const normalized = (0, jid_1.normalizeRecipientJid)(jid);
100
+ if (!(0, jid_1.isLidJid)(normalized) && !(0, jid_1.isUserJid)(normalized)) {
101
+ throw new Error(`blocklist target must be a user jid: ${jid}`);
102
+ }
103
+ const pair = await options.resolveUserJidPair(normalized);
104
+ if (pair.lidJid !== null) {
105
+ return { lidJid: pair.lidJid, pnJid: pair.pnJid };
106
+ }
107
+ return { lidJid: null, pnJid: pair.pnJid ?? normalized };
108
+ }
91
109
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
92
110
  function createPrivacyCoordinator(options) {
93
111
  const { queryWithContext } = options;
@@ -123,13 +141,20 @@ function createPrivacyCoordinator(options) {
123
141
  return parseBlocklist(result);
124
142
  },
125
143
  blockUser: async (jid) => {
126
- const node = (0, privacy_2.buildBlocklistChangeIq)(jid, 'block');
127
- const result = await queryWithContext('privacy.blockUser', node, undefined, { jid });
144
+ const target = await resolveBlocklistTarget(options, jid);
145
+ const node = (0, privacy_2.buildBlocklistBlockIq)(target);
146
+ const result = await queryWithContext('privacy.blockUser', node, undefined, {
147
+ jid: target.lidJid ?? target.pnJid
148
+ });
128
149
  (0, query_1.assertIqResult)(result, 'privacy.blockUser');
129
150
  },
130
151
  unblockUser: async (jid) => {
131
- const node = (0, privacy_2.buildBlocklistChangeIq)(jid, 'unblock');
132
- const result = await queryWithContext('privacy.unblockUser', node, undefined, { jid });
152
+ const target = await resolveBlocklistTarget(options, jid);
153
+ const unblockJid = target.lidJid ?? target.pnJid;
154
+ const node = (0, privacy_2.buildBlocklistUnblockIq)(unblockJid);
155
+ const result = await queryWithContext('privacy.unblockUser', node, undefined, {
156
+ jid: unblockJid
157
+ });
133
158
  (0, query_1.assertIqResult)(result, 'privacy.unblockUser');
134
159
  }
135
160
  };
@@ -371,7 +371,8 @@ export function buildWaClientDependencies(input) {
371
371
  logger
372
372
  });
373
373
  const privacyCoordinator = createPrivacyCoordinator({
374
- queryWithContext: runtime.queryWithContext
374
+ queryWithContext: runtime.queryWithContext,
375
+ resolveUserJidPair: (userJid) => signalDeviceSync.resolveUserJidPair(userJid)
375
376
  });
376
377
  const businessCoordinator = createBusinessCoordinator({
377
378
  queryWithContext: runtime.queryWithContext,
@@ -297,8 +297,8 @@ export class WaMessageDispatchCoordinator {
297
297
  }
298
298
  /**
299
299
  * For a 1:1 recipient passed in PN form, returns the LID-addressed user JID
300
- * (cache-first; falls back to a one-shot `queryLidsByPhoneJids`). Switching
301
- * to LID before fanout ensures the envelope, eligible-requester list, and
300
+ * (via {@link SignalDeviceSyncApi.resolveUserJidPair}). Switching to LID
301
+ * before fanout ensures the envelope, eligible-requester list, and
302
302
  * retry-receipt addressing all agree, which keeps the retry tracker from
303
303
  * rejecting receipts that arrive in LID form. Returns the original PN if
304
304
  * no LID is known/resolvable. Inputs already in LID form pass through.
@@ -306,26 +306,8 @@ export class WaMessageDispatchCoordinator {
306
306
  async resolveDirectRecipientLid(pnUserJid) {
307
307
  if (isLidJid(pnUserJid))
308
308
  return pnUserJid;
309
- const cached = await this.deps.deviceListStore.findByAnyUserJid(pnUserJid);
310
- if (cached) {
311
- if (isLidJid(cached.userJid))
312
- return cached.userJid;
313
- if (cached.altUserJid && isLidJid(cached.altUserJid))
314
- return cached.altUserJid;
315
- }
316
- try {
317
- const results = await this.deps.signalDeviceSync.queryLidsByPhoneJids([pnUserJid]);
318
- const match = results.find((entry) => entry.queriedJid === pnUserJid);
319
- if (match?.lidJid)
320
- return match.lidJid;
321
- }
322
- catch (error) {
323
- this.deps.logger.debug('lid resolution failed for direct recipient', {
324
- pnUserJid,
325
- message: toError(error).message
326
- });
327
- }
328
- return pnUserJid;
309
+ const pair = await this.deps.signalDeviceSync.resolveUserJidPair(pnUserJid);
310
+ return pair.lidJid ?? pnUserJid;
329
311
  }
330
312
  /**
331
313
  * Resolves the `peer_recipient_pn` cross-reference for a 1:1 send, or
@@ -341,20 +323,8 @@ export class WaMessageDispatchCoordinator {
341
323
  return undefined;
342
324
  if (isUserJid(recipientUserJid))
343
325
  return recipientUserJid;
344
- try {
345
- const snapshot = await this.deps.deviceListStore.findByAnyUserJid(directRecipientJid);
346
- if (snapshot?.userJid && isUserJid(snapshot.userJid))
347
- return snapshot.userJid;
348
- if (snapshot?.altUserJid && isUserJid(snapshot.altUserJid))
349
- return snapshot.altUserJid;
350
- }
351
- catch (error) {
352
- this.deps.logger.trace('peer_recipient_pn store lookup failed', {
353
- lid: directRecipientJid,
354
- message: toError(error).message
355
- });
356
- }
357
- return undefined;
326
+ const pair = await this.deps.signalDeviceSync.resolveUserJidPair(directRecipientJid);
327
+ return pair.pnJid ?? undefined;
358
328
  }
359
329
  async syncSignalSession(jid, reasonIdentity = false) {
360
330
  const address = parseSignalAddressFromJid(jid);
@@ -1,6 +1,7 @@
1
+ import { isLidJid, isUserJid, normalizeRecipientJid } from '../../protocol/jid.js';
1
2
  import { WA_NODE_TAGS } from '../../protocol/nodes.js';
2
3
  import { WA_PRIVACY_CATEGORY_TO_SETTING, WA_PRIVACY_SETTING_TO_CATEGORY, WA_PRIVACY_TAGS, WA_PRIVACY_VALUES } from '../../protocol/privacy.js';
3
- import { buildBlocklistChangeIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq } from '../../transport/node/builders/privacy.js';
4
+ import { buildBlocklistBlockIq, buildBlocklistUnblockIq, buildGetBlocklistIq, buildGetPrivacyDisallowedListIq, buildGetPrivacySettingsIq, buildSetPrivacyCategoryIq } from '../../transport/node/builders/privacy.js';
4
5
  import { findNodeChild, getNodeChildren, getNodeChildrenByTag } from '../../transport/node/helpers.js';
5
6
  import { assertIqResult } from '../../transport/node/query.js';
6
7
  const IGNORED_SERVER_CATEGORIES = new Set([
@@ -85,6 +86,23 @@ function parseBlocklist(result) {
85
86
  jids.length = jidsCount;
86
87
  return { jids, dhash };
87
88
  }
89
+ /**
90
+ * Resolves a blocklist input into both addressing forms via
91
+ * `resolveUserJidPair` (device-list cache first, usync fallback for phone
92
+ * jids). Resolution failures degrade to the single known form instead of
93
+ * throwing - the server then decides whether that form is acceptable.
94
+ */
95
+ async function resolveBlocklistTarget(options, jid) {
96
+ const normalized = normalizeRecipientJid(jid);
97
+ if (!isLidJid(normalized) && !isUserJid(normalized)) {
98
+ throw new Error(`blocklist target must be a user jid: ${jid}`);
99
+ }
100
+ const pair = await options.resolveUserJidPair(normalized);
101
+ if (pair.lidJid !== null) {
102
+ return { lidJid: pair.lidJid, pnJid: pair.pnJid };
103
+ }
104
+ return { lidJid: null, pnJid: pair.pnJid ?? normalized };
105
+ }
88
106
  /** Builds a {@link WaPrivacyCoordinator} backed by the given IQ query function. */
89
107
  export function createPrivacyCoordinator(options) {
90
108
  const { queryWithContext } = options;
@@ -120,13 +138,20 @@ export function createPrivacyCoordinator(options) {
120
138
  return parseBlocklist(result);
121
139
  },
122
140
  blockUser: async (jid) => {
123
- const node = buildBlocklistChangeIq(jid, 'block');
124
- const result = await queryWithContext('privacy.blockUser', node, undefined, { jid });
141
+ const target = await resolveBlocklistTarget(options, jid);
142
+ const node = buildBlocklistBlockIq(target);
143
+ const result = await queryWithContext('privacy.blockUser', node, undefined, {
144
+ jid: target.lidJid ?? target.pnJid
145
+ });
125
146
  assertIqResult(result, 'privacy.blockUser');
126
147
  },
127
148
  unblockUser: async (jid) => {
128
- const node = buildBlocklistChangeIq(jid, 'unblock');
129
- const result = await queryWithContext('privacy.unblockUser', node, undefined, { jid });
149
+ const target = await resolveBlocklistTarget(options, jid);
150
+ const unblockJid = target.lidJid ?? target.pnJid;
151
+ const node = buildBlocklistUnblockIq(unblockJid);
152
+ const result = await queryWithContext('privacy.unblockUser', node, undefined, {
153
+ jid: unblockJid
154
+ });
130
155
  assertIqResult(result, 'privacy.unblockUser');
131
156
  }
132
157
  };
@@ -1,10 +1,11 @@
1
1
  import { PromiseDedup } from '../../infra/perf/PromiseDedup.js';
2
2
  import { WA_DEFAULTS, WA_NODE_TAGS, WA_USYNC_CONTEXTS } from '../../protocol/constants.js';
3
- import { buildDeviceJid, isHostedDeviceId, parsePhoneJid, splitJid, toUserJid } from '../../protocol/jid.js';
3
+ import { buildDeviceJid, isHostedDeviceId, isLidJid, isUserJid, parsePhoneJid, splitJid, toUserJid } from '../../protocol/jid.js';
4
4
  import { buildUsyncIq, iterateUsyncUsers, parseUsyncResultEnvelope } from '../../transport/node/builders/usync.js';
5
5
  import { findNodeChild, getNodeChildrenByTag, getNodeTextContent } from '../../transport/node/helpers.js';
6
6
  import { assertIqResult } from '../../transport/node/query.js';
7
7
  import { createUsyncSidGenerator, logUsyncProtocolErrors } from '../../transport/node/usync.js';
8
+ import { toError } from '../../util/primitives.js';
8
9
  /**
9
10
  * Resolves the device list and LID mapping for a set of users via the `usync`
10
11
  * protocol. Concurrent calls for the same JIDs are deduplicated.
@@ -139,6 +140,66 @@ export class SignalDeviceSyncApi {
139
140
  await this.propagateAltUserJids(result);
140
141
  return result;
141
142
  }
143
+ /**
144
+ * Resolves both addressing forms for a 1:1 user jid (PN or LID input),
145
+ * cache-first via the device-list store (`userJid`/`altUserJid`) with a
146
+ * one-shot {@link queryLidsByPhoneJids} fallback for PN inputs. LID
147
+ * inputs have no reverse lookup - their PN side stays `null` on a cache
148
+ * miss. Store/usync failures are logged at debug and degrade to the
149
+ * forms already known. Inputs that are neither PN nor LID user jids
150
+ * resolve to `{ lidJid: null, pnJid: null }`.
151
+ */
152
+ async resolveUserJidPair(userJid, timeoutMs = this.defaultTimeoutMs) {
153
+ if (isLidJid(userJid)) {
154
+ return { lidJid: userJid, pnJid: await this.findCachedAltForm(userJid, isUserJid) };
155
+ }
156
+ if (!isUserJid(userJid)) {
157
+ return { lidJid: null, pnJid: null };
158
+ }
159
+ const cachedLid = await this.findCachedAltForm(userJid, isLidJid);
160
+ if (cachedLid) {
161
+ return { lidJid: cachedLid, pnJid: userJid };
162
+ }
163
+ try {
164
+ const results = await this.queryLidsByPhoneJids([userJid], timeoutMs);
165
+ const match = results.find((entry) => entry.queriedJid === userJid);
166
+ if (match?.lidJid) {
167
+ return { lidJid: match.lidJid, pnJid: match.phoneJid };
168
+ }
169
+ }
170
+ catch (error) {
171
+ this.logger.debug('lid usync resolution failed for jid pair', {
172
+ jid: userJid,
173
+ message: toError(error).message
174
+ });
175
+ }
176
+ return { lidJid: null, pnJid: userJid };
177
+ }
178
+ /**
179
+ * Returns the device-list snapshot form of `userJid` that satisfies
180
+ * `matches` (checked against `userJid` then `altUserJid`), or `null` when
181
+ * the store is absent, misses, or fails.
182
+ */
183
+ async findCachedAltForm(userJid, matches) {
184
+ if (!this.deviceListStore)
185
+ return null;
186
+ try {
187
+ const snapshot = await this.deviceListStore.findByAnyUserJid(userJid);
188
+ if (snapshot) {
189
+ if (matches(snapshot.userJid))
190
+ return snapshot.userJid;
191
+ if (snapshot.altUserJid && matches(snapshot.altUserJid))
192
+ return snapshot.altUserJid;
193
+ }
194
+ }
195
+ catch (error) {
196
+ this.logger.debug('device-list lookup failed for jid pair', {
197
+ jid: userJid,
198
+ message: toError(error).message
199
+ });
200
+ }
201
+ return null;
202
+ }
142
203
  /**
143
204
  * Enriches existing device-list snapshots with the resolved LID equivalents
144
205
  * so {@link WaDeviceListStore.findByAnyUserJid} can later match a retry
@@ -78,6 +78,7 @@ export class WaComms {
78
78
  this.usedResumeHandshake = false;
79
79
  this.noiseSession = null;
80
80
  this.lastServerStaticKey = null;
81
+ this.lastInboundAtMs = 0;
81
82
  this.frameProcessingQueue = Promise.resolve();
82
83
  this.frameHandlerQueue = new BoundedTaskQueue(WA_FRAME_HANDLER_QUEUE_MAX_SIZE, WA_FRAME_HANDLER_MAX_CONCURRENCY);
83
84
  }
@@ -205,6 +206,10 @@ export class WaComms {
205
206
  const wire = await this.noiseSession.encryptFrame(payload);
206
207
  await this.socket.send(wire);
207
208
  }
209
+ /** Timestamp (`Date.now()` ms) of the last raw payload received from the socket; `0` before any inbound. */
210
+ getLastInboundAtMs() {
211
+ return this.lastInboundAtMs;
212
+ }
208
213
  getCommsState() {
209
214
  return {
210
215
  started: this.started,
@@ -285,6 +290,7 @@ export class WaComms {
285
290
  });
286
291
  }
287
292
  onSocketMessage(payload) {
293
+ this.lastInboundAtMs = Date.now();
288
294
  this.frameProcessingQueue = this.frameProcessingQueue.then(() => this.processSocketPayload(payload), () => this.processSocketPayload(payload));
289
295
  }
290
296
  async processSocketPayload(payload) {
@@ -522,6 +528,7 @@ export class WaComms {
522
528
  this.resumeHandshakeFailures = 0;
523
529
  }
524
530
  this.noiseSession = null;
531
+ this.lastInboundAtMs = 0;
525
532
  this.clearPendingFrames();
526
533
  this.pendingFramesOverflowClosing = false;
527
534
  if (options.clearHandlers) {
@@ -5,8 +5,13 @@ const KEEPALIVE_DEFAULT_MIN_JITTER_MS = 250;
5
5
  const KEEPALIVE_MAX_JITTER_RATIO = 0.5;
6
6
  /**
7
7
  * Periodically pings the server with a ping IQ to detect dead sockets and
8
- * measure clock skew. Skips when the orchestrator has other traffic in
9
- * flight; recovers a queued/dropped ping via the configured `getComms()`.
8
+ * measure clock skew. A ping is skipped while another ping is in flight, or
9
+ * while other queries are pending and the socket shows recent inbound
10
+ * activity. A socket silent past the dead-socket timeout is pinged even with
11
+ * queries pending: pending queries on a silent socket are evidence of a dead
12
+ * connection, so steady outbound traffic must not starve detection. On ping
13
+ * failure it rejects pending queries and resumes the socket via the
14
+ * configured `getComms()`.
10
15
  */
11
16
  export class WaKeepAlive {
12
17
  constructor(options) {
@@ -68,14 +73,25 @@ export class WaKeepAlive {
68
73
  this.schedule(generation);
69
74
  return;
70
75
  }
71
- if (this.inFlight || this.nodeOrchestrator.hasPending()) {
72
- this.logger.trace('keepalive skipped: in-flight or pending queries', {
73
- inFlight: this.inFlight,
74
- pendingQueries: this.nodeOrchestrator.hasPending()
75
- });
76
+ if (this.inFlight) {
77
+ this.logger.trace('keepalive skipped: ping already in flight');
76
78
  this.schedule(generation);
77
79
  return;
78
80
  }
81
+ if (this.nodeOrchestrator.hasPending()) {
82
+ const inboundIdleMs = Date.now() - comms.getLastInboundAtMs();
83
+ if (inboundIdleMs < this.timeoutMs) {
84
+ this.logger.trace('keepalive skipped: pending queries with recent inbound', {
85
+ inboundIdleMs
86
+ });
87
+ this.schedule(generation);
88
+ return;
89
+ }
90
+ this.logger.debug('keepalive pinging despite pending queries: inbound is silent', {
91
+ inboundIdleMs,
92
+ timeoutMs: this.timeoutMs
93
+ });
94
+ }
79
95
  this.inFlight = true;
80
96
  const startedAt = Date.now();
81
97
  try {
@@ -108,6 +124,7 @@ export class WaKeepAlive {
108
124
  this.logger.warn('keepalive ping failed, reconnecting socket', {
109
125
  message: toError(error).message
110
126
  });
127
+ this.nodeOrchestrator.clearPending(new Error('socket resume requested by keepalive ping failure'));
111
128
  try {
112
129
  await comms.closeSocketAndResume();
113
130
  }
@@ -38,11 +38,41 @@ export function buildGetPrivacyDisallowedListIq(category) {
38
38
  export function buildGetBlocklistIq() {
39
39
  return buildIqNode(WA_IQ_TYPES.GET, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.BLOCKLIST);
40
40
  }
41
- export function buildBlocklistChangeIq(jid, action) {
41
+ /**
42
+ * Builds the blocklist `set` IQ for a block action. LID-migrated targets are
43
+ * addressed by the LID jid plus an identifier attribute: `pn_jid` when the
44
+ * phone jid is known, else `unknown_identifier="true"`. Non-migrated targets
45
+ * are addressed by the phone jid alone. The server rejects a block that
46
+ * addresses a migrated account by phone jid or omits the identifier
47
+ * (`400: bad-request`).
48
+ */
49
+ export function buildBlocklistBlockIq(target) {
50
+ let attrs;
51
+ if (target.lidJid !== null) {
52
+ attrs =
53
+ target.pnJid !== null
54
+ ? { action: 'block', jid: target.lidJid, pn_jid: target.pnJid }
55
+ : { action: 'block', jid: target.lidJid, unknown_identifier: 'true' };
56
+ }
57
+ else {
58
+ attrs = { action: 'block', jid: target.pnJid };
59
+ }
42
60
  return buildIqNode(WA_IQ_TYPES.SET, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.BLOCKLIST, [
43
61
  {
44
62
  tag: 'item',
45
- attrs: { jid, action }
63
+ attrs
64
+ }
65
+ ]);
66
+ }
67
+ /**
68
+ * Builds the blocklist `set` IQ for an unblock action. The server keys
69
+ * migrated entries by LID, so `jid` must be the LID jid when one exists.
70
+ */
71
+ export function buildBlocklistUnblockIq(jid) {
72
+ return buildIqNode(WA_IQ_TYPES.SET, WA_DEFAULTS.HOST_DOMAIN, WA_XMLNS.BLOCKLIST, [
73
+ {
74
+ tag: 'item',
75
+ attrs: { jid, action: 'unblock' }
46
76
  }
47
77
  ]);
48
78
  }
@@ -29,6 +29,17 @@ export interface SignalLidSyncResult {
29
29
  */
30
30
  readonly invalid: boolean;
31
31
  }
32
+ /** Both addressing forms of a 1:1 user, as far as they could be resolved. */
33
+ export interface SignalUserJidPair {
34
+ /** LID user jid, or `null` when no LID mapping is known. */
35
+ readonly lidJid: string | null;
36
+ /**
37
+ * Phone-number user jid, or `null` when unknown. When resolved through
38
+ * the usync fallback this is the server-canonical form, which may differ
39
+ * from the queried number (e.g. BR 9th digit added).
40
+ */
41
+ readonly pnJid: string | null;
42
+ }
32
43
  /**
33
44
  * Resolves the device list and LID mapping for a set of users via the `usync`
34
45
  * protocol. Concurrent calls for the same JIDs are deduplicated.
@@ -56,6 +67,22 @@ export declare class SignalDeviceSyncApi {
56
67
  * one entry per input JID with `exists` indicating server-side presence.
57
68
  */
58
69
  queryLidsByPhoneJids(phoneJids: readonly string[], timeoutMs?: number): Promise<readonly SignalLidSyncResult[]>;
70
+ /**
71
+ * Resolves both addressing forms for a 1:1 user jid (PN or LID input),
72
+ * cache-first via the device-list store (`userJid`/`altUserJid`) with a
73
+ * one-shot {@link queryLidsByPhoneJids} fallback for PN inputs. LID
74
+ * inputs have no reverse lookup - their PN side stays `null` on a cache
75
+ * miss. Store/usync failures are logged at debug and degrade to the
76
+ * forms already known. Inputs that are neither PN nor LID user jids
77
+ * resolve to `{ lidJid: null, pnJid: null }`.
78
+ */
79
+ resolveUserJidPair(userJid: string, timeoutMs?: number): Promise<SignalUserJidPair>;
80
+ /**
81
+ * Returns the device-list snapshot form of `userJid` that satisfies
82
+ * `matches` (checked against `userJid` then `altUserJid`), or `null` when
83
+ * the store is absent, misses, or fails.
84
+ */
85
+ private findCachedAltForm;
59
86
  /**
60
87
  * Enriches existing device-list snapshots with the resolved LID equivalents
61
88
  * so {@link WaDeviceListStore.findByAnyUserJid} can later match a retry
@@ -8,6 +8,7 @@ const usync_1 = require("../../transport/node/builders/usync");
8
8
  const helpers_1 = require("../../transport/node/helpers");
9
9
  const query_1 = require("../../transport/node/query");
10
10
  const usync_2 = require("../../transport/node/usync");
11
+ const primitives_1 = require("../../util/primitives");
11
12
  /**
12
13
  * Resolves the device list and LID mapping for a set of users via the `usync`
13
14
  * protocol. Concurrent calls for the same JIDs are deduplicated.
@@ -142,6 +143,66 @@ class SignalDeviceSyncApi {
142
143
  await this.propagateAltUserJids(result);
143
144
  return result;
144
145
  }
146
+ /**
147
+ * Resolves both addressing forms for a 1:1 user jid (PN or LID input),
148
+ * cache-first via the device-list store (`userJid`/`altUserJid`) with a
149
+ * one-shot {@link queryLidsByPhoneJids} fallback for PN inputs. LID
150
+ * inputs have no reverse lookup - their PN side stays `null` on a cache
151
+ * miss. Store/usync failures are logged at debug and degrade to the
152
+ * forms already known. Inputs that are neither PN nor LID user jids
153
+ * resolve to `{ lidJid: null, pnJid: null }`.
154
+ */
155
+ async resolveUserJidPair(userJid, timeoutMs = this.defaultTimeoutMs) {
156
+ if ((0, jid_1.isLidJid)(userJid)) {
157
+ return { lidJid: userJid, pnJid: await this.findCachedAltForm(userJid, jid_1.isUserJid) };
158
+ }
159
+ if (!(0, jid_1.isUserJid)(userJid)) {
160
+ return { lidJid: null, pnJid: null };
161
+ }
162
+ const cachedLid = await this.findCachedAltForm(userJid, jid_1.isLidJid);
163
+ if (cachedLid) {
164
+ return { lidJid: cachedLid, pnJid: userJid };
165
+ }
166
+ try {
167
+ const results = await this.queryLidsByPhoneJids([userJid], timeoutMs);
168
+ const match = results.find((entry) => entry.queriedJid === userJid);
169
+ if (match?.lidJid) {
170
+ return { lidJid: match.lidJid, pnJid: match.phoneJid };
171
+ }
172
+ }
173
+ catch (error) {
174
+ this.logger.debug('lid usync resolution failed for jid pair', {
175
+ jid: userJid,
176
+ message: (0, primitives_1.toError)(error).message
177
+ });
178
+ }
179
+ return { lidJid: null, pnJid: userJid };
180
+ }
181
+ /**
182
+ * Returns the device-list snapshot form of `userJid` that satisfies
183
+ * `matches` (checked against `userJid` then `altUserJid`), or `null` when
184
+ * the store is absent, misses, or fails.
185
+ */
186
+ async findCachedAltForm(userJid, matches) {
187
+ if (!this.deviceListStore)
188
+ return null;
189
+ try {
190
+ const snapshot = await this.deviceListStore.findByAnyUserJid(userJid);
191
+ if (snapshot) {
192
+ if (matches(snapshot.userJid))
193
+ return snapshot.userJid;
194
+ if (snapshot.altUserJid && matches(snapshot.altUserJid))
195
+ return snapshot.altUserJid;
196
+ }
197
+ }
198
+ catch (error) {
199
+ this.logger.debug('device-list lookup failed for jid pair', {
200
+ jid: userJid,
201
+ message: (0, primitives_1.toError)(error).message
202
+ });
203
+ }
204
+ return null;
205
+ }
145
206
  /**
146
207
  * Enriches existing device-list snapshots with the resolved LID equivalents
147
208
  * so {@link WaDeviceListStore.findByAnyUserJid} can later match a retry
@@ -30,6 +30,7 @@ export declare class WaComms {
30
30
  private usedResumeHandshake;
31
31
  private noiseSession;
32
32
  private lastServerStaticKey;
33
+ private lastInboundAtMs;
33
34
  private frameProcessingQueue;
34
35
  private readonly frameHandlerQueue;
35
36
  constructor(config: WaCommsConfig, logger?: Logger);
@@ -41,6 +42,8 @@ export declare class WaComms {
41
42
  closeSocketAndResume(): Promise<void>;
42
43
  closeSocketAndPreventRetry(): Promise<void>;
43
44
  sendFrame(payload: Uint8Array): Promise<void>;
45
+ /** Timestamp (`Date.now()` ms) of the last raw payload received from the socket; `0` before any inbound. */
46
+ getLastInboundAtMs(): number;
44
47
  getCommsState(): Readonly<WaCommsState>;
45
48
  getServerStaticKey(): Uint8Array | null;
46
49
  private openSocket;
@@ -81,6 +81,7 @@ class WaComms {
81
81
  this.usedResumeHandshake = false;
82
82
  this.noiseSession = null;
83
83
  this.lastServerStaticKey = null;
84
+ this.lastInboundAtMs = 0;
84
85
  this.frameProcessingQueue = Promise.resolve();
85
86
  this.frameHandlerQueue = new BoundedTaskQueue_1.BoundedTaskQueue(WA_FRAME_HANDLER_QUEUE_MAX_SIZE, WA_FRAME_HANDLER_MAX_CONCURRENCY);
86
87
  }
@@ -208,6 +209,10 @@ class WaComms {
208
209
  const wire = await this.noiseSession.encryptFrame(payload);
209
210
  await this.socket.send(wire);
210
211
  }
212
+ /** Timestamp (`Date.now()` ms) of the last raw payload received from the socket; `0` before any inbound. */
213
+ getLastInboundAtMs() {
214
+ return this.lastInboundAtMs;
215
+ }
211
216
  getCommsState() {
212
217
  return {
213
218
  started: this.started,
@@ -288,6 +293,7 @@ class WaComms {
288
293
  });
289
294
  }
290
295
  onSocketMessage(payload) {
296
+ this.lastInboundAtMs = Date.now();
291
297
  this.frameProcessingQueue = this.frameProcessingQueue.then(() => this.processSocketPayload(payload), () => this.processSocketPayload(payload));
292
298
  }
293
299
  async processSocketPayload(payload) {
@@ -525,6 +531,7 @@ class WaComms {
525
531
  this.resumeHandshakeFailures = 0;
526
532
  }
527
533
  this.noiseSession = null;
534
+ this.lastInboundAtMs = 0;
528
535
  this.clearPendingFrames();
529
536
  this.pendingFramesOverflowClosing = false;
530
537
  if (options.clearHandlers) {
@@ -5,6 +5,7 @@ interface WaKeepAliveOptions {
5
5
  readonly logger: Logger;
6
6
  readonly nodeOrchestrator: {
7
7
  hasPending(): boolean;
8
+ clearPending(reason: Error): void;
8
9
  query(node: BinaryNode, timeoutMs?: number): Promise<BinaryNode>;
9
10
  };
10
11
  readonly getComms: () => WaComms | null;
@@ -18,8 +19,13 @@ interface WaKeepAliveOptions {
18
19
  }
19
20
  /**
20
21
  * Periodically pings the server with a ping IQ to detect dead sockets and
21
- * measure clock skew. Skips when the orchestrator has other traffic in
22
- * flight; recovers a queued/dropped ping via the configured `getComms()`.
22
+ * measure clock skew. A ping is skipped while another ping is in flight, or
23
+ * while other queries are pending and the socket shows recent inbound
24
+ * activity. A socket silent past the dead-socket timeout is pinged even with
25
+ * queries pending: pending queries on a silent socket are evidence of a dead
26
+ * connection, so steady outbound traffic must not starve detection. On ping
27
+ * failure it rejects pending queries and resumes the socket via the
28
+ * configured `getComms()`.
23
29
  */
24
30
  export declare class WaKeepAlive {
25
31
  private readonly logger;
@@ -8,8 +8,13 @@ const KEEPALIVE_DEFAULT_MIN_JITTER_MS = 250;
8
8
  const KEEPALIVE_MAX_JITTER_RATIO = 0.5;
9
9
  /**
10
10
  * Periodically pings the server with a ping IQ to detect dead sockets and
11
- * measure clock skew. Skips when the orchestrator has other traffic in
12
- * flight; recovers a queued/dropped ping via the configured `getComms()`.
11
+ * measure clock skew. A ping is skipped while another ping is in flight, or
12
+ * while other queries are pending and the socket shows recent inbound
13
+ * activity. A socket silent past the dead-socket timeout is pinged even with
14
+ * queries pending: pending queries on a silent socket are evidence of a dead
15
+ * connection, so steady outbound traffic must not starve detection. On ping
16
+ * failure it rejects pending queries and resumes the socket via the
17
+ * configured `getComms()`.
13
18
  */
14
19
  class WaKeepAlive {
15
20
  constructor(options) {
@@ -71,14 +76,25 @@ class WaKeepAlive {
71
76
  this.schedule(generation);
72
77
  return;
73
78
  }
74
- if (this.inFlight || this.nodeOrchestrator.hasPending()) {
75
- this.logger.trace('keepalive skipped: in-flight or pending queries', {
76
- inFlight: this.inFlight,
77
- pendingQueries: this.nodeOrchestrator.hasPending()
78
- });
79
+ if (this.inFlight) {
80
+ this.logger.trace('keepalive skipped: ping already in flight');
79
81
  this.schedule(generation);
80
82
  return;
81
83
  }
84
+ if (this.nodeOrchestrator.hasPending()) {
85
+ const inboundIdleMs = Date.now() - comms.getLastInboundAtMs();
86
+ if (inboundIdleMs < this.timeoutMs) {
87
+ this.logger.trace('keepalive skipped: pending queries with recent inbound', {
88
+ inboundIdleMs
89
+ });
90
+ this.schedule(generation);
91
+ return;
92
+ }
93
+ this.logger.debug('keepalive pinging despite pending queries: inbound is silent', {
94
+ inboundIdleMs,
95
+ timeoutMs: this.timeoutMs
96
+ });
97
+ }
82
98
  this.inFlight = true;
83
99
  const startedAt = Date.now();
84
100
  try {
@@ -111,6 +127,7 @@ class WaKeepAlive {
111
127
  this.logger.warn('keepalive ping failed, reconnecting socket', {
112
128
  message: (0, primitives_1.toError)(error).message
113
129
  });
130
+ this.nodeOrchestrator.clearPending(new Error('socket resume requested by keepalive ping failure'));
114
131
  try {
115
132
  await comms.closeSocketAndResume();
116
133
  }
@@ -4,4 +4,29 @@ export declare function buildGetPrivacySettingsIq(): BinaryNode;
4
4
  export declare function buildSetPrivacyCategoryIq(category: WaPrivacyCategory, value: WaPrivacyValue): BinaryNode;
5
5
  export declare function buildGetPrivacyDisallowedListIq(category: WaPrivacyCategory): BinaryNode;
6
6
  export declare function buildGetBlocklistIq(): BinaryNode;
7
- export declare function buildBlocklistChangeIq(jid: string, action: 'block' | 'unblock'): BinaryNode;
7
+ /**
8
+ * Blocklist target in both addressing forms. At least one side is always
9
+ * present: LID-migrated accounts carry `lidJid` (plus `pnJid` when known),
10
+ * non-migrated accounts carry only `pnJid`.
11
+ */
12
+ export type WaBlocklistTarget = {
13
+ readonly lidJid: string;
14
+ readonly pnJid: string | null;
15
+ } | {
16
+ readonly lidJid: null;
17
+ readonly pnJid: string;
18
+ };
19
+ /**
20
+ * Builds the blocklist `set` IQ for a block action. LID-migrated targets are
21
+ * addressed by the LID jid plus an identifier attribute: `pn_jid` when the
22
+ * phone jid is known, else `unknown_identifier="true"`. Non-migrated targets
23
+ * are addressed by the phone jid alone. The server rejects a block that
24
+ * addresses a migrated account by phone jid or omits the identifier
25
+ * (`400: bad-request`).
26
+ */
27
+ export declare function buildBlocklistBlockIq(target: WaBlocklistTarget): BinaryNode;
28
+ /**
29
+ * Builds the blocklist `set` IQ for an unblock action. The server keys
30
+ * migrated entries by LID, so `jid` must be the LID jid when one exists.
31
+ */
32
+ export declare function buildBlocklistUnblockIq(jid: string): BinaryNode;
@@ -4,7 +4,8 @@ exports.buildGetPrivacySettingsIq = buildGetPrivacySettingsIq;
4
4
  exports.buildSetPrivacyCategoryIq = buildSetPrivacyCategoryIq;
5
5
  exports.buildGetPrivacyDisallowedListIq = buildGetPrivacyDisallowedListIq;
6
6
  exports.buildGetBlocklistIq = buildGetBlocklistIq;
7
- exports.buildBlocklistChangeIq = buildBlocklistChangeIq;
7
+ exports.buildBlocklistBlockIq = buildBlocklistBlockIq;
8
+ exports.buildBlocklistUnblockIq = buildBlocklistUnblockIq;
8
9
  const defaults_1 = require("../../../protocol/defaults");
9
10
  const nodes_1 = require("../../../protocol/nodes");
10
11
  const privacy_1 = require("../../../protocol/privacy");
@@ -45,11 +46,41 @@ function buildGetPrivacyDisallowedListIq(category) {
45
46
  function buildGetBlocklistIq() {
46
47
  return (0, query_1.buildIqNode)(nodes_1.WA_IQ_TYPES.GET, defaults_1.WA_DEFAULTS.HOST_DOMAIN, nodes_1.WA_XMLNS.BLOCKLIST);
47
48
  }
48
- function buildBlocklistChangeIq(jid, action) {
49
+ /**
50
+ * Builds the blocklist `set` IQ for a block action. LID-migrated targets are
51
+ * addressed by the LID jid plus an identifier attribute: `pn_jid` when the
52
+ * phone jid is known, else `unknown_identifier="true"`. Non-migrated targets
53
+ * are addressed by the phone jid alone. The server rejects a block that
54
+ * addresses a migrated account by phone jid or omits the identifier
55
+ * (`400: bad-request`).
56
+ */
57
+ function buildBlocklistBlockIq(target) {
58
+ let attrs;
59
+ if (target.lidJid !== null) {
60
+ attrs =
61
+ target.pnJid !== null
62
+ ? { action: 'block', jid: target.lidJid, pn_jid: target.pnJid }
63
+ : { action: 'block', jid: target.lidJid, unknown_identifier: 'true' };
64
+ }
65
+ else {
66
+ attrs = { action: 'block', jid: target.pnJid };
67
+ }
49
68
  return (0, query_1.buildIqNode)(nodes_1.WA_IQ_TYPES.SET, defaults_1.WA_DEFAULTS.HOST_DOMAIN, nodes_1.WA_XMLNS.BLOCKLIST, [
50
69
  {
51
70
  tag: 'item',
52
- attrs: { jid, action }
71
+ attrs
72
+ }
73
+ ]);
74
+ }
75
+ /**
76
+ * Builds the blocklist `set` IQ for an unblock action. The server keys
77
+ * migrated entries by LID, so `jid` must be the LID jid when one exists.
78
+ */
79
+ function buildBlocklistUnblockIq(jid) {
80
+ return (0, query_1.buildIqNode)(nodes_1.WA_IQ_TYPES.SET, defaults_1.WA_DEFAULTS.HOST_DOMAIN, nodes_1.WA_XMLNS.BLOCKLIST, [
81
+ {
82
+ tag: 'item',
83
+ attrs: { jid, action: 'unblock' }
53
84
  }
54
85
  ]);
55
86
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.6.0",
3
+ "version": "1.6.1",
4
4
  "description": "High-performance WhatsApp Web TypeScript library",
5
5
  "license": "MIT",
6
6
  "author": "vinikjkkj <contact@vinicius.email> (https://github.com/vinikjkkj)",