zapo-js 1.1.0 → 1.1.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.
package/README.md CHANGED
@@ -1,5 +1,9 @@
1
1
  <p align="center">
2
- <img src="https://raw.githubusercontent.com/vinikjkkj/zapo/master/.github/assets/logo.png" alt="zapo" width="400" />
2
+ <picture>
3
+ <source media="(prefers-color-scheme: dark)" srcset="https://raw.githubusercontent.com/vinikjkkj/zapo/master/.github/assets/logo.png" />
4
+ <source media="(prefers-color-scheme: light)" srcset="https://raw.githubusercontent.com/vinikjkkj/zapo/master/.github/assets/logo-light.png" />
5
+ <img src="https://raw.githubusercontent.com/vinikjkkj/zapo/master/.github/assets/logo-light.png" alt="zapo" width="400" />
6
+ </picture>
3
7
  </p>
4
8
 
5
9
  <p align="center">
@@ -11,6 +11,7 @@ const WaAdvSignature_1 = require("../../signal/attestation/WaAdvSignature");
11
11
  const global_1 = require("../../transport/node/builders/global");
12
12
  const pairing_1 = require("../../transport/node/builders/pairing");
13
13
  const helpers_1 = require("../../transport/node/helpers");
14
+ const query_1 = require("../../transport/node/query");
14
15
  const bytes_1 = require("../../util/bytes");
