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.
- package/dist/client/WaClient.js +2 -1
- package/dist/client/coordinators/WaOfflineResumeCoordinator.d.ts +22 -0
- package/dist/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/client/coordinators/WaRetryCoordinator.js +1 -1
- package/dist/client/persistence/group-history.js +4 -1
- package/dist/client/persistence/history-sync.d.ts +2 -0
- package/dist/client/persistence/history-sync.js +33 -19
- package/dist/client/types.d.ts +12 -2
- package/dist/esm/client/WaClient.js +2 -1
- package/dist/esm/client/coordinators/WaOfflineResumeCoordinator.js +84 -3
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +1 -1
- package/dist/esm/client/persistence/group-history.js +4 -1
- package/dist/esm/client/persistence/history-sync.js +34 -20
- package/dist/esm/media/constants.js +6 -0
- package/dist/esm/media/container.js +105 -0
- package/dist/esm/media/crypto/WaMediaCrypto.js +9 -2
- package/dist/esm/message/crypto/phash.js +65 -20
- package/dist/esm/message/primitives/incoming.js +27 -3
- package/dist/media/constants.d.ts +6 -0
- package/dist/media/constants.js +7 -1
- package/dist/media/container.d.ts +43 -0
- package/dist/media/container.js +110 -0
- package/dist/media/crypto/WaMediaCrypto.js +9 -2
- package/dist/message/crypto/phash.d.ts +12 -0
- package/dist/message/crypto/phash.js +65 -20
- package/dist/message/primitives/incoming.d.ts +17 -0
- package/dist/message/primitives/incoming.js +28 -3
- package/package.json +1 -1
package/dist/client/WaClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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()
|
package/dist/client/types.d.ts
CHANGED
|
@@ -1458,9 +1458,19 @@ export interface WaClientEventMap {
|
|
|
1458
1458
|
export interface WaOfflineResumeEvent {
|
|
1459
1459
|
readonly status: 'resuming' | 'complete';
|
|
1460
1460
|
readonly totalStanzas: number;
|
|
1461
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
|
|
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',
|