zapo-js 1.9.0 → 1.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +14 -13
  2. package/dist/auth/WaAuthClient.d.ts +5 -0
  3. package/dist/auth/WaAuthClient.js +5 -0
  4. package/dist/auth/pairing/WaPairingFlow.d.ts +11 -0
  5. package/dist/auth/pairing/WaPairingFlow.js +97 -40
  6. package/dist/client/WaClient.d.ts +4 -0
  7. package/dist/client/WaClient.js +7 -1
  8. package/dist/client/WaClientFactory.js +6 -5
  9. package/dist/client/connection/WaConnectionManager.d.ts +14 -2
  10. package/dist/client/connection/WaConnectionManager.js +55 -22
  11. package/dist/client/coordinators/WaMessageCoordinator.d.ts +27 -8
  12. package/dist/client/coordinators/WaMessageCoordinator.js +4 -8
  13. package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +13 -1
  14. package/dist/client/coordinators/WaMessageDispatchCoordinator.js +19 -6
  15. package/dist/client/coordinators/WaProfileCoordinator.d.ts +5 -1
  16. package/dist/client/coordinators/WaStreamControlCoordinator.d.ts +2 -0
  17. package/dist/client/coordinators/WaStreamControlCoordinator.js +6 -1
  18. package/dist/client/messaging/ignore-key.d.ts +2 -1
  19. package/dist/client/messaging/ignore-key.js +24 -3
  20. package/dist/client/messaging/messages.js +24 -8
  21. package/dist/client/persistence/history-sync.d.ts +5 -1
  22. package/dist/client/persistence/history-sync.js +172 -38
  23. package/dist/client/types.d.ts +65 -10
  24. package/dist/esm/auth/WaAuthClient.js +5 -0
  25. package/dist/esm/auth/pairing/WaPairingFlow.js +97 -40
  26. package/dist/esm/client/WaClient.js +7 -1
  27. package/dist/esm/client/WaClientFactory.js +6 -5
  28. package/dist/esm/client/connection/WaConnectionManager.js +55 -22
  29. package/dist/esm/client/coordinators/WaMessageCoordinator.js +4 -8
  30. package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +18 -6
  31. package/dist/esm/client/coordinators/WaStreamControlCoordinator.js +6 -1
  32. package/dist/esm/client/messaging/ignore-key.js +25 -4
  33. package/dist/esm/client/messaging/messages.js +25 -9
  34. package/dist/esm/client/persistence/history-sync.js +172 -38
  35. package/dist/esm/infra/log/types.js +2 -1
  36. package/dist/esm/message/context-info.js +2 -0
  37. package/dist/esm/message/encode/content.js +16 -0
  38. package/dist/esm/message/primitives/incoming.js +12 -5
  39. package/dist/esm/protocol/defaults.js +6 -0
  40. package/dist/esm/protocol/message.js +2 -1
  41. package/dist/esm/signal/api/SignalDeviceSyncApi.js +64 -32
  42. package/dist/esm/store/cache/identity.cache.js +30 -5
  43. package/dist/esm/store/cache/privacy-token.cache.js +30 -5
  44. package/dist/esm/store/cache/sender-key.cache.js +38 -7
  45. package/dist/esm/store/cache/session.cache.js +30 -4
  46. package/dist/esm/store/createStore.js +39 -4
  47. package/dist/esm/store/memory/identity.store.js +28 -4
  48. package/dist/esm/store/memory/privacy-token.store.js +24 -7
  49. package/dist/esm/store/memory/sender-key.store.js +36 -7
  50. package/dist/esm/store/memory/session.store.js +41 -7
  51. package/dist/esm/transport/WaComms.js +94 -16
  52. package/dist/esm/transport/WaWebSocket.js +79 -32
  53. package/dist/esm/transport/node/builders/message.js +1 -1
  54. package/dist/esm/transport/noise/WaNoiseSession.js +6 -5
  55. package/dist/esm/util/collections.js +94 -0
  56. package/dist/esm/util/index.js +2 -2
  57. package/dist/index.d.ts +3 -3
  58. package/dist/infra/log/types.d.ts +1 -0
  59. package/dist/infra/log/types.js +2 -1
  60. package/dist/message/context-info.js +2 -0
  61. package/dist/message/encode/content.d.ts +3 -2
  62. package/dist/message/encode/content.js +17 -0
  63. package/dist/message/primitives/incoming.js +12 -5
  64. package/dist/message/primitives/media-retry.d.ts +11 -0
  65. package/dist/message/types.d.ts +21 -12
  66. package/dist/protocol/defaults.d.ts +6 -0
  67. package/dist/protocol/defaults.js +6 -0
  68. package/dist/protocol/message.d.ts +1 -0
  69. package/dist/protocol/message.js +2 -1
  70. package/dist/signal/api/SignalDeviceSyncApi.d.ts +27 -5
  71. package/dist/signal/api/SignalDeviceSyncApi.js +64 -32
  72. package/dist/store/cache/identity.cache.d.ts +6 -4
  73. package/dist/store/cache/identity.cache.js +30 -5
  74. package/dist/store/cache/privacy-token.cache.d.ts +6 -4
  75. package/dist/store/cache/privacy-token.cache.js +30 -5
  76. package/dist/store/cache/sender-key.cache.d.ts +5 -3
  77. package/dist/store/cache/sender-key.cache.js +38 -7
  78. package/dist/store/cache/session.cache.d.ts +6 -3
  79. package/dist/store/cache/session.cache.js +30 -4
  80. package/dist/store/createStore.js +39 -4
  81. package/dist/store/memory/identity.store.d.ts +7 -1
  82. package/dist/store/memory/identity.store.js +28 -4
  83. package/dist/store/memory/privacy-token.store.d.ts +7 -1
  84. package/dist/store/memory/privacy-token.store.js +24 -7
  85. package/dist/store/memory/sender-key.store.d.ts +12 -2
  86. package/dist/store/memory/sender-key.store.js +36 -7
  87. package/dist/store/memory/session.store.d.ts +7 -1
  88. package/dist/store/memory/session.store.js +41 -7
  89. package/dist/store/types.d.ts +43 -4
  90. package/dist/transport/WaComms.d.ts +9 -1
  91. package/dist/transport/WaComms.js +94 -16
  92. package/dist/transport/WaWebSocket.d.ts +8 -2
  93. package/dist/transport/WaWebSocket.js +79 -32
  94. package/dist/transport/node/builders/message.js +1 -1
  95. package/dist/transport/noise/WaNoiseSession.js +6 -5
  96. package/dist/util/collections.d.ts +46 -0
  97. package/dist/util/collections.js +98 -0
  98. package/dist/util/index.d.ts +2 -2
  99. package/dist/util/index.js +3 -1
  100. package/package.json +1 -1
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.WaMessageDispatchCoordinator = void 0;
4
4
  exports.restrictGroupHistoryTargets = restrictGroupHistoryTargets;
