zapo-js 1.8.0 → 1.8.2

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.
@@ -278,7 +278,8 @@ class WaClientImpl extends node_events_1.EventEmitter {
278
278
  emitEvent: this.emit.bind(this),
279
279
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
280
280
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
281
- onProcessed: sendHistSyncReceipt
281
+ onProcessed: sendHistSyncReceipt,
282
+ meJid: this.deps.authClient.getCurrentCredentials()?.meJid
282
283
  }, protocolMessage.historySyncNotification);
283
284
  }
284
285
  else if (sendHistSyncReceipt) {
@@ -15,6 +15,11 @@ export declare class WaOfflineResumeCoordinator {
15
15
  private state;
16
16
  private totalStanzas;
17
17
  private pendingStanzas;
18
+ private batchInFlight;
19
+ private batchRetries;
20
+ private resumeGeneration;
21
+ private lastBatchRequestMs;
22
+ private batchTimeout;
18
23
  private stanzaTimeout;
19
24
  constructor(options: WaOfflineResumeCoordinatorOptions);
20
25
  get isComplete(): boolean;
@@ -24,6 +29,23 @@ export declare class WaOfflineResumeCoordinator {
24
29
  trackOfflineStanza(): void;
25
30
  reset(): void;
26
31
  private completeResume;
32
+ /**
33
+ * Ask the server for the next window of queued stanzas, at most one request
34
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
35
+ * delivered stanza schedules a request, so the loop winds down on its own
36
+ * once the queue dries up; the resume itself ends on the terminal `offline`
37
+ * bulletin or on the stanza timeout, never on the preview counter, which is
38
+ * a progress estimate rather than an authoritative total.
39
+ */
40
+ private scheduleNextBatch;
41
+ private requestOfflineBatch;
42
+ /**
43
+ * A rejected request delivers no stanza, and only a delivered stanza
44
+ * schedules the next one, so without a retry here a single transport blip
45
+ * strands the whole queue until the stanza timeout. `generation` pins the
46
+ * outcome to the resume that issued it: a rejection from a torn-down resume
47
+ * must not clear the current one's in-flight flag or retry on its behalf.
48
+ */
27
49
  private sendOfflineBatch;
28
50
  private resetStanzaTimeout;
29
51
  private clearTimers;
@@ -5,6 +5,8 @@ const offline_1 = require("../../transport/node/builders/offline");
5
5
  const primitives_1 = require("../../util/primitives");
6
6
  const WA_OFFLINE_RESUME = Object.freeze({
7
7
  BATCH_SIZE: 200,
8
+ REQUEST_DEBOUNCE_MS: 100,
9
+ MAX_BATCH_RETRIES: 3,
8
10
  STANZA_TIMEOUT_MS: 60000
9
11
  });
10
12
  const WA_OFFLINE_RESUME_STATE = Object.freeze({
@@ -19,6 +21,11 @@ class WaOfflineResumeCoordinator {
19
21
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
20
22
  this.totalStanzas = 0;
21
23
  this.pendingStanzas = 0;
24
+ this.batchInFlight = false;
25
+ this.batchRetries = 0;
26
+ this.resumeGeneration = 0;
27
+ this.lastBatchRequestMs = 0;
28
+ this.batchTimeout = null;
22
29
  this.stanzaTimeout = null;
23
30
  }
24
31
  get isComplete() {
@@ -32,6 +39,10 @@ class WaOfflineResumeCoordinator {
32
39
  this.state = WA_OFFLINE_RESUME_STATE.RESUMING;
33
40
  this.totalStanzas = stanzaCount;
34
41
  this.pendingStanzas = stanzaCount;
42
+ this.batchInFlight = false;
43
+ this.batchRetries = 0;
44
+ this.resumeGeneration += 1;
45
+ this.lastBatchRequestMs = 0;
35
46
  this.logger.info('offline resume started', {
36
47
  totalStanzas: stanzaCount
37
48
  });
@@ -41,7 +52,7 @@ class WaOfflineResumeCoordinator {
41
52
  remainingStanzas: stanzaCount,
42
53
  forced: false
43
54
  });
44
- void this.sendOfflineBatch();
55
+ this.requestOfflineBatch();
45
56
  this.resetStanzaTimeout();
46
57
  }
47
58
  handleOfflineComplete(serverStanzaCount) {
@@ -55,17 +66,24 @@ class WaOfflineResumeCoordinator {
55
66
  return;
56
67
  }
57
68
  this.pendingStanzas = Math.max(0, this.pendingStanzas - 1);
69
+ this.batchInFlight = false;
58
70
  this.resetStanzaTimeout();
71
+ this.scheduleNextBatch();
59
72
  }
60
73
  reset() {
61
74
  this.clearTimers();
62
75
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
63
76
  this.totalStanzas = 0;
64
77
  this.pendingStanzas = 0;
78
+ this.batchInFlight = false;
79
+ this.batchRetries = 0;
80
+ this.resumeGeneration += 1;
81
+ this.lastBatchRequestMs = 0;
65
82
  }
66
83
  completeResume(forced, serverStanzaCount) {
67
84
  this.clearTimers();
68
85
  this.state = WA_OFFLINE_RESUME_STATE.COMPLETE;
86
+ this.batchInFlight = false;
69
87
  this.logger.info('offline resume complete', {
70
88
  totalStanzas: this.totalStanzas,
71
89
  remainingStanzas: this.pendingStanzas,
@@ -79,14 +97,73 @@ class WaOfflineResumeCoordinator {
79
97
  forced
80
98
  });
81
99
  }
82
- async sendOfflineBatch() {
100
+ /**
101
+ * Ask the server for the next window of queued stanzas, at most one request
102
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
103
+ * delivered stanza schedules a request, so the loop winds down on its own
104
+ * once the queue dries up; the resume itself ends on the terminal `offline`
105
+ * bulletin or on the stanza timeout, never on the preview counter, which is
106
+ * a progress estimate rather than an authoritative total.
107
+ */
108
+ scheduleNextBatch() {
109
+ if (this.batchInFlight || this.batchTimeout !== null) {
110
+ return;
111
+ }
112
+ const elapsedMs = Date.now() - this.lastBatchRequestMs;
113
+ if (elapsedMs >= WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS) {
114
+ this.requestOfflineBatch();
115
+ return;
116
+ }
117
+ this.batchTimeout = setTimeout(() => {
118
+ this.batchTimeout = null;
119
+ if (this.state === WA_OFFLINE_RESUME_STATE.RESUMING) {
120
+ this.scheduleNextBatch();
121
+ }
122
+ }, WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS - elapsedMs);
123
+ }
124
+ requestOfflineBatch() {
125
+ this.batchInFlight = true;
126
+ this.lastBatchRequestMs = Date.now();
127
+ this.logger.debug('offline batch requested', {
128
+ batchSize: WA_OFFLINE_RESUME.BATCH_SIZE,
129
+ remainingStanzas: this.pendingStanzas
130
+ });
131
+ void this.sendOfflineBatch(this.resumeGeneration);
132
+ }
133
+ /**
134
+ * A rejected request delivers no stanza, and only a delivered stanza
135
+ * schedules the next one, so without a retry here a single transport blip
136
+ * strands the whole queue until the stanza timeout. `generation` pins the
137
+ * outcome to the resume that issued it: a rejection from a torn-down resume
138
+ * must not clear the current one's in-flight flag or retry on its behalf.
139
+ */
140
+ async sendOfflineBatch(generation) {
83
141
  try {
84
142
  await this.runtime.sendNode((0, offline_1.buildOfflineBatchNode)(WA_OFFLINE_RESUME.BATCH_SIZE));
143
+ if (generation === this.resumeGeneration) {
144
+ this.batchRetries = 0;
145
+ }
85
146
  }
86
147
  catch (err) {
87
- this.logger.warn('offline batch request failed', {
148
+ if (generation !== this.resumeGeneration ||
149
+ this.state !== WA_OFFLINE_RESUME_STATE.RESUMING) {
150
+ return;
151
+ }
152
+ this.batchInFlight = false;
153
+ this.batchRetries += 1;
154
+ if (this.batchRetries > WA_OFFLINE_RESUME.MAX_BATCH_RETRIES) {
155
+ this.logger.warn('offline batch request failed, giving up', {
156
+ attempts: this.batchRetries,
157
+ remainingStanzas: this.pendingStanzas,
158
+ message: (0, primitives_1.toError)(err).message
159
+ });
160
+ return;
161
+ }
162
+ this.logger.debug('offline batch request failed, retrying', {
163
+ attempt: this.batchRetries,
88
164
  message: (0, primitives_1.toError)(err).message
89
165
  });
166
+ this.scheduleNextBatch();
90
167
  }
91
168
  }
92
169
  resetStanzaTimeout() {
@@ -109,6 +186,10 @@ class WaOfflineResumeCoordinator {
109
186
  clearTimeout(this.stanzaTimeout);
110
187
  this.stanzaTimeout = null;
111
188
  }
189
+ if (this.batchTimeout !== null) {
190
+ clearTimeout(this.batchTimeout);
191
+ this.batchTimeout = null;
192
+ }
112
193
  }
113
194
  }
114
195
  exports.WaOfflineResumeCoordinator = WaOfflineResumeCoordinator;
@@ -25,7 +25,7 @@ const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
25
25
  // serialize on the prekey lock and hit the store); excess under flood is dropped.
26
26
  const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
27
27
  const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
28
- const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
28
+ const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 4;
29
29
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
30
30
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
31
31
  const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
@@ -4,6 +4,7 @@ exports.runGroupHistoryBundle = runGroupHistoryBundle;
4
4
  exports.processGroupHistoryBundle = processGroupHistoryBundle;
5
5
  const history_blob_1 = require("../persistence/history-blob");
6
6
  const group_history_1 = require("../../message/kinds/group-history");
7
+ const incoming_1 = require("../../message/primitives/incoming");
7
8
  const _proto_1 = require("../../proto");
8
9
  const jid_1 = require("../../protocol/jid");
9
10
  const primitives_1 = require("../../util/primitives");
@@ -79,10 +80,12 @@ async function processGroupHistoryBundle(deps, input) {
79
80
  (oldestTimestampMs === undefined || timestampMs < oldestTimestampMs)) {
80
81
  oldestTimestampMs = timestampMs;
81
82
  }
83
+ const authorJid = (0, incoming_1.resolveWebMessageInfoAuthor)(webMsg, deps.meJid, input.groupJid);
82
84
  const write = deps.writeBehind.persistMessageAsync({
83
85
  id: webMsg.key.id,
84
86
  threadJid: input.groupJid,
85
- senderJid: webMsg.key.participant ?? undefined,
87
+ senderJid: authorJid,
88
+ participantJid: authorJid,
86
89
  fromMe: webMsg.key.fromMe === true,
87
90
  timestampMs: timestampMs || undefined,
88
91
  messageBytes: _proto_1.proto.Message.encode(webMsg.message).finish()
@@ -33,6 +33,8 @@ interface WaHistorySyncDeps {
33
33
  readonly onNctSalt?: (salt: Uint8Array) => Promise<void>;
34
34
  /** Acks the chunk via the `hist_sync` receipt so the primary stops resending it. */
35
35
  readonly onProcessed?: (syncType: Proto.Message.HistorySyncType) => Promise<void>;
36
+ /** Author fallback for self-sent group messages that carry no participant. */
37
+ readonly meJid?: string | null;
36
38
  }
37
39
  export declare function runHistorySyncNotification(deps: WaHistorySyncDeps, notification: Proto.Message.IHistorySyncNotification): Promise<void>;
38
40
  /**
@@ -4,6 +4,7 @@ exports.CONVERSATION_FIELDS = exports.HISTORY_SYNC_FIELDS = void 0;
4
4
  exports.runHistorySyncNotification = runHistorySyncNotification;
5
5
  exports.processHistorySyncNotification = processHistorySyncNotification;
6
6
  const history_blob_1 = require("../persistence/history-blob");
7
+ const incoming_1 = require("../../message/primitives/incoming");
7
8
  const _proto_1 = require("../../proto");
8
9
  const jid_1 = require("../../protocol/jid");
9
10
  const message_1 = require("../../protocol/message");
@@ -158,15 +159,7 @@ async function consumeHistorySyncStream(deps, inflated, state) {
158
159
  parked[parked.length] = message;
159
160
  return undefined;
160
161
  }
161
- state.messagesCount += 1;
162
- pushWrite(state, deps.writeBehind.persistMessageAsync({
163
- id: message.id,
164
- threadJid,
165
- senderJid: message.senderJid,
166
- fromMe: message.fromMe,
167
- timestampMs: message.timestampMs,
168
- messageBytes: message.messageBytes
169
- }));
162
+ persistHistoryMessage(deps, state, message, threadJid);
170
163
  return maybeFlush(state);
171
164
  }
172
165
  if (headerParts) {
@@ -292,18 +285,35 @@ async function closeConversation(deps, state, headerParts, threadJid, parked) {
292
285
  }
293
286
  }
294
287
  for (const message of parked ?? []) {
295
- state.messagesCount += 1;
296
- pushWrite(state, deps.writeBehind.persistMessageAsync({
297
- id: message.id,
298
- threadJid: resolvedJid,
299
- senderJid: message.senderJid,
300
- fromMe: message.fromMe,
301
- timestampMs: message.timestampMs,
302
- messageBytes: message.messageBytes
303
- }));
288
+ persistHistoryMessage(deps, state, message, resolvedJid);
304
289
  }
305
290
  await maybeFlush(state);
306
291
  }
292
+ /**
293
+ * Resolves the author here rather than at decode time because a message may be
294
+ * read before its `Conversation.id`, and the thread type decides both the
295
+ * self-sent fallback and the shape of the record.
296
+ *
297
+ * `participantJid` carries the group/broadcast author and stays absent in 1:1
298
+ * threads, where `senderJid` falls back to the thread JID - the same shape the
299
+ * live message path persists. A group author that stays unresolved leaves both
300
+ * fields empty: writing the group JID there would read downstream as if the
301
+ * group itself had sent the message.
302
+ */
303
+ function persistHistoryMessage(deps, state, message, threadJid) {
304
+ state.messagesCount += 1;
305
+ const authorJid = (0, incoming_1.resolveWebMessageInfoAuthor)(message.authorInfo, deps.meJid, threadJid);
306
+ const isGroupOrBroadcast = (0, jid_1.isGroupJid)(threadJid) || (0, jid_1.isBroadcastJid)(threadJid);
307
+ pushWrite(state, deps.writeBehind.persistMessageAsync({
308
+ id: message.id,
309
+ threadJid,
310
+ senderJid: isGroupOrBroadcast ? authorJid : (authorJid ?? threadJid),
311
+ participantJid: isGroupOrBroadcast ? authorJid : undefined,
312
+ fromMe: message.fromMe,
313
+ timestampMs: message.timestampMs,
314
+ messageBytes: message.messageBytes
315
+ }));
316
+ }
307
317
  async function settleHistorySyncChunk(deps, state) {
308
318
  for (const pushname of state.pushnames) {
309
319
  if (!pushname.id) {
@@ -365,7 +375,11 @@ function readHistoryMessage(record) {
365
375
  const timestampMs = (0, primitives_1.longToNumber)(webMsg.messageTimestamp) * 1000;
366
376
  return {
367
377
  id: webMsg.key.id,
368
- senderJid: webMsg.key.participant ?? undefined,
378
+ authorInfo: {
379
+ key: { fromMe: webMsg.key.fromMe, participant: webMsg.key.participant },
380
+ participant: webMsg.participant,
381
+ originalSelfAuthorUserJidString: webMsg.originalSelfAuthorUserJidString
382
+ },
369
383
  fromMe: webMsg.key.fromMe === true,
370
384
  timestampMs: timestampMs || undefined,
371
385
  messageBytes: _proto_1.proto.Message.encode(webMsg.message).finish()
@@ -1458,9 +1458,19 @@ export interface WaClientEventMap {
1458
1458
  export interface WaOfflineResumeEvent {
1459
1459
  readonly status: 'resuming' | 'complete';
1460
1460
  readonly totalStanzas: number;
1461
- /** `0` on the terminal `'complete'` event. */
1461
+ /**
1462
+ * Stanzas still outstanding against `totalStanzas`, counted down from the
1463
+ * server's preview figure. That figure is an estimate, so a clean finish
1464
+ * can still report a small non-zero remainder. Read it together with
1465
+ * `forced`: a remainder alongside `forced: true` is the case where the
1466
+ * flush was cut short and the rest stays queued for the next connection.
1467
+ */
1462
1468
  readonly remainingStanzas: number;
1463
- /** `true` when triggered by an explicit catch-up request rather than auto-resume on reconnect. */
1469
+ /**
1470
+ * `true` when the resume was closed by the client because the server went
1471
+ * quiet for 60s instead of by the server's terminal `offline` bulletin.
1472
+ * Pair with `remainingStanzas` to tell a clean finish from a cut-short one.
1473
+ */
1464
1474
  readonly forced: boolean;
1465
1475
  }
1466
1476
  export interface WaOfflineThreadPreview {
@@ -275,7 +275,8 @@ class WaClientImpl extends EventEmitter {
275
275
  emitEvent: this.emit.bind(this),
276
276
  onPrivacyTokens: (conversations) => this.deps.trustedContactToken.hydrateFromHistorySync(conversations),
277
277
  onNctSalt: (salt) => this.deps.trustedContactToken.hydrateNctSaltFromHistorySync(salt),
278
- onProcessed: sendHistSyncReceipt
278
+ onProcessed: sendHistSyncReceipt,
279
+ meJid: this.deps.authClient.getCurrentCredentials()?.meJid
279
280
  }, protocolMessage.historySyncNotification);
280
281
  }
281
282
  else if (sendHistSyncReceipt) {
@@ -2,6 +2,8 @@ import { buildOfflineBatchNode } from '../../transport/node/builders/offline.js'
2
2
  import { toError } from '../../util/primitives.js';
3
3
  const WA_OFFLINE_RESUME = Object.freeze({
4
4
  BATCH_SIZE: 200,
5
+ REQUEST_DEBOUNCE_MS: 100,
6
+ MAX_BATCH_RETRIES: 3,
5
7
  STANZA_TIMEOUT_MS: 60000
6
8
  });
7
9
  const WA_OFFLINE_RESUME_STATE = Object.freeze({
@@ -16,6 +18,11 @@ export class WaOfflineResumeCoordinator {
16
18
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
17
19
  this.totalStanzas = 0;
18
20
  this.pendingStanzas = 0;
21
+ this.batchInFlight = false;
22
+ this.batchRetries = 0;
23
+ this.resumeGeneration = 0;
24
+ this.lastBatchRequestMs = 0;
25
+ this.batchTimeout = null;
19
26
  this.stanzaTimeout = null;
20
27
  }
21
28
  get isComplete() {
@@ -29,6 +36,10 @@ export class WaOfflineResumeCoordinator {
29
36
  this.state = WA_OFFLINE_RESUME_STATE.RESUMING;
30
37
  this.totalStanzas = stanzaCount;
31
38
  this.pendingStanzas = stanzaCount;
39
+ this.batchInFlight = false;
40
+ this.batchRetries = 0;
41
+ this.resumeGeneration += 1;
42
+ this.lastBatchRequestMs = 0;
32
43
  this.logger.info('offline resume started', {
33
44
  totalStanzas: stanzaCount
34
45
  });
@@ -38,7 +49,7 @@ export class WaOfflineResumeCoordinator {
38
49
  remainingStanzas: stanzaCount,
39
50
  forced: false
40
51
  });
41
- void this.sendOfflineBatch();
52
+ this.requestOfflineBatch();
42
53
  this.resetStanzaTimeout();
43
54
  }
44
55
  handleOfflineComplete(serverStanzaCount) {
@@ -52,17 +63,24 @@ export class WaOfflineResumeCoordinator {
52
63
  return;
53
64
  }
54
65
  this.pendingStanzas = Math.max(0, this.pendingStanzas - 1);
66
+ this.batchInFlight = false;
55
67
  this.resetStanzaTimeout();
68
+ this.scheduleNextBatch();
56
69
  }
57
70
  reset() {
58
71
  this.clearTimers();
59
72
  this.state = WA_OFFLINE_RESUME_STATE.INIT;
60
73
  this.totalStanzas = 0;
61
74
  this.pendingStanzas = 0;
75
+ this.batchInFlight = false;
76
+ this.batchRetries = 0;
77
+ this.resumeGeneration += 1;
78
+ this.lastBatchRequestMs = 0;
62
79
  }
63
80
  completeResume(forced, serverStanzaCount) {
64
81
  this.clearTimers();
65
82
  this.state = WA_OFFLINE_RESUME_STATE.COMPLETE;
83
+ this.batchInFlight = false;
66
84
  this.logger.info('offline resume complete', {
67
85
  totalStanzas: this.totalStanzas,
68
86
  remainingStanzas: this.pendingStanzas,
@@ -76,14 +94,73 @@ export class WaOfflineResumeCoordinator {
76
94
  forced
77
95
  });
78
96
  }
79
- async sendOfflineBatch() {
97
+ /**
98
+ * Ask the server for the next window of queued stanzas, at most one request
99
+ * per `REQUEST_DEBOUNCE_MS` and never while one is still outstanding. Only a
100
+ * delivered stanza schedules a request, so the loop winds down on its own
101
+ * once the queue dries up; the resume itself ends on the terminal `offline`
102
+ * bulletin or on the stanza timeout, never on the preview counter, which is
103
+ * a progress estimate rather than an authoritative total.
104
+ */
105
+ scheduleNextBatch() {
106
+ if (this.batchInFlight || this.batchTimeout !== null) {
107
+ return;
108
+ }
109
+ const elapsedMs = Date.now() - this.lastBatchRequestMs;
110
+ if (elapsedMs >= WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS) {
111
+ this.requestOfflineBatch();
112
+ return;
113
+ }
114
+ this.batchTimeout = setTimeout(() => {
115
+ this.batchTimeout = null;
116
+ if (this.state === WA_OFFLINE_RESUME_STATE.RESUMING) {
117
+ this.scheduleNextBatch();
118
+ }
119
+ }, WA_OFFLINE_RESUME.REQUEST_DEBOUNCE_MS - elapsedMs);
120
+ }
121
+ requestOfflineBatch() {
122
+ this.batchInFlight = true;
123
+ this.lastBatchRequestMs = Date.now();
124
+ this.logger.debug('offline batch requested', {
125
+ batchSize: WA_OFFLINE_RESUME.BATCH_SIZE,
126
+ remainingStanzas: this.pendingStanzas
127
+ });
128
+ void this.sendOfflineBatch(this.resumeGeneration);
129
+ }
130
+ /**
131
+ * A rejected request delivers no stanza, and only a delivered stanza
132
+ * schedules the next one, so without a retry here a single transport blip
133
+ * strands the whole queue until the stanza timeout. `generation` pins the
134
+ * outcome to the resume that issued it: a rejection from a torn-down resume
135
+ * must not clear the current one's in-flight flag or retry on its behalf.
136
+ */
137
+ async sendOfflineBatch(generation) {
80
138
  try {
81
139
  await this.runtime.sendNode(buildOfflineBatchNode(WA_OFFLINE_RESUME.BATCH_SIZE));
140
+ if (generation === this.resumeGeneration) {
141
+ this.batchRetries = 0;
142
+ }
82
143
  }
83
144
  catch (err) {
84
- this.logger.warn('offline batch request failed', {
145
+ if (generation !== this.resumeGeneration ||
146
+ this.state !== WA_OFFLINE_RESUME_STATE.RESUMING) {
147
+ return;
148
+ }
149
+ this.batchInFlight = false;
150
+ this.batchRetries += 1;
151
+ if (this.batchRetries > WA_OFFLINE_RESUME.MAX_BATCH_RETRIES) {
152
+ this.logger.warn('offline batch request failed, giving up', {
153
+ attempts: this.batchRetries,
154
+ remainingStanzas: this.pendingStanzas,
155
+ message: toError(err).message
156
+ });
157
+ return;
158
+ }
159
+ this.logger.debug('offline batch request failed, retrying', {
160
+ attempt: this.batchRetries,
85
161
  message: toError(err).message
86
162
  });
163
+ this.scheduleNextBatch();
87
164
  }
88
165
  }
89
166
  resetStanzaTimeout() {
@@ -106,5 +183,9 @@ export class WaOfflineResumeCoordinator {
106
183
  clearTimeout(this.stanzaTimeout);
107
184
  this.stanzaTimeout = null;
108
185
  }
186
+ if (this.batchTimeout !== null) {
187
+ clearTimeout(this.batchTimeout);
188
+ this.batchTimeout = null;
189
+ }
109
190
  }
110
191
  }
@@ -22,7 +22,7 @@ const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
22
22
  // serialize on the prekey lock and hit the store); excess under flood is dropped.
23
23
  const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
24
24
  const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
25
- const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
25
+ const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 4;
26
26
  const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
27
27
  const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
28
28
  const PLACEHOLDER_RESEND_FALLBACK_MAX_AGE_DAYS = 14;
@@ -1,5 +1,6 @@
1
1
  import { flushPendingWrites, openHistoryBlobStream } from '../persistence/history-blob.js';
2
2
  import { streamGroupHistoryBundle } from '../../message/kinds/group-history.js';
3
+ import { resolveWebMessageInfoAuthor } from '../../message/primitives/incoming.js';
3
4
  import { proto } from '../../proto.js';
4
5
  import { isGroupJid, toUserJid } from '../../protocol/jid.js';
5
6
  import { longToNumber, toError } from '../../util/primitives.js';
@@ -75,10 +76,12 @@ export async function processGroupHistoryBundle(deps, input) {
75
76
  (oldestTimestampMs === undefined || timestampMs < oldestTimestampMs)) {
76
77
  oldestTimestampMs = timestampMs;
77
78
  }
79
+ const authorJid = resolveWebMessageInfoAuthor(webMsg, deps.meJid, input.groupJid);
78
80
  const write = deps.writeBehind.persistMessageAsync({
79
81
  id: webMsg.key.id,
80
82
  threadJid: input.groupJid,
81
- senderJid: webMsg.key.participant ?? undefined,
83
+ senderJid: authorJid,
84
+ participantJid: authorJid,
82
85
  fromMe: webMsg.key.fromMe === true,
83
86
  timestampMs: timestampMs || undefined,
84
87
  messageBytes: proto.Message.encode(webMsg.message).finish()
@@ -1,6 +1,7 @@
1
1
  import { flushPendingWrites, openHistoryBlobStream } from '../persistence/history-blob.js';
2
+ import { resolveWebMessageInfoAuthor } from '../../message/primitives/incoming.js';
2
3
  import { proto } from '../../proto.js';
3
- import { isUserJid } from '../../protocol/jid.js';
4
+ import { isBroadcastJid, isGroupJid, isUserJid } from '../../protocol/jid.js';
4
5
  import { normalizeEphemeralSettingSeconds } from '../../protocol/message.js';
5
6
  import { normalizeUsername } from '../../protocol/username.js';
6
7
  import { concatBytes, TEXT_DECODER } from '../../util/bytes.js';
@@ -153,15 +154,7 @@ async function consumeHistorySyncStream(deps, inflated, state) {
153
154
  parked[parked.length] = message;
154
155
  return undefined;
155
156
  }
156
- state.messagesCount += 1;
157
- pushWrite(state, deps.writeBehind.persistMessageAsync({
158
- id: message.id,
159
- threadJid,
160
- senderJid: message.senderJid,
161
- fromMe: message.fromMe,
162
- timestampMs: message.timestampMs,
163
- messageBytes: message.messageBytes
164
- }));
157
+ persistHistoryMessage(deps, state, message, threadJid);
165
158
  return maybeFlush(state);
166
159
  }
167
160
  if (headerParts) {
@@ -287,18 +280,35 @@ async function closeConversation(deps, state, headerParts, threadJid, parked) {
287
280
  }
288
281
  }
289
282
  for (const message of parked ?? []) {
290
- state.messagesCount += 1;
291
- pushWrite(state, deps.writeBehind.persistMessageAsync({
292
- id: message.id,
293
- threadJid: resolvedJid,
294
- senderJid: message.senderJid,
295
- fromMe: message.fromMe,
296
- timestampMs: message.timestampMs,
297
- messageBytes: message.messageBytes
298
- }));
283
+ persistHistoryMessage(deps, state, message, resolvedJid);
299
284
  }
300
285
  await maybeFlush(state);
301
286
  }
287
+ /**
288
+ * Resolves the author here rather than at decode time because a message may be
289
+ * read before its `Conversation.id`, and the thread type decides both the
290
+ * self-sent fallback and the shape of the record.
291
+ *
292
+ * `participantJid` carries the group/broadcast author and stays absent in 1:1
293
+ * threads, where `senderJid` falls back to the thread JID - the same shape the
294
+ * live message path persists. A group author that stays unresolved leaves both
295
+ * fields empty: writing the group JID there would read downstream as if the
296
+ * group itself had sent the message.
297
+ */
298
+ function persistHistoryMessage(deps, state, message, threadJid) {
299
+ state.messagesCount += 1;
300
+ const authorJid = resolveWebMessageInfoAuthor(message.authorInfo, deps.meJid, threadJid);
301
+ const isGroupOrBroadcast = isGroupJid(threadJid) || isBroadcastJid(threadJid);
302
+ pushWrite(state, deps.writeBehind.persistMessageAsync({
303
+ id: message.id,
304
+ threadJid,
305
+ senderJid: isGroupOrBroadcast ? authorJid : (authorJid ?? threadJid),
306
+ participantJid: isGroupOrBroadcast ? authorJid : undefined,
307
+ fromMe: message.fromMe,
308
+ timestampMs: message.timestampMs,
309
+ messageBytes: message.messageBytes
310
+ }));
311
+ }
302
312
  async function settleHistorySyncChunk(deps, state) {
303
313
  for (const pushname of state.pushnames) {
304
314
  if (!pushname.id) {
@@ -360,7 +370,11 @@ function readHistoryMessage(record) {
360
370
  const timestampMs = longToNumber(webMsg.messageTimestamp) * 1000;
361
371
  return {
362
372
  id: webMsg.key.id,
363
- senderJid: webMsg.key.participant ?? undefined,
373
+ authorInfo: {
374
+ key: { fromMe: webMsg.key.fromMe, participant: webMsg.key.participant },
375
+ participant: webMsg.participant,
376
+ originalSelfAuthorUserJidString: webMsg.originalSelfAuthorUserJidString
377
+ },
364
378
  fromMe: webMsg.key.fromMe === true,
365
379
  timestampMs: timestampMs || undefined,
366
380
  messageBytes: proto.Message.encode(webMsg.message).finish()
@@ -12,6 +12,12 @@ export const MAC_KEY_END = 80;
12
12
  export const HMAC_TRUNCATED_SIZE = 10;
13
13
  export const SIDECAR_CHUNK_SIZE = 65536;
14
14
  export const SIDECAR_HMAC_SIZE = 10;
15
+ /**
16
+ * Leading bytes buffered when deciding whether a payload supports progressive
17
+ * playback. The top-level boxes that settle it sit at the very front of the
18
+ * file, so this only has to survive a few small boxes before `moov`/`mdat`.
19
+ */
20
+ export const CONTAINER_SCAN_MAX_BYTES = 1024;
15
21
  export const MEDIA_UPLOAD_PATHS = Object.freeze({
16
22
  image: '/mms/image',
17
23
  video: '/mms/video',