15
16
  class WaPairingFlow {
16
17
  constructor(options) {
@@ -49,6 +50,7 @@ class WaPairingFlow {
49
50
  responseTag: response.tag,
50
51
  responseType: response.attrs.type
51
52
  });
53
+ (0, query_1.assertIqResult)(response, 'companion hello');
52
54
  const linkCodeNode = (0, helpers_1.findNodeChild)(response, constants_1.WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
53
55
  if (!linkCodeNode) {
54
56
  throw new Error('companion hello response missing link_code_companion_reg');
@@ -258,10 +258,28 @@ class WaClient extends node_events_1.EventEmitter {
258
258
  return;
259
259
  }
260
260
  if (protocolType === _proto_1.proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION) {
261
- if (this.options.history?.enabled !== false &&
262
- protocolMessage.historySyncNotification) {
263
- const peerRemoteJid = event.key.remoteJid;
264
- const peerStanzaId = event.key.id;
261
+ if (!protocolMessage.historySyncNotification) {
262
+ return;
263
+ }
264
+ const peerRemoteJid = event.key.remoteJid;
265
+ const peerStanzaId = event.key.id;
266
+ const sendHistSyncReceipt = peerRemoteJid && peerStanzaId
267
+ ? async () => {
268
+ try {
269
+ await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
270
+ type: constants_1.WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
271
+ });
272
+ }
273
+ catch (err) {
274
+ this.logger.warn('failed to send hist_sync receipt', {
275
+ id: peerStanzaId,
276
+ to: peerRemoteJid,
277
+ message: (0, primitives_1.toError)(err).message
278
+ });
279
+ }
280
+ }
281
+ : undefined;
282
+ if (this.options.history?.enabled !== false) {
265
283
  await (0, history_sync_1.runHistorySyncNotification)({
266
284
  logger: this.logger,
267
285
  mediaTransfer: this.mediaTransfer,
@@ -269,24 +287,12 @@ class WaClient extends node_events_1.EventEmitter {
269
287
  emitEvent: this.emit.bind(this),
270
288
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
271
289
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
272
- onProcessed: peerRemoteJid && peerStanzaId
273
- ? async () => {
274
- try {
275
- await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
276
- type: constants_1.WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
277
- });
278
- }
279
- catch (err) {
280
- this.logger.warn('failed to send hist_sync receipt', {
281
- id: peerStanzaId,
282
- to: peerRemoteJid,
283
- message: (0, primitives_1.toError)(err).message
284
- });
285
- }
286
- }
287
- : undefined
290
+ onProcessed: sendHistSyncReceipt
288
291
  }, protocolMessage.historySyncNotification);
289
292
  }
293
+ else if (sendHistSyncReceipt) {
294
+ await sendHistSyncReceipt();
295
+ }
290
296
  return;
291
297
  }
292
298
  if (SYNC_RELATED_PROTOCOL_TYPES.has(protocolType)) {
@@ -391,7 +391,7 @@ class WaIncomingNodeCoordinator {
391
391
  try {
392
392
  const routingInfo = (0, helpers_1.decodeNodeContentBase64OrBytes)(routingInfoNode.content, `ib.${constants_1.WA_NODE_TAGS.EDGE_ROUTING}.${constants_1.WA_NODE_TAGS.ROUTING_INFO}`);
393
393
  await this.runtime.persistRoutingInfo(routingInfo);
394
- this.logger.info('updated routing info from info bulletin', {
394
+ this.logger.debug('updated routing info from info bulletin', {
395
395
  byteLength: routingInfo.byteLength
396
396
  });
397
397
  }
@@ -50,7 +50,6 @@ export declare class WaRetryCoordinator {
50
50
  private isRetryReceiptNode;
51
51
  private prepareDecryptFailureRetry;
52
52
  private sendDecryptFailureRetryReceipt;
53
- private resolvePeerRetryRecipient;
54
53
  private handleParsedRetryRequest;
55
54
  private processRetryRequest;
56
55
  private prepareRetryResend;
@@ -169,7 +169,7 @@ class WaRetryCoordinator {
169
169
  };
170
170
  }
171
171
  async sendDecryptFailureRetryReceipt(context, prepared) {
172
- const recipient = context.recipient ?? this.resolvePeerRetryRecipient(context);
172
+ const { recipient } = context;
173
173
  const retryReceiptNode = (0, retry_1.buildRetryReceiptNode)({
174
174
  stanzaId: context.stanzaId,
175
175
  to: context.from,
@@ -194,26 +194,6 @@ class WaRetryCoordinator {
194
194
  withKeys: prepared.retryKeys !== undefined
195
195
  });
196
196
  }
197
- resolvePeerRetryRecipient(context) {
198
- if (!context.participant) {
199
- return undefined;
200
- }
201
- const meLid = this.deps.getCurrentCredentials()?.meLid;
202
- if (!meLid) {
203
- return undefined;
204
- }
205
- try {
206
- const participantUser = (0, jid_1.toUserJid)(context.participant);
207
- const meUserLid = (0, jid_1.toUserJid)(meLid);
208
- if (participantUser !== meUserLid) {
209
- return undefined;
210
- }
211
- return meUserLid;
212
- }
213
- catch {
214
- return undefined;
215
- }
216
- }
217
197
  async handleParsedRetryRequest(receiptNode, request) {
218
198
  if (request.type === constants_1.WA_MESSAGE_TYPES.RECEIPT_TYPE_ENC_REKEY_RETRY) {
219
199
  this.deps.logger.debug('received enc_rekey_retry request (voip path deferred)', {
@@ -88,12 +88,14 @@ function extractIgnoreKeyContext(node, meJid) {
88
88
  const me = tryParseJid(meJid);
89
89
  const fromCandidates = collectFromCandidates(kind, a);
90
90
  const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
91
+ // Device-stripped to match the JID form used by events/keys; a userless
92
+ // server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
91
93
  return {
92
94
  kind,
93
- remoteJid: a.from ?? null,
95
+ remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
94
96
  fromMe,
95
97
  id: a.id,
96
- participant: a.participant ?? null
98
+ participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
97
99
  };
98
100
  }
99
101
  /** Pure matcher. Exported for direct testing without a coordinator. */
@@ -423,27 +423,30 @@ export interface WaIgnoreKey {
423
423
  * Lib derives `kind` from the stanza tag and resolves `fromMe` by comparing
424
424
  * every from-candidate (`from`, `sender_pn`, `sender_lid`) against `meJid`.
425
425
  *
426
- * `remoteJid` and `participant` expose the **raw** `from` / `participant`
427
- * attrs verbatim and do NOT include the descriptor-style alt-attr lookups
428
- * (`sender_pn` / `sender_lid` / `participant_pn` / `participant_lid`) or
429
- * PN↔LID normalization. If the predicate needs to match by user identity
430
- * regardless of addressing mode, run the raw JID through `parseJidFull` and
431
- * compare on `userJid`, or use the descriptor form which handles it.
426
+ * `remoteJid` and `participant` are the `from` / `participant` attrs with the
427
+ * `:device` segment stripped (bare `user@server`), matching the JID form used
428
+ * by message events and keys. A value that does not parse as a JID (e.g. a
429
+ * userless server `from` like `s.whatsapp.net`) is passed through unchanged.
430
+ * They do NOT include the descriptor-style
431
+ * alt-attr lookups (`sender_pn` / `sender_lid` / `participant_pn` /
432
+ * `participant_lid`) or PN↔LID normalization, so they stay in whichever
433
+ * addressing mode the stanza arrived in. To match by user identity regardless
434
+ * of addressing mode, use the descriptor form, which handles it.
432
435
  */
433
436
  export interface WaIgnoreKeyContext {
434
437
  readonly kind: WaIgnoreStanzaKind;
435
- /** Raw `from` attr (group JID for groups, PN or LID device JID for 1:1). */
438
+ /** `from` attr without `:device` (group JID for groups, PN or LID user JID for 1:1). */
436
439
  readonly remoteJid: string | null;
437
440
  readonly fromMe: boolean;
438
441
  readonly id: string | undefined;
439
- /** Raw `participant` attr; `null` for non-group stanzas. */
442
+ /** `participant` attr without `:device`; `null` for non-group stanzas. */
440
443
  readonly participant: string | null;
441
444
  }
442
445
  /**
443
446
  * Predicate form of {@link WaClient.ignoreKey}. Return `true` to drop the
444
447
  * stanza, `false` to let it through. Receives a {@link WaIgnoreKeyContext}
445
- * with the raw `from`/`participant` attrs (see the context's JSDoc for the
446
- * PN↔LID caveat) plus lib-resolved `kind` and `fromMe`.
448
+ * with the device-stripped `from`/`participant` (see the context's JSDoc for
449
+ * the addressing-mode caveat) plus lib-resolved `kind` and `fromMe`.
447
450
  */
448
451
  export type WaIgnoreKeyPredicate = (ctx: WaIgnoreKeyContext) => boolean;
449
452
  export interface WaIncomingBaseEvent {
@@ -8,6 +8,7 @@ import { ADV_PREFIX_HOSTED_ACCOUNT_SIGNATURE, computeAdvIdentityHmac, generateDe
8
8
  import { buildAckNode, buildIqResultNode } from '../../transport/node/builders/global.js';
9
9
  import { buildCompanionFinishRequestNode, buildCompanionHelloRequestNode, buildGetCountryCodeRequestNode } from '../../transport/node/builders/pairing.js';
10
10
  import { decodeNodeContentUtf8OrBytes, findNodeChild, findNodeChildrenByTags, getFirstNodeChild, getNodeChildrenNonEmptyUtf8ByTag, hasNodeChild } from '../../transport/node/helpers.js';
11
+ import { assertIqResult } from '../../transport/node/query.js';
11
12
  import { concatBytes, decodeProtoBytes, uint8Equal, uint8TimingSafeEqual } from '../../util/bytes.js';
12
13
  export class WaPairingFlow {
13
14
  constructor(options) {
@@ -46,6 +47,7 @@ export class WaPairingFlow {
46
47
  responseTag: response.tag,
47
48
  responseType: response.attrs.type
48
49
  });
50
+ assertIqResult(response, 'companion hello');
49
51
  const linkCodeNode = findNodeChild(response, WA_NODE_TAGS.LINK_CODE_COMPANION_REG);
50
52
  if (!linkCodeNode) {
51
53
  throw new Error('companion hello response missing link_code_companion_reg');
@@ -255,10 +255,28 @@ export class WaClient extends EventEmitter {
255
255
  return;
256
256
  }
257
257
  if (protocolType === proto.Message.ProtocolMessage.Type.HISTORY_SYNC_NOTIFICATION) {
258
- if (this.options.history?.enabled !== false &&
259
- protocolMessage.historySyncNotification) {
260
- const peerRemoteJid = event.key.remoteJid;
261
- const peerStanzaId = event.key.id;
258
+ if (!protocolMessage.historySyncNotification) {
259
+ return;
260
+ }
261
+ const peerRemoteJid = event.key.remoteJid;
262
+ const peerStanzaId = event.key.id;
263
+ const sendHistSyncReceipt = peerRemoteJid && peerStanzaId
264
+ ? async () => {
265
+ try {
266
+ await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
267
+ type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
268
+ });
269
+ }
270
+ catch (err) {
271
+ this.logger.warn('failed to send hist_sync receipt', {
272
+ id: peerStanzaId,
273
+ to: peerRemoteJid,
274
+ message: toError(err).message
275
+ });
276
+ }
277
+ }
278
+ : undefined;
279
+ if (this.options.history?.enabled !== false) {
262
280
  await runHistorySyncNotification({
263
281
  logger: this.logger,
264
282
  mediaTransfer: this.mediaTransfer,
@@ -266,24 +284,12 @@ export class WaClient extends EventEmitter {
266
284
  emitEvent: this.emit.bind(this),
267
285
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
268
286
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
269
- onProcessed: peerRemoteJid && peerStanzaId
270
- ? async () => {
271
- try {
272
- await this.message.sendReceipt(peerRemoteJid, peerStanzaId, {
273
- type: WA_MESSAGE_TYPES.RECEIPT_TYPE_HISTORY_SYNC
274
- });
275
- }
276
- catch (err) {
277
- this.logger.warn('failed to send hist_sync receipt', {
278
- id: peerStanzaId,
279
- to: peerRemoteJid,
280
- message: toError(err).message
281
- });
282
- }
283
- }
284
- : undefined
287
+ onProcessed: sendHistSyncReceipt
285
288
  }, protocolMessage.historySyncNotification);
286
289
  }
290
+ else if (sendHistSyncReceipt) {
291
+ await sendHistSyncReceipt();
292
+ }
287
293
  return;
288
294
  }
289
295
  if (SYNC_RELATED_PROTOCOL_TYPES.has(protocolType)) {
@@ -388,7 +388,7 @@ export class WaIncomingNodeCoordinator {
388
388
  try {
389
389
  const routingInfo = decodeNodeContentBase64OrBytes(routingInfoNode.content, `ib.${WA_NODE_TAGS.EDGE_ROUTING}.${WA_NODE_TAGS.ROUTING_INFO}`);
390
390
  await this.runtime.persistRoutingInfo(routingInfo);
391
- this.logger.info('updated routing info from info bulletin', {
391
+ this.logger.debug('updated routing info from info bulletin', {
392
392
  byteLength: routingInfo.byteLength
393
393
  });
394
394
  }
@@ -166,7 +166,7 @@ export class WaRetryCoordinator {
166
166
  };
167
167
  }
168
168
  async sendDecryptFailureRetryReceipt(context, prepared) {
169
- const recipient = context.recipient ?? this.resolvePeerRetryRecipient(context);
169
+ const { recipient } = context;
170
170
  const retryReceiptNode = buildRetryReceiptNode({
171
171
  stanzaId: context.stanzaId,
172
172
  to: context.from,
@@ -191,26 +191,6 @@ export class WaRetryCoordinator {
191
191
  withKeys: prepared.retryKeys !== undefined
192
192
  });
193
193
  }
194
- resolvePeerRetryRecipient(context) {
195
- if (!context.participant) {
196
- return undefined;
197
- }
198
- const meLid = this.deps.getCurrentCredentials()?.meLid;
199
- if (!meLid) {
200
- return undefined;
201
- }
202
- try {
203
- const participantUser = toUserJid(context.participant);
204
- const meUserLid = toUserJid(meLid);
205
- if (participantUser !== meUserLid) {
206
- return undefined;
207
- }
208
- return meUserLid;
209
- }
210
- catch {
211
- return undefined;
212
- }
213
- }
214
194
  async handleParsedRetryRequest(receiptNode, request) {
215
195
  if (request.type === WA_MESSAGE_TYPES.RECEIPT_TYPE_ENC_REKEY_RETRY) {
216
196
  this.deps.logger.debug('received enc_rekey_retry request (voip path deferred)', {
@@ -82,12 +82,14 @@ export function extractIgnoreKeyContext(node, meJid) {
82
82
  const me = tryParseJid(meJid);
83
83
  const fromCandidates = collectFromCandidates(kind, a);
84
84
  const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
85
+ // Device-stripped to match the JID form used by events/keys; a userless
86
+ // server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
85
87
  return {
86
88
  kind,
87
- remoteJid: a.from ?? null,
89
+ remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
88
90
  fromMe,
89
91
  id: a.id,
90
- participant: a.participant ?? null
92
+ participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
91
93
  };
92
94
  }
93
95
  /** Pure matcher. Exported for direct testing without a coordinator. */
@@ -229,7 +229,7 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
229
229
  encPayload: decoded.encPayload
230
230
  }
231
231
  };
232
- const chatJid = node.attrs.from;
232
+ const chatJid = node.attrs.from ? toUserJid(node.attrs.from) : node.attrs.from;
233
233
  const sender = senderJid ? parseJidFull(senderJid) : null;
234
234
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
235
235
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
@@ -302,7 +302,11 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
302
302
  }
303
303
  }
304
304
  if (shouldEmitIncomingMessage(message)) {
305
- const chatJid = node.attrs.from;
305
+ // remoteJid is the chat identity, which is deviceless: the device
306
+ // lives in senderDevice (from senderAddress), so strip any `:device`
307
+ // segment the `from` attr carries for 1:1 chats.
308
+ const fromAttr = node.attrs.from;
309
+ const chatJid = fromAttr ? toUserJid(fromAttr) : fromAttr;
306
310
  const isGroup = chatJid ? isGroupJid(chatJid) : false;
307
311
  const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
308
312
  const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
@@ -169,6 +169,10 @@ export class WaComms {
169
169
  await this.socket.close(1000, 'stop_comms');
170
170
  }
171
171
  async closeSocketAndResume() {
172
+ if (!this.started || this.preventRetry) {
173
+ this.logger.debug('comms resume skipped: comms stopped or retry disabled');
174
+ return;
175
+ }
172
176
  this.logger.debug('comms close socket and resume requested');
173
177
  this.resetConnectionState({
174
178
  started: true,
@@ -101,6 +101,10 @@ export class WaKeepAlive {
101
101
  }
102
102
  }
103
103
  catch (error) {
104
+ if (generation !== this.generation) {
105
+ this.logger.trace('keepalive stopped during in-flight ping, not resuming');
106
+ return;
107
+ }
104
108
  this.logger.warn('keepalive ping failed, reconnecting socket', {
105
109
  message: toError(error).message
106
110
  });
@@ -233,7 +233,7 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
233
233
  encPayload: decoded.encPayload
234
234
  }
235
235
  };
236
- const chatJid = node.attrs.from;
236
+ const chatJid = node.attrs.from ? (0, jid_1.toUserJid)(node.attrs.from) : node.attrs.from;
237
237
  const sender = senderJid ? (0, jid_1.parseJidFull)(senderJid) : null;
238
238
  const isGroup = chatJid ? (0, jid_1.isGroupJid)(chatJid) : false;
239
239
  const isBroadcast = chatJid ? (0, jid_1.isBroadcastJid)(chatJid) : false;
@@ -306,7 +306,11 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
306
306
  }
307
307
  }
308
308
  if (shouldEmitIncomingMessage(message)) {
309
- const chatJid = node.attrs.from;
309
+ // remoteJid is the chat identity, which is deviceless: the device
310
+ // lives in senderDevice (from senderAddress), so strip any `:device`
311
+ // segment the `from` attr carries for 1:1 chats.
312
+ const fromAttr = node.attrs.from;
313
+ const chatJid = fromAttr ? (0, jid_1.toUserJid)(fromAttr) : fromAttr;
310
314
  const isGroup = chatJid ? (0, jid_1.isGroupJid)(chatJid) : false;
311
315
  const isBroadcast = chatJid ? (0, jid_1.isBroadcastJid)(chatJid) : false;
312
316
  const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
@@ -172,6 +172,10 @@ class WaComms {
172
172
  await this.socket.close(1000, 'stop_comms');
173
173
  }
174
174
  async closeSocketAndResume() {
175
+ if (!this.started || this.preventRetry) {
176
+ this.logger.debug('comms resume skipped: comms stopped or retry disabled');
177
+ return;
178
+ }
175
179
  this.logger.debug('comms close socket and resume requested');
176
180
  this.resetConnectionState({
177
181
  started: true,
@@ -104,6 +104,10 @@ class WaKeepAlive {
104
104
  }
105
105
  }
106
106
  catch (error) {
107
+ if (generation !== this.generation) {
108
+ this.logger.trace('keepalive stopped during in-flight ping, not resuming');
109
+ return;
110
+ }
107
111
  this.logger.warn('keepalive ping failed, reconnecting socket', {
108
112
  message: (0, primitives_1.toError)(error).message
109
113
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zapo-js",
3
- "version": "1.1.0",
3
+ "version": "1.1.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)",