5
+ exports.resolveOutboundStanzaShape = resolveOutboundStanzaShape;
5
6
  const _crypto_1 = require("../../crypto");
6
7
  const primitives_1 = require("../../crypto/core/primitives");
7
8
  const PromiseDedup_1 = require("../../infra/perf/PromiseDedup");
@@ -73,6 +74,19 @@ function restrictGroupHistoryTargets(message, participantUserJids, senderJid, on
73
74
  }
74
75
  return targets;
75
76
  }
77
+ /**
78
+ * Resolves the stanza `type` and `enc.mediatype` of an outbound message. A
79
+ * `<biz>` companion rides a type=text stanza with no mediatype, except on a
80
+ * group list: there the server nacks with 479 (SMAX_INVALID) unless
81
+ * type=media, mediatype=list and `<biz><list>` travel together.
82
+ */
83
+ function resolveOutboundStanzaShape(attrs, isGroup) {
84
+ const flattensForAddon = attrs.buttonAddonKind !== null && !(isGroup && attrs.buttonAddonKind === 'list');
85
+ if (flattensForAddon) {
86
+ return { type: 'text', mediatype: undefined };
87
+ }
88
+ return { type: attrs.typeAttr, mediatype: attrs.mediatype ?? undefined };
89
+ }
76
90
  class WaMessageDispatchCoordinator {
77
91
  constructor(options) {
78
92
  this.icdcDedup = new PromiseDedup_1.PromiseDedup();
@@ -403,12 +417,8 @@ class WaMessageDispatchCoordinator {
403
417
  const outboundAttrs = (0, content_1.resolveOutboundMessageAttrs)(messageWithIcdc);
404
418
  const buttonAddonKind = outboundAttrs.buttonAddonKind;
405
419
  const buttonAddonNode = buttonAddonKind ? (0, message_1.buildButtonAddonNode)(buttonAddonKind) : undefined;
406
- // when a <biz> companion is attached the stanza must advertise type=text and
407
- // omit enc.mediatype; sending type=media + mediatype=list/button alongside the
408
- // companion is rejected by the server as SMAX_INVALID (479).
409
- const type = buttonAddonKind ? 'text' : outboundAttrs.typeAttr;
420
+ const { type, mediatype } = resolveOutboundStanzaShape(outboundAttrs, isGroup);
410
421
  const edit = outboundAttrs.edit ?? undefined;
411
- const mediatype = buttonAddonKind ? undefined : (outboundAttrs.mediatype ?? undefined);
412
422
  const metaAttrs = outboundAttrs.metaAttrs;
413
423
  const metaNode = metaAttrs ? (0, message_1.buildMetaNode)(metaAttrs) : undefined;
414
424
  const customNodes = [];
@@ -473,11 +483,14 @@ class WaMessageDispatchCoordinator {
473
483
  const pair = await this.deps.signalDeviceSync.resolveUserJidPair(directRecipientJid);
474
484
  return pair.pnJid ?? undefined;
475
485
  }
476
- async syncSignalSession(jid, reasonIdentity = false) {
486
+ async syncSignalSession(jid, { force = false, reasonIdentity = false } = {}) {
477
487
  const address = (0, jid_1.parseSignalAddressFromJid)(jid);
478
488
  if (address.server === constants_1.WA_DEFAULTS.GROUP_SERVER) {
479
489
  throw new Error('syncSignalSession supports only direct chats');
480
490
  }
491
+ if (force || reasonIdentity) {
492
+ await this.deps.sessionStore.deleteSession(address);
493
+ }
481
494
  await this.deps.sessionResolver.ensureSession(address, jid, undefined, reasonIdentity);
482
495
  }
483
496
  async sendReceipt(input) {
@@ -158,7 +158,11 @@ export interface WaProfileCoordinator {
158
158
  * @throws when `pin` is not exactly 4 digits, before any round-trip.
159
159
  */
160
160
  readonly setUsernameKey: (pin: string) => Promise<boolean>;
161
- /** Resolves LIDs for a list of phone numbers (handles normalization). */
161
+ /**
162
+ * Resolves LIDs for a list of phone numbers (handles normalization). Check
163
+ * `status`: `'out'` means no WhatsApp account, `'unanswered'` or `'error'` mean
164
+ * the server gave no verdict (see `error` for the reason).
165
+ */
162
166
  readonly getLidsByPhoneNumbers: (phoneNumbers: readonly string[]) => Promise<readonly SignalLidSyncResult[]>;
163
167
  }
164
168
  interface WaProfileCoordinatorOptions {
@@ -10,6 +10,8 @@ interface WaStreamControlCoordinatorOptions {
10
10
  readonly disconnect: (reason: WaDisconnectReason, isLogout: boolean, code: WaConnectionCode | null) => Promise<void>;
11
11
  readonly clearStoredCredentials: () => Promise<void>;
12
12
  readonly connect: (reason: WaConnectionOpenReason) => Promise<void>;
13
+ /** Returns `true` when a pairing restart already owns the force-login `stream:error`. */
14
+ readonly handlePairingRestart: () => boolean;
13
15
  }
14
16
  export interface WaStreamControlHandler {
15
17
  readonly handleStreamControlResult: (result: WaStreamControlNodeResult) => Promise<void>;
@@ -4,7 +4,7 @@ exports.createStreamControlHandler = createStreamControlHandler;
4
4
  const constants_1 = require("../../protocol/constants");
5
5
  const primitives_1 = require("../../util/primitives");
6
6
  function createStreamControlHandler(options) {
7
- const { logger, getComms, clearPendingQueries, clearMediaConnCache, disconnect, clearStoredCredentials, connect } = options;
7
+ const { logger, getComms, clearPendingQueries, clearMediaConnCache, disconnect, clearStoredCredentials, connect, handlePairingRestart } = options;
8
8
  let lifecyclePromise = null;
9
9
  const runStreamControlLifecycle = (reason, action) => {
10
10
  if (lifecyclePromise) {
@@ -88,6 +88,11 @@ function createStreamControlHandler(options) {
88
88
  logger.info('received xmlstreamend stanza');
89
89
  return;
90
90
  case 'stream_error_code':
91
+ if (result.code === constants_1.WA_STREAM_SIGNALING.FORCE_LOGIN_CODE &&
92
+ handlePairingRestart()) {
93
+ logger.info('stream:error force login claimed by the pairing restart');
94
+ return;
95
+ }
91
96
  logger.warn('received stream:error with code', { code: result.code });
92
97
  if (result.code >= 500 && result.code < 600) {
93
98
  if (result.code === constants_1.WA_STREAM_SIGNALING.FORCE_LOGIN_CODE) {
@@ -1,3 +1,4 @@
1
+ import type { WaAuthCredentials } from '../../auth/types';
1
2
  import type { WaIgnoreKey, WaIgnoreKeyContext, WaIgnoreKeyPredicate, WaIncomingStanzaFilter } from '../types';
2
3
  import type { BinaryNode } from '../../transport/types';
3
4
  export declare function validateIgnoreKey(d: WaIgnoreKey): void;
@@ -8,4 +9,4 @@ export declare function validateIgnoreKey(d: WaIgnoreKey): void;
8
9
  export declare function extractIgnoreKeyContext(node: BinaryNode, meJid: string | null | undefined): WaIgnoreKeyContext | null;
9
10
  /** Pure matcher. Exported for direct testing without a coordinator. */
10
11
  export declare function matchesIgnoreKey(node: BinaryNode, d: WaIgnoreKey, meJid: string | null | undefined): boolean;
11
- export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMeJid: () => string | null | undefined): WaIncomingStanzaFilter;
12
+ export declare function createIgnoreKeyFilter(input: WaIgnoreKey | WaIgnoreKeyPredicate, getMe: () => Pick<WaAuthCredentials, 'meJid' | 'meLid'> | null | undefined): WaIncomingStanzaFilter;
@@ -123,12 +123,33 @@ function matchesIgnoreKey(node, d, meJid) {
123
123
  return false;
124
124
  return true;
125
125
  }
126
- function createIgnoreKeyFilter(input, getMeJid) {
126
+ /**
127
+ * A `category='peer'` message from our own account is traffic between our own
128
+ * devices (app-state key share, history sync, PDO responses), not a chat
129
+ * message, so it has no key to ignore by. Dropping it starves the protocol:
130
+ * app-state collections stay blocked on a key whose share never gets through.
131
+ * The own-account check keeps a foreign sender from dodging a filter by
132
+ * stamping the attribute.
133
+ */
134
+ function isOwnPeerMessage(node, me) {
135
+ if (node.tag !== message_1.WA_MESSAGE_TAGS.MESSAGE || node.attrs.category !== 'peer' || !me) {
136
+ return false;
137
+ }
138
+ const from = node.attrs.from;
139
+ return !!from && tryParseJid(from) !== null && (0, jid_1.isOwnAccountJid)(from, me.meJid, me.meLid);
140
+ }
141
+ function createIgnoreKeyFilter(input, getMe) {
127
142
  if (typeof input === 'function') {
128
143
  return (node) => {
129
- const ctx = extractIgnoreKeyContext(node, getMeJid());
144
+ const me = getMe();
145
+ if (isOwnPeerMessage(node, me))
146
+ return false;
147
+ const ctx = extractIgnoreKeyContext(node, me?.meJid);
130
148
  return ctx !== null && input(ctx);
131
149
  };
132
150
  }
133
- return (node) => matchesIgnoreKey(node, input, getMeJid());
151
+ return (node) => {
152
+ const me = getMe();
153
+ return !isOwnPeerMessage(node, me) && matchesIgnoreKey(node, input, me?.meJid);
154
+ };
134
155
  }
@@ -228,6 +228,27 @@ async function buildMediaMessageContent(options, content, ctx) {
228
228
  if (typeof content === 'string') {
229
229
  return { message: { conversation: content } };
230
230
  }
231
+ if ((0, content_1.isSendTypedMessage)(content)) {
232
+ return withMessageContextInfo(await buildTypedMessageContent(options, content, ctx), content.messageContextInfo);
233
+ }
234
+ if (!content || typeof content !== 'object') {
235
+ throw new Error('invalid message content');
236
+ }
237
+ return { message: content };
238
+ }
239
+ /** Adds the caller's `messageContextInfo` under the built one: fields the builder set win. */
240
+ function withMessageContextInfo(built, messageContextInfo) {
241
+ if (!messageContextInfo)
242
+ return built;
243
+ return {
244
+ ...built,
245
+ message: {
246
+ ...built.message,
247
+ messageContextInfo: { ...messageContextInfo, ...built.message.messageContextInfo }
248
+ }
249
+ };
250
+ }
251
+ async function buildTypedMessageContent(options, content, ctx) {
231
252
  if ((0, content_1.isSendTextMessage)(content)) {
232
253
  if (options.linkPreviewResolver) {
233
254
  try {
@@ -267,13 +288,7 @@ async function buildMediaMessageContent(options, content, ctx) {
267
288
  if ((0, content_1.isSendEventResponseMessage)(content)) {
268
289
  return { message: await buildEventResponseMessage(content, options.serverClock, ctx) };
269
290
  }
270
- if ((0, content_1.isSendMediaMessage)(content)) {
271
- return buildMediaMessage(options, content);
272
- }
273
- if (!content || typeof content !== 'object') {
274
- throw new Error('invalid message content');
275
- }
276
- return { message: content };
291
+ return buildMediaMessage(options, content);
277
292
  }
278
293
  async function getMediaConn(options, forceRefresh = false) {
279
294
  const cached = options.getMediaConnCache();
@@ -394,7 +409,8 @@ async function buildMediaMessage(options, content) {
394
409
  if (key !== 'type' &&
395
410
  key !== 'media' &&
396
411
  key !== 'fileLength' &&
397
- key !== 'mimetype') {
412
+ key !== 'mimetype' &&
413
+ key !== 'messageContextInfo') {
398
414
  result[key] = c[key];
399
415
  }
400
416
  }
@@ -1,5 +1,5 @@
1
1
  import type { WriteBehindPersistence } from '../persistence/WriteBehindPersistence';
2
- import type { WaClientEventMap } from '../types';
2
+ import type { WaClientEventMap, WaHistorySyncSink } from '../types';
3
3
  import type { Logger } from '../../infra/log/types';
4
4
  import type { WaMediaTransferClient } from '../../media/transfer/WaMediaTransferClient';
5
5
  import { type Proto } from '../../proto';
@@ -27,6 +27,9 @@ interface WaHistorySyncDeps {
27
27
  readonly logger: Logger;
28
28
  readonly mediaTransfer: WaMediaTransferClient;
29
29
  readonly writeBehind: WriteBehindPersistence;
30
+ /** Id of the protocol message that carried the notification; empty when the caller has none. */
31
+ readonly chunkId?: string;
32
+ readonly sink?: WaHistorySyncSink;
30
33
  readonly chatMetadataStore?: WaChatMetadataStore;
31
34
  readonly emitEvent: <K extends keyof WaClientEventMap>(event: K, ...args: Parameters<WaClientEventMap[K]>) => void;
32
35
  readonly onPrivacyTokens?: (conversations: readonly WaHistorySyncPrivacyTokenConversation[]) => Promise<void>;
@@ -41,6 +44,7 @@ export declare function runHistorySyncNotification(deps: WaHistorySyncDeps, noti
41
44
  * Applies one history sync chunk while it downloads, descending into
42
45
  * `conversations` so peak memory tracks the largest single message and not the
43
46
  * chunk. Chunk-wide records settle after the stream, once every LID mapping is in.
47
+ * `INITIAL_STATUS_V3` is processed only for a sink and never emits `history_sync_chunk`.
44
48
  */
45
49
  export declare function processHistorySyncNotification(deps: WaHistorySyncDeps, notification: Proto.Message.IHistorySyncNotification): Promise<void>;
46
50
  export {};
@@ -40,6 +40,26 @@ exports.HISTORY_SYNC_FIELDS = Object.freeze({
40
40
  NCT_SALT: 19,
41
41
  INLINE_CONTACTS: 20
42
42
  });
43
+ const HISTORY_SYNC_SINK_KEYS = new Map([
44
+ [3, 'statusV3Messages'],
45
+ [5, 'chunkOrder'],
46
+ [6, 'progress'],
47
+ [7, 'pushnames'],
48
+ [8, 'globalSettings'],
49
+ [9, 'threadIdUserSecret'],
50
+ [10, 'threadDsTimeframeOffset'],
51
+ [11, 'recentStickers'],
52
+ [12, 'pastParticipants'],
53
+ [13, 'callLogRecords'],
54
+ [14, 'aiWaitListState'],
55
+ [15, 'phoneNumberToLidMappings'],
56
+ [16, 'companionMetaNonce'],
57
+ [17, 'shareableChatIdentifierEncryptionKey'],
58
+ [18, 'accounts'],
59
+ [19, 'nctSalt'],
60
+ [20, 'inlineContacts'],
61
+ [21, 'inlineContactsProvided']
62
+ ]);
43
63
  exports.CONVERSATION_FIELDS = Object.freeze({
44
64
  ID: 1,
45
65
  MESSAGES: 2
@@ -60,6 +80,7 @@ async function runHistorySyncNotification(deps, notification) {
60
80
  * Applies one history sync chunk while it downloads, descending into
61
81
  * `conversations` so peak memory tracks the largest single message and not the
62
82
  * chunk. Chunk-wide records settle after the stream, once every LID mapping is in.
83
+ * `INITIAL_STATUS_V3` is processed only for a sink and never emits `history_sync_chunk`.
63
84
  */
64
85
  async function processHistorySyncNotification(deps, notification) {
65
86
  const syncType = notification.syncType;
@@ -67,15 +88,17 @@ async function processHistorySyncNotification(deps, notification) {
67
88
  deps.logger.debug('skipping history sync notification without syncType');
68
89
  return;
69
90
  }
70
- if (!HANDLED_SYNC_TYPES.has(syncType)) {
91
+ const isStatusV3 = syncType === _proto_1.proto.Message.HistorySyncType.INITIAL_STATUS_V3;
92
+ if (!HANDLED_SYNC_TYPES.has(syncType) && !(isStatusV3 && deps.sink)) {
71
93
  deps.logger.debug('skipping unhandled history sync type', { syncType });
72
- if (syncType === _proto_1.proto.Message.HistorySyncType.INITIAL_STATUS_V3 && deps.onProcessed) {
94
+ if (isStatusV3 && deps.onProcessed) {
73
95
  await deps.onProcessed(syncType);
74
96
  }
75
97
  return;
76
98
  }
77
99
  const blob = await (0, history_blob_1.openHistoryBlobStream)(deps.mediaTransfer, notification, 'history', 'history sync', notification.initialHistBootstrapInlinePayload);
78
100
  const state = {
101
+ chunk: { id: deps.chunkId ?? '', syncType },
79
102
  nowMs: Date.now(),
80
103
  pendingWrites: [],
81
104
  pushnames: [],
@@ -88,6 +111,23 @@ async function processHistorySyncNotification(deps, notification) {
88
111
  conversationsCount: 0,
89
112
  messagesCount: 0
90
113
  };
114
+ let event;
115
+ try {
116
+ event = await applyHistorySyncChunk(deps, blob, state);
117
+ }
118
+ catch (error) {
119
+ const failure = (0, primitives_1.toError)(error);
120
+ await abortHistorySink(deps, state.chunk, failure);
121
+ throw failure;
122
+ }
123
+ if (!isStatusV3) {
124
+ deps.emitEvent('history_sync_chunk', event);
125
+ }
126
+ if (deps.onProcessed) {
127
+ await deps.onProcessed(syncType);
128
+ }
129
+ }
130
+ async function applyHistorySyncChunk(deps, blob, state) {
91
131
  try {
92
132
  await consumeHistorySyncStream(deps, blob.inflated, state);
93
133
  }
@@ -100,7 +140,7 @@ async function processHistorySyncNotification(deps, notification) {
100
140
  await blob.verified;
101
141
  await settleHistorySyncChunk(deps, state);
102
142
  deps.logger.info('decoded history sync chunk', {
103
- syncType,
143
+ syncType: state.chunk.syncType,
104
144
  chunkOrder: state.chunkOrder,
105
145
  progress: state.progress,
106
146
  conversations: state.conversationsCount,
@@ -109,7 +149,8 @@ async function processHistorySyncNotification(deps, notification) {
109
149
  inlineContacts: state.inlineContacts.length
110
150
  });
111
151
  const event = {
112
- syncType,
152
+ id: state.chunk.id,
153
+ syncType: state.chunk.syncType,
113
154
  messagesCount: state.messagesCount,
114
155
  conversationsCount: state.conversationsCount,
115
156
  pushnamesCount: state.pushnames.length,
@@ -118,15 +159,53 @@ async function processHistorySyncNotification(deps, notification) {
118
159
  progress: state.progress ?? undefined
119
160
  };
120
161
  await (0, history_blob_1.flushPendingWrites)(state.pendingWrites);
121
- deps.emitEvent('history_sync_chunk', event);
122
- if (deps.onProcessed) {
123
- await deps.onProcessed(syncType);
162
+ if (deps.sink?.commit) {
163
+ await deps.sink.commit(event);
124
164
  }
165
+ return event;
166
+ }
167
+ /** A throwing `abort` is logged so it never hides the failure that triggered it. */
168
+ async function abortHistorySink(deps, chunk, error) {
169
+ if (!deps.sink?.abort) {
170
+ return;
171
+ }
172
+ try {
173
+ await deps.sink.abort(chunk, error);
174
+ }
175
+ catch (abortError) {
176
+ deps.logger.warn('history sync sink abort failed', {
177
+ id: chunk.id,
178
+ message: (0, primitives_1.toError)(abortError).message
179
+ });
180
+ }
181
+ }
182
+ /** Only a thenable result is surfaced, so a synchronous sink never adds an await. */
183
+ function deliverHistoryItem(sink, chunk, item) {
184
+ const result = sink.item(item, chunk);
185
+ return typeof result?.then === 'function'
186
+ ? Promise.resolve(result)
187
+ : undefined;
188
+ }
189
+ function deliverHistoryValue(sink, chunk, key, value) {
190
+ return deliverHistoryItem(sink, chunk, { kind: key, value });
191
+ }
192
+ /**
193
+ * Delivers a `HistorySync` field the stores do not consume. The field is decoded
194
+ * from a copy because the reader buffer is reused and `bytes` values are views of
195
+ * their input; one occurrence of a repeated field yields a single element.
196
+ */
197
+ function deliverHistoryField(sink, chunk, key, event) {
198
+ const decoded = _proto_1.proto.HistorySync.decode(event.raw.slice())[key];
199
+ const value = Array.isArray(decoded) ? decoded[0] : decoded;
200
+ return value === null || value === undefined
201
+ ? undefined
202
+ : deliverHistoryValue(sink, chunk, key, value);
125
203
  }
126
204
  async function consumeHistorySyncStream(deps, inflated, state) {
127
205
  let headerParts = null;
128
206
  let threadJid = null;
129
207
  let parked = null;
208
+ const sink = deps.sink;
130
209
  const onEvent = (event) => {
131
210
  if (event.kind === proto_stream_1.PROTO_STREAM_EVENT_KINDS.ENTER) {
132
211
  headerParts = [];
@@ -147,20 +226,37 @@ async function consumeHistorySyncStream(deps, inflated, state) {
147
226
  if (event.depth === 1) {
148
227
  if (event.fieldNumber === exports.CONVERSATION_FIELDS.MESSAGES &&
149
228
  event.wireType === protoscan_1.PROTO_WIRE_TYPES.LEN) {
150
- const message = readHistoryMessage(event.value);
151
- if (!message) {
229
+ const record = _proto_1.proto.HistorySyncMsg.decode(sink ? event.value.slice() : event.value);
230
+ const webMsg = record.message;
231
+ if (!webMsg && !sink) {
152
232
  return undefined;
153
233
  }
234
+ const stored = webMsg ? toStoredHistoryMessage(webMsg) : null;
154
235
  if (!threadJid) {
236
+ if (!stored && !sink) {
237
+ return undefined;
238
+ }
155
239
  parked ?? (parked = []);
156
240
  if (parked.length >= HISTORY_SYNC_MAX_PARKED_MESSAGES) {
157
241
  throw new Error(`history sync parked ${parked.length} messages with no thread jid, exceeding the ${HISTORY_SYNC_MAX_PARKED_MESSAGES} limit`);
158
242
  }
159
- parked[parked.length] = message;
243
+ parked[parked.length] = { stored, record: sink ? record : null };
160
244
  return undefined;
161
245
  }
162
- persistHistoryMessage(deps, state, message, threadJid);
163
- return maybeFlush(state);
246
+ if (stored) {
247
+ persistHistoryMessage(deps, state, stored, threadJid);
248
+ }
249
+ const delivery = sink
250
+ ? deliverHistoryItem(sink, state.chunk, {
251
+ kind: 'conversations.messages',
252
+ conversationId: threadJid,
253
+ value: record
254
+ })
255
+ : undefined;
256
+ const flush = maybeFlush(state);
257
+ return delivery && flush
258
+ ? Promise.all([delivery, flush]).then(() => undefined)
259
+ : (delivery ?? flush);
164
260
  }
165
261
  if (headerParts) {
166
262
  if (event.fieldNumber === exports.CONVERSATION_FIELDS.ID &&
@@ -177,35 +273,52 @@ async function consumeHistorySyncStream(deps, inflated, state) {
177
273
  if (event.wireType === protoscan_1.PROTO_WIRE_TYPES.VARINT) {
178
274
  if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.CHUNK_ORDER) {
179
275
  state.chunkOrder = event.varintValue;
276
+ return sink
277
+ ? deliverHistoryValue(sink, state.chunk, 'chunkOrder', event.varintValue)
278
+ : undefined;
180
279
  }
181
280
  else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.PROGRESS) {
182
281
  state.progress = event.varintValue;
282
+ return sink
283
+ ? deliverHistoryValue(sink, state.chunk, 'progress', event.varintValue)
284
+ : undefined;
183
285
  }
184
- return undefined;
185
286
  }
186
- if (event.wireType !== protoscan_1.PROTO_WIRE_TYPES.LEN) {
187
- return undefined;
188
- }
189
- if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.PUSHNAMES) {
190
- state.pushnames[state.pushnames.length] = _proto_1.proto.Pushname.decode(event.value);
191
- }
192
- else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.PHONE_NUMBER_TO_LID_MAPPINGS) {
193
- const map = _proto_1.proto.PhoneNumberToLIDMapping.decode(event.value);
194
- if (map.pnJid && map.lidJid) {
195
- state.pnToLid.set(map.pnJid, map.lidJid);
287
+ else if (event.wireType === protoscan_1.PROTO_WIRE_TYPES.LEN) {
288
+ if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.PUSHNAMES) {
289
+ const pushname = _proto_1.proto.Pushname.decode(sink ? event.value.slice() : event.value);
290
+ state.pushnames[state.pushnames.length] = pushname;
291
+ return sink
292
+ ? deliverHistoryValue(sink, state.chunk, 'pushnames', pushname)
293
+ : undefined;
196
294
  }
197
- }
198
- else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.INLINE_CONTACTS) {
199
- const contact = _proto_1.proto.InlineContact.decode(event.value);
200
- state.inlineContacts[state.inlineContacts.length] = contact;
201
- if (contact.pnJid && contact.lidJid) {
202
- state.pnToLid.set(contact.pnJid, contact.lidJid);
295
+ else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.PHONE_NUMBER_TO_LID_MAPPINGS) {
296
+ const mapping = _proto_1.proto.PhoneNumberToLIDMapping.decode(sink ? event.value.slice() : event.value);
297
+ if (mapping.pnJid && mapping.lidJid) {
298
+ state.pnToLid.set(mapping.pnJid, mapping.lidJid);
299
+ }
300
+ return sink
301
+ ? deliverHistoryValue(sink, state.chunk, 'phoneNumberToLidMappings', mapping)
302
+ : undefined;
303
+ }
304
+ else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.INLINE_CONTACTS) {
305
+ const contact = _proto_1.proto.InlineContact.decode(sink ? event.value.slice() : event.value);
306
+ state.inlineContacts[state.inlineContacts.length] = contact;
307
+ if (contact.pnJid && contact.lidJid) {
308
+ state.pnToLid.set(contact.pnJid, contact.lidJid);
309
+ }
310
+ return sink
311
+ ? deliverHistoryValue(sink, state.chunk, 'inlineContacts', contact)
312
+ : undefined;
313
+ }
314
+ else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.NCT_SALT) {
315
+ const salt = event.value.slice();
316
+ state.nctSalt = salt;
317
+ return sink ? deliverHistoryValue(sink, state.chunk, 'nctSalt', salt) : undefined;
203
318
  }
204
319
  }
205
- else if (event.fieldNumber === exports.HISTORY_SYNC_FIELDS.NCT_SALT) {
206
- state.nctSalt = event.value.slice();
207
- }
208
- return undefined;
320
+ const key = sink ? HISTORY_SYNC_SINK_KEYS.get(event.fieldNumber) : undefined;
321
+ return sink && key ? deliverHistoryField(sink, state.chunk, key, event) : undefined;
209
322
  };
210
323
  await (0, proto_stream_1.streamProtoFields)(inflated, onEvent, {
211
324
  shouldDescend: (fieldNumber, depth) => depth === 0 && fieldNumber === exports.HISTORY_SYNC_FIELDS.CONVERSATIONS,
@@ -284,8 +397,30 @@ async function closeConversation(deps, state, headerParts, threadJid, parked) {
284
397
  }));
285
398
  }
286
399
  }
287
- for (const message of parked ?? []) {
288
- persistHistoryMessage(deps, state, message, resolvedJid);
400
+ const sink = deps.sink;
401
+ for (const entry of parked ?? []) {
402
+ if (entry.stored) {
403
+ persistHistoryMessage(deps, state, entry.stored, resolvedJid);
404
+ }
405
+ if (sink && entry.record) {
406
+ const delivery = deliverHistoryItem(sink, state.chunk, {
407
+ kind: 'conversations.messages',
408
+ conversationId: resolvedJid,
409
+ value: entry.record
410
+ });
411
+ if (delivery) {
412
+ await delivery;
413
+ }
414
+ }
415
+ }
416
+ if (sink) {
417
+ const delivery = deliverHistoryItem(sink, state.chunk, {
418
+ kind: 'conversations',
419
+ value: conversation
420
+ });
421
+ if (delivery) {
422
+ await delivery;
423
+ }
289
424
  }
290
425
  await maybeFlush(state);
291
426
  }
@@ -367,9 +502,8 @@ async function settleHistorySyncChunk(deps, state) {
367
502
  }
368
503
  }
369
504
  /** `null` for content-less stubs, which duplicate live notification stanzas. */
370
- function readHistoryMessage(record) {
371
- const webMsg = _proto_1.proto.HistorySyncMsg.decode(record).message;
372
- if (!webMsg?.key?.id || !webMsg.message) {
505
+ function toStoredHistoryMessage(webMsg) {
506
+ if (!webMsg.key?.id || !webMsg.message) {
373
507
  return null;
374
508
  }
375
509
  const timestampMs = (0, primitives_1.longToNumber)(webMsg.messageTimestamp) * 1000;
@@ -148,11 +148,12 @@ export interface WaClientOptions extends WaAuthClientOptions, WaAuthSocketOption
148
148
  * History-sync behavior. By default chunks pushed via
149
149
  * `historySyncNotification` (both the initial bootstrap and the
150
150
  * on-demand backfill triggered by `message.requestHistorySync`) are
151
- * downloaded and emitted as `history_sync_chunk` events. Set
152
- * `enabled: false` to drop them; `requireFullSync: true` asks the
153
- * primary device for a full history download instead of just recent;
154
- * `groupBundles: true` opts into the group-history bundle a member may
155
- * share after somebody joins a group.
151
+ * downloaded, written to the stores and emitted as `history_sync_chunk`
152
+ * events. `sink` streams the decoded content to your own callback, with
153
+ * or without stores. Set `enabled: false` to drop the chunks;
154
+ * `requireFullSync: true` asks the primary device for a full history
155
+ * download instead of just recent; `groupBundles: true` opts into the
156
+ * group-history bundle a member may share after somebody joins a group.
156
157
  */
157
158
  readonly history?: WaHistorySyncOptions;
158
159
  /**
@@ -313,11 +314,22 @@ export interface WaHistorySyncOptions {
313
314
  * Whether to process the `historySyncNotification` protocol messages
314
315
  * that WhatsApp pushes on first connect (initial bootstrap) and via
315
316
  * `message.requestHistorySync` (on-demand). On by default - set to
316
- * `false` to drop the chunks silently (useful when you do not persist
317
- * mailbox/threads/contacts and the conversation download would just
318
- * burn bandwidth).
317
+ * `false` to drop the chunks silently (and skip `sink`): the primary is
318
+ * still acked, but nothing is downloaded or delivered.
319
319
  */
320
320
  readonly enabled?: boolean;
321
+ /**
322
+ * Receives every field of the decoded history as it streams, awaited per item,
323
+ * even with every store off; it alone receives the `INITIAL_STATUS_V3` chunk,
324
+ * which is otherwise only acked and never emits `history_sync_chunk`. A slow
325
+ * sink pauses the download: the media timeout ends at the headers.
326
+ */
327
+ readonly sink?: WaHistorySyncSink;
328
+ /**
329
+ * When `true`, request a full history download from the primary device on
330
+ * pairing instead of just recent messages. Takes precedence over the
331
+ * top-level `requireFullSync`; has no effect on an already-paired session.
332
+ */
321
333
  readonly requireFullSync?: boolean;
322
334
  /**
323
335
  * Whether to download and process the group-history bundle a member may
@@ -1101,9 +1113,51 @@ export interface WaGroupEvent extends WaIncomingBaseEvent {
1101
1113
  /** Decoded payload bag for actions without dedicated fields above. */
1102
1114
  readonly details?: Readonly<Record<string, unknown>>;
1103
1115
  }
1104
- export interface WaHistorySyncChunkEvent {
1116
+ /** Identifies one history-sync chunk; chunks can be processed concurrently. */
1117
+ export interface WaHistorySyncChunkRef {
1118
+ /** Id of the protocol message that carried the notification. */
1119
+ readonly id: string;
1105
1120
  /** Numeric value of `proto.Message.HistorySyncType` (`RECENT`, `ON_DEMAND`, …). */
1106
1121
  readonly syncType: number;
1122
+ }
1123
+ type WaHistorySyncElement<T> = T extends readonly (infer E)[] ? E : T;
1124
+ type WaHistorySyncKey = Exclude<keyof Proto.IHistorySync, 'syncType' | 'conversations' | '$unknowns'>;
1125
+ /**
1126
+ * One decoded record of a history-sync chunk. `kind` is the `HistorySync` field it
1127
+ * came from, one element per item for repeated fields; `syncType` rides on the chunk ref.
1128
+ * Conversation messages precede their metadata on the wire, so they stream out first as
1129
+ * `conversations.messages` and the conversation arrives at its close, without `messages`.
1130
+ */
1131
+ export type WaHistorySyncItem = {
1132
+ [K in WaHistorySyncKey]-?: {
1133
+ readonly kind: K;
1134
+ readonly value: NonNullable<WaHistorySyncElement<NonNullable<Proto.IHistorySync[K]>>>;
1135
+ };
1136
+ }[WaHistorySyncKey] | {
1137
+ readonly kind: 'conversations';
1138
+ readonly value: Omit<Proto.IConversation, 'messages'>;
1139
+ } | {
1140
+ readonly kind: 'conversations.messages';
1141
+ readonly conversationId: string;
1142
+ /** Includes stubs and records without content, which the stores skip. */
1143
+ readonly value: Proto.IHistorySyncMsg;
1144
+ };
1145
+ export interface WaHistorySyncSink {
1146
+ /**
1147
+ * Called per record in wire order. A returned promise pauses the stream until
1148
+ * it settles; a throw or rejection aborts the chunk. Items arrive before the
1149
+ * chunk MAC is verified.
1150
+ */
1151
+ item(item: WaHistorySyncItem, chunk: WaHistorySyncChunkRef): void | Promise<void>;
1152
+ /** Called once the chunk verified and applied; the receipt goes out after it resolves. */
1153
+ commit?(chunk: WaHistorySyncChunkEvent): void | Promise<void>;
1154
+ /**
1155
+ * Called when the chunk fails after items may have been delivered. The primary
1156
+ * resends it, so items repeat: dedupe by `key.id`.
1157
+ */
1158
+ abort?(chunk: WaHistorySyncChunkRef, error: Error): void | Promise<void>;
1159
+ }
1160
+ export interface WaHistorySyncChunkEvent extends WaHistorySyncChunkRef {
1107
1161
  readonly messagesCount: number;
1108
1162
  readonly conversationsCount: number;
1109
1163
  readonly pushnamesCount: number;
@@ -1209,7 +1263,8 @@ export interface WaClientEventMap {
1209
1263
  }) => void;
1210
1264
  /**
1211
1265
  * The server is ready to receive pairing input. `forceManual: true` means
1212
- * the QR refresh budget was exhausted and the user must request a fresh
1266
+ * the QR refresh budget was exhausted, or a pending pairing code could not
1267
+ * be registered again after a reconnect, and the user must request a fresh
1213
1268
  * one (e.g. via the link-code flow).
1214
1269
  */
1215
1270
  readonly auth_pairing_required: (event: {