zapo-js 1.1.0 → 1.1.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/README.md +5 -1
- package/dist/appstate/sync/WaAppStateSyncClient.d.ts +11 -1
- package/dist/appstate/sync/WaAppStateSyncClient.js +36 -15
- package/dist/auth/credentials-flow.js +3 -1
- package/dist/auth/pairing/WaPairingFlow.js +2 -0
- package/dist/client/WaClient.js +26 -20
- package/dist/client/WaClientFactory.js +28 -6
- package/dist/client/connection/WaConnectionManager.js +3 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.d.ts +8 -0
- package/dist/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
- package/dist/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.d.ts +6 -1
- package/dist/client/coordinators/WaMessageDispatchCoordinator.js +5 -5
- package/dist/client/coordinators/WaPassiveTasksCoordinator.d.ts +6 -1
- package/dist/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
- package/dist/client/coordinators/WaProfileCoordinator.d.ts +11 -6
- package/dist/client/coordinators/WaProfileCoordinator.js +4 -1
- package/dist/client/coordinators/WaRetryCoordinator.d.ts +18 -1
- package/dist/client/coordinators/WaRetryCoordinator.js +83 -30
- package/dist/client/messaging/ignore-key.js +4 -2
- package/dist/client/types.d.ts +13 -10
- package/dist/esm/appstate/sync/WaAppStateSyncClient.js +36 -15
- package/dist/esm/auth/credentials-flow.js +3 -1
- package/dist/esm/auth/pairing/WaPairingFlow.js +2 -0
- package/dist/esm/client/WaClient.js +26 -20
- package/dist/esm/client/WaClientFactory.js +28 -6
- package/dist/esm/client/connection/WaConnectionManager.js +3 -0
- package/dist/esm/client/coordinators/WaAppStateMutationCoordinator.js +29 -5
- package/dist/esm/client/coordinators/WaIncomingNodeCoordinator.js +1 -1
- package/dist/esm/client/coordinators/WaMessageDispatchCoordinator.js +6 -6
- package/dist/esm/client/coordinators/WaPassiveTasksCoordinator.js +3 -3
- package/dist/esm/client/coordinators/WaProfileCoordinator.js +4 -1
- package/dist/esm/client/coordinators/WaRetryCoordinator.js +84 -31
- package/dist/esm/client/messaging/ignore-key.js +4 -2
- package/dist/esm/message/WaMessageClient.js +3 -0
- package/dist/esm/message/primitives/incoming.js +20 -5
- package/dist/esm/protocol/constants.js +1 -1
- package/dist/esm/protocol/message.js +22 -0
- package/dist/esm/retry/replay.js +36 -2
- package/dist/esm/signal/session/SignalRatchet.js +2 -2
- package/dist/esm/transport/WaComms.js +4 -0
- package/dist/esm/transport/keepalive/WaKeepAlive.js +4 -0
- package/dist/esm/transport/node/WaNodeOrchestrator.js +2 -2
- package/dist/esm/transport/node/builders/global.js +3 -0
- package/dist/message/WaMessageClient.js +3 -0
- package/dist/message/primitives/incoming.d.ts +7 -1
- package/dist/message/primitives/incoming.js +20 -5
- package/dist/message/types.d.ts +5 -0
- package/dist/protocol/constants.d.ts +1 -1
- package/dist/protocol/constants.js +3 -2
- package/dist/protocol/message.d.ts +22 -0
- package/dist/protocol/message.js +23 -1
- package/dist/retry/replay.d.ts +12 -0
- package/dist/retry/replay.js +36 -2
- package/dist/signal/session/SignalRatchet.js +2 -2
- package/dist/transport/WaComms.js +4 -0
- package/dist/transport/keepalive/WaKeepAlive.js +4 -0
- package/dist/transport/node/WaNodeOrchestrator.d.ts +6 -1
- package/dist/transport/node/WaNodeOrchestrator.js +2 -2
- package/dist/transport/node/builders/global.d.ts +1 -0
- package/dist/transport/node/builders/global.js +3 -0
- package/package.json +1 -1
|
@@ -180,6 +180,7 @@ export class WaAppStateMutationCoordinator {
|
|
|
180
180
|
this.emitSnapshotMutations = options.emitSnapshotMutations === true;
|
|
181
181
|
this.nctSaltSink = options.nctSaltSink;
|
|
182
182
|
this.contactSink = options.contactSink;
|
|
183
|
+
this.pushNameSink = options.pushNameSink;
|
|
183
184
|
this.pendingMutations = new Map();
|
|
184
185
|
this.flushPromise = null;
|
|
185
186
|
}
|
|
@@ -227,11 +228,10 @@ export class WaAppStateMutationCoordinator {
|
|
|
227
228
|
emitEventsFromSyncResult(syncResult) {
|
|
228
229
|
for (const collectionResult of syncResult.collections) {
|
|
229
230
|
const mutations = collectionResult.mutations ?? [];
|
|
230
|
-
// Persistence sinks (contact store,
|
|
231
|
-
// mutation per key INCLUDING snapshot sources, so
|
|
232
|
-
// bootstrap
|
|
233
|
-
|
|
234
|
-
if (this.contactSink) {
|
|
231
|
+
// Persistence sinks (contact store, own pushName): run on the
|
|
232
|
+
// last-wins mutation per key INCLUDING snapshot sources, so
|
|
233
|
+
// pair-time bootstrap lands even when snapshot events are suppressed.
|
|
234
|
+
if (this.contactSink || this.pushNameSink) {
|
|
235
235
|
const sinkLastIndex = new Map();
|
|
236
236
|
for (let i = 0; i < mutations.length; i += 1) {
|
|
237
237
|
const m = mutations[i];
|
|
@@ -252,6 +252,16 @@ export class WaAppStateMutationCoordinator {
|
|
|
252
252
|
message: toError(error).message
|
|
253
253
|
});
|
|
254
254
|
}
|
|
255
|
+
try {
|
|
256
|
+
this.handlePushNameMutation(m);
|
|
257
|
+
}
|
|
258
|
+
catch (error) {
|
|
259
|
+
this.logger.debug('pushName sink failed', {
|
|
260
|
+
collection: m.collection,
|
|
261
|
+
index: m.index,
|
|
262
|
+
message: toError(error).message
|
|
263
|
+
});
|
|
264
|
+
}
|
|
255
265
|
}
|
|
256
266
|
}
|
|
257
267
|
const lastMutationIndexByKey = new Map();
|
|
@@ -356,6 +366,20 @@ export class WaAppStateMutationCoordinator {
|
|
|
356
366
|
lastUpdatedMs
|
|
357
367
|
});
|
|
358
368
|
}
|
|
369
|
+
handlePushNameMutation(mutation) {
|
|
370
|
+
if (!this.pushNameSink)
|
|
371
|
+
return;
|
|
372
|
+
// A `set` under the literal index ["setting_pushName"]; cheap reject
|
|
373
|
+
// before reading the value.
|
|
374
|
+
if (mutation.operation !== 'set')
|
|
375
|
+
return;
|
|
376
|
+
if (!mutation.index.includes('setting_pushName'))
|
|
377
|
+
return;
|
|
378
|
+
const name = mutation.value?.pushNameSetting?.name;
|
|
379
|
+
if (typeof name !== 'string')
|
|
380
|
+
return;
|
|
381
|
+
this.pushNameSink(name);
|
|
382
|
+
}
|
|
359
383
|
/**
|
|
360
384
|
* Mutes or unmutes a chat. `muteEndTimestampMs` is required when
|
|
361
385
|
* `muted` is `true` and must be a non-negative safe-integer epoch.
|
|
@@ -388,7 +388,7 @@ export class WaIncomingNodeCoordinator {
|
|
|
388
388
|
try {
|
|
389
389
|
const routingInfo = decodeNodeContentBase64OrBytes(routingInfoNode.content, `ib.${WA_NODE_TAGS.EDGE_ROUTING}.${WA_NODE_TAGS.ROUTING_INFO}`);
|
|
390
390
|
await this.runtime.persistRoutingInfo(routingInfo);
|
|
391
|
-
this.logger.
|
|
391
|
+
this.logger.debug('updated routing info from info bulletin', {
|
|
392
392
|
byteLength: routingInfo.byteLength
|
|
393
393
|
});
|
|
394
394
|
}
|
|
@@ -11,7 +11,7 @@ import { wrapDeviceSentMessage } from '../../message/encode/device-sent.js';
|
|
|
11
11
|
import { writeRandomPadMax16 } from '../../message/encode/padding.js';
|
|
12
12
|
import { buildBotInvokeProtoCopy, extractInvokedBotJid, genBotMsgSecret } from '../../message/kinds/bot.js';
|
|
13
13
|
import { proto } from '../../proto.js';
|
|
14
|
-
import { WA_DEFAULTS } from '../../protocol/constants.js';
|
|
14
|
+
import { WA_DEFAULTS, WA_NACK_REASONS } from '../../protocol/constants.js';
|
|
15
15
|
import { isBotJid, isGroupJid, isHostedDeviceJid, isLidJid, isNewsletterJid, normalizeDeviceJid, normalizeRecipientJid, parseJidFull, parseSignalAddressFromJid, signalAddressKey, toUserJid } from '../../protocol/jid.js';
|
|
16
16
|
import { encodeBinaryNode } from '../../transport/binary/index.js';
|
|
17
17
|
import { buildButtonAddonNode, buildDirectMessageFanoutNode, buildGroupSenderKeyMessageNode, buildMetaNode } from '../../transport/node/builders/message.js';
|
|
@@ -23,7 +23,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
23
23
|
this.privacyTokenDedup = new PromiseDedup();
|
|
24
24
|
this.distributionDedup = new PromiseDedup();
|
|
25
25
|
this.deps = options;
|
|
26
|
-
this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? false;
|
|
26
|
+
this.mobileMessageIdFormat = options.mobileMessageIdFormat ?? (() => false);
|
|
27
27
|
this.serverClock = options.serverClock;
|
|
28
28
|
}
|
|
29
29
|
async publishMessageNode(node, options = {}) {
|
|
@@ -654,7 +654,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
654
654
|
const serverAddressingMode = result.ack.addressingMode;
|
|
655
655
|
const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
|
|
656
656
|
const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
|
|
657
|
-
const hasAddressingError = ackError ===
|
|
657
|
+
const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
|
|
658
658
|
if (!retryContext.retried &&
|
|
659
659
|
(hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
|
|
660
660
|
this.deps.logger.warn('group direct publish acknowledged with mismatch metadata', {
|
|
@@ -829,7 +829,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
829
829
|
const serverAddressingMode = result.ack.addressingMode;
|
|
830
830
|
const hasPhashMismatch = !!serverPhash && serverPhash !== localPhash;
|
|
831
831
|
const hasAddressingMismatch = !!serverAddressingMode && serverAddressingMode !== addressingMode;
|
|
832
|
-
const hasAddressingError = ackError ===
|
|
832
|
+
const hasAddressingError = ackError === WA_NACK_REASONS.STALE_GROUP_ADDRESSING_MODE;
|
|
833
833
|
if (!retryContext.retried &&
|
|
834
834
|
(hasPhashMismatch || hasAddressingMismatch || hasAddressingError)) {
|
|
835
835
|
this.deps.logger.warn('group message publish acknowledged with mismatch metadata', {
|
|
@@ -1191,7 +1191,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
1191
1191
|
const meUserJid = toUserJid(this.requireCurrentMeJid('sendMessage'));
|
|
1192
1192
|
const timestampBytes = new Uint8Array(8);
|
|
1193
1193
|
const dv = new DataView(timestampBytes.buffer, timestampBytes.byteOffset, timestampBytes.byteLength);
|
|
1194
|
-
if (this.mobileMessageIdFormat) {
|
|
1194
|
+
if (this.mobileMessageIdFormat()) {
|
|
1195
1195
|
dv.setBigUint64(0, BigInt(Date.now()), false);
|
|
1196
1196
|
const digest = md5Bytes([
|
|
1197
1197
|
timestampBytes,
|
|
@@ -1213,7 +1213,7 @@ export class WaMessageDispatchCoordinator {
|
|
|
1213
1213
|
this.deps.logger.warn('failed to generate message id, falling back to random', {
|
|
1214
1214
|
message: toError(error).message
|
|
1215
1215
|
});
|
|
1216
|
-
if (this.mobileMessageIdFormat) {
|
|
1216
|
+
if (this.mobileMessageIdFormat()) {
|
|
1217
1217
|
const bytes = await randomBytesAsync(16);
|
|
1218
1218
|
bytes[0] = 0xac;
|
|
1219
1219
|
return bytesToHex(bytes).toUpperCase();
|
|
@@ -16,7 +16,7 @@ export class WaPassiveTasksCoordinator {
|
|
|
16
16
|
this.signedPreKeyServerErrorBackoffMs =
|
|
17
17
|
options.signedPreKeyServerErrorBackoffMs ?? SIGNAL_SIGNED_PREKEY_SERVER_ERROR_BACKOFF_MS;
|
|
18
18
|
this.runtime = options.runtime;
|
|
19
|
-
this.mobilePrimary = options.mobilePrimary ?? false;
|
|
19
|
+
this.mobilePrimary = options.mobilePrimary ?? (() => false);
|
|
20
20
|
this.appStateSync = options.appStateSync;
|
|
21
21
|
this.passiveTasksPromise = null;
|
|
22
22
|
}
|
|
@@ -59,7 +59,7 @@ export class WaPassiveTasksCoordinator {
|
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
this.runtime.syncAbProps();
|
|
62
|
-
if (this.mobilePrimary && this.appStateSync) {
|
|
62
|
+
if (this.mobilePrimary() && this.appStateSync) {
|
|
63
63
|
await this.appStateSync.ensureInitialSyncKey().catch((error) => {
|
|
64
64
|
this.logger.warn('app-state initial key generation failed', {
|
|
65
65
|
message: toError(error).message
|
|
@@ -146,7 +146,7 @@ export class WaPassiveTasksCoordinator {
|
|
|
146
146
|
const response = await this.runtime.queryWithContext('prekeys.upload', uploadNode, WA_DEFAULTS.IQ_TIMEOUT_MS, {
|
|
147
147
|
count: preKeys.length,
|
|
148
148
|
lastPreKeyId
|
|
149
|
-
}, this.mobilePrimary ? { useSystemId: true } : undefined);
|
|
149
|
+
}, this.mobilePrimary() ? { useSystemId: true } : undefined);
|
|
150
150
|
if (response.attrs.type === WA_IQ_TYPES.RESULT) {
|
|
151
151
|
// Mark uploaded key first so the serverHasPreKeys flag never commits ahead of local key progress.
|
|
152
152
|
await this.preKeyStore.markKeyAsUploaded(lastPreKeyId);
|
|
@@ -192,7 +192,7 @@ function buildTextStatusMutationInput(input) {
|
|
|
192
192
|
}
|
|
193
193
|
/** Builds a {@link WaProfileCoordinator} from its IQ/MEX/SID dependencies. */
|
|
194
194
|
export function createProfileCoordinator(options) {
|
|
195
|
-
const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, logger } = options;
|
|
195
|
+
const { queryWithContext, generateSid, mexSocket, queryLidsByPhoneJids, mutations, applyOwnPushName, logger } = options;
|
|
196
196
|
return {
|
|
197
197
|
getProfilePicture: async (jid, type, existingId) => {
|
|
198
198
|
const node = buildGetProfilePictureIq(jid, type, existingId);
|
|
@@ -242,6 +242,9 @@ export function createProfileCoordinator(options) {
|
|
|
242
242
|
assertIqResult(result, 'profile.setStatus');
|
|
243
243
|
},
|
|
244
244
|
setPushName: async (name) => {
|
|
245
|
+
// Local apply first: the app-state echo of this same write then
|
|
246
|
+
// collapses into a no-op via applyOwnPushName's idempotency guard.
|
|
247
|
+
await applyOwnPushName(name);
|
|
245
248
|
await mutations.set({ schema: 'SettingPushName', name });
|
|
246
249
|
},
|
|
247
250
|
getProfiles: async (jids) => {
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { toRawPubKey } from '../../crypto/core/keys.js';
|
|
2
|
+
import { BoundedTaskQueue, BoundedTaskQueueFullError } from '../../infra/perf/BoundedTaskQueue.js';
|
|
1
3
|
import { buildRecoveredIncomingEvent } from '../../message/primitives/incoming.js';
|
|
2
4
|
import { proto } from '../../proto.js';
|
|
3
|
-
import { WA_MESSAGE_TAGS, WA_MESSAGE_TYPES } from '../../protocol/constants.js';
|
|
5
|
+
import { WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_NACK_REASONS } from '../../protocol/constants.js';
|
|
4
6
|
import { isGroupOrBroadcastJid, isHostedDeviceJid, normalizeDeviceJid, parseJidFull, parseSignalAddressFromJid, toUserJid } from '../../protocol/jid.js';
|
|
5
7
|
import { MAX_RETRY_ATTEMPTS, RETRY_KEYS_MIN_COUNT, RETRY_OUTBOUND_TTL_MS, RETRY_REASON } from '../../retry/constants.js';
|
|
6
8
|
import { parseRetryReceiptRequest, pickRetryStateMax } from '../../retry/parse.js';
|
|
@@ -14,6 +16,10 @@ import { setBoundedMapEntry } from '../../util/collections.js';
|
|
|
14
16
|
import { toError } from '../../util/primitives.js';
|
|
15
17
|
const RETRY_CLEANUP_INTERVAL_MS = 30000;
|
|
16
18
|
const RETRY_SESSION_BASE_KEY_CACHE_MAX_ENTRIES = 8192;
|
|
19
|
+
// Decrypt-failure handling runs off the inbound pipeline (keys-section builds
|
|
20
|
+
// serialize on the prekey lock and hit the store); excess under flood is dropped.
|
|
21
|
+
const DECRYPT_FAILURE_QUEUE_MAX_SIZE = 1024;
|
|
22
|
+
const DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY = 8;
|
|
17
23
|
const PLACEHOLDER_RESEND_RETRY_THRESHOLD = 3;
|
|
18
24
|
const PLACEHOLDER_RESEND_BATCH_SIZE = 32;
|
|
19
25
|
const PLACEHOLDER_RESEND_DEBOUNCE_MS = 200;
|
|
@@ -55,22 +61,46 @@ export class WaRetryCoordinator {
|
|
|
55
61
|
signalProtocol: options.signalProtocol,
|
|
56
62
|
sessionResolver: options.sessionResolver,
|
|
57
63
|
getCurrentCredentials: options.getCurrentCredentials,
|
|
58
|
-
resolveUserIcdc: options.resolveUserIcdc
|
|
64
|
+
resolveUserIcdc: options.resolveUserIcdc,
|
|
65
|
+
resolvePrivacyTokenNode: options.resolvePrivacyTokenNode
|
|
59
66
|
});
|
|
60
67
|
this.retryProcessingByMessageId = new Map();
|
|
61
68
|
this.retrySessionBaseKeys = new Map();
|
|
69
|
+
this.decryptFailureQueue = new BoundedTaskQueue(DECRYPT_FAILURE_QUEUE_MAX_SIZE, DECRYPT_FAILURE_QUEUE_MAX_CONCURRENCY);
|
|
62
70
|
}
|
|
63
|
-
|
|
71
|
+
onDecryptFailure(context, error) {
|
|
72
|
+
// Deferred to the bounded queue: building the receipt inline would stall
|
|
73
|
+
// the inbound node pipeline.
|
|
74
|
+
void this.decryptFailureQueue
|
|
75
|
+
.enqueue(() => this.handleDecryptFailure(context, error))
|
|
76
|
+
.catch((queueError) => {
|
|
77
|
+
if (queueError instanceof BoundedTaskQueueFullError) {
|
|
78
|
+
this.deps.logger.warn('decrypt-failure retry dropped: queue saturated', {
|
|
79
|
+
id: context.stanzaId,
|
|
80
|
+
from: context.from,
|
|
81
|
+
participant: context.participant
|
|
82
|
+
});
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
this.deps.logger.warn('failed to schedule decrypt-failure retry', {
|
|
86
|
+
id: context.stanzaId,
|
|
87
|
+
from: context.from,
|
|
88
|
+
participant: context.participant,
|
|
89
|
+
message: toError(queueError).message
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
return Promise.resolve(true);
|
|
93
|
+
}
|
|
94
|
+
async handleDecryptFailure(context, error) {
|
|
64
95
|
try {
|
|
65
96
|
const prepared = await this.prepareDecryptFailureRetry(context, error);
|
|
66
|
-
if (!prepared) {
|
|
67
|
-
|
|
97
|
+
if (prepared && !prepared.delegatedToPlaceholderResend) {
|
|
98
|
+
await this.sendDecryptFailureRetryReceipt(context, prepared);
|
|
68
99
|
}
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
await this.
|
|
73
|
-
return true;
|
|
100
|
+
// Ack the failed stanza even on the give-up path: the retry receipt
|
|
101
|
+
// asks for a resend but does not consume the message, so without the
|
|
102
|
+
// ack it is redelivered on every offline resume.
|
|
103
|
+
await this.sendDecryptFailureAck(context);
|
|
74
104
|
}
|
|
75
105
|
catch (sendError) {
|
|
76
106
|
this.deps.logger.warn('failed to send retry receipt for decrypt failure', {
|
|
@@ -79,7 +109,6 @@ export class WaRetryCoordinator {
|
|
|
79
109
|
participant: context.participant,
|
|
80
110
|
message: toError(sendError).message
|
|
81
111
|
});
|
|
82
|
-
return false;
|
|
83
112
|
}
|
|
84
113
|
}
|
|
85
114
|
async handleIncomingRetryReceipt(receiptNode) {
|
|
@@ -138,6 +167,17 @@ export class WaRetryCoordinator {
|
|
|
138
167
|
const requester = context.participant ?? context.from;
|
|
139
168
|
const expiresAtMs = nowMs + this.retryTtlMs;
|
|
140
169
|
const retryCount = await this.deps.retryStore.incrementInboundCounter(context.stanzaId, requester, nowMs, expiresAtMs);
|
|
170
|
+
if (retryCount > MAX_RETRY_ATTEMPTS) {
|
|
171
|
+
// Give up past the ceiling: each attempt rebuilds an expensive keys
|
|
172
|
+
// section, so an uncapped retry on redelivered backlog hammers the store.
|
|
173
|
+
this.deps.logger.debug('retry receipt skipped: inbound retry limit exceeded', {
|
|
174
|
+
id: context.stanzaId,
|
|
175
|
+
from: context.from,
|
|
176
|
+
participant: context.participant,
|
|
177
|
+
retryCount
|
|
178
|
+
});
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
141
181
|
const delegatedToPlaceholderResend = retryCount >= PLACEHOLDER_RESEND_RETRY_THRESHOLD &&
|
|
142
182
|
this.enqueuePlaceholderResend(context);
|
|
143
183
|
if (delegatedToPlaceholderResend) {
|
|
@@ -166,7 +206,7 @@ export class WaRetryCoordinator {
|
|
|
166
206
|
};
|
|
167
207
|
}
|
|
168
208
|
async sendDecryptFailureRetryReceipt(context, prepared) {
|
|
169
|
-
const recipient = context
|
|
209
|
+
const { recipient } = context;
|
|
170
210
|
const retryReceiptNode = buildRetryReceiptNode({
|
|
171
211
|
stanzaId: context.stanzaId,
|
|
172
212
|
to: context.from,
|
|
@@ -191,24 +231,33 @@ export class WaRetryCoordinator {
|
|
|
191
231
|
withKeys: prepared.retryKeys !== undefined
|
|
192
232
|
});
|
|
193
233
|
}
|
|
194
|
-
|
|
195
|
-
if (!context.
|
|
196
|
-
return
|
|
197
|
-
}
|
|
198
|
-
const meLid = this.deps.getCurrentCredentials()?.meLid;
|
|
199
|
-
if (!meLid) {
|
|
200
|
-
return undefined;
|
|
234
|
+
async sendDecryptFailureAck(context) {
|
|
235
|
+
if (!context.stanzaId || !context.from) {
|
|
236
|
+
return;
|
|
201
237
|
}
|
|
202
238
|
try {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
239
|
+
await this.deps.sendNode(buildAckNode({
|
|
240
|
+
kind: 'message',
|
|
241
|
+
node: context.messageNode,
|
|
242
|
+
id: context.stanzaId,
|
|
243
|
+
to: context.from,
|
|
244
|
+
participant: context.participant,
|
|
245
|
+
from: this.deps.getCurrentCredentials()?.meJid ?? undefined,
|
|
246
|
+
error: WA_NACK_REASONS.UNHANDLED_ERROR
|
|
247
|
+
}));
|
|
248
|
+
this.deps.logger.trace('acked undecryptable stanza', {
|
|
249
|
+
id: context.stanzaId,
|
|
250
|
+
from: context.from,
|
|
251
|
+
participant: context.participant
|
|
252
|
+
});
|
|
209
253
|
}
|
|
210
|
-
catch {
|
|
211
|
-
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.deps.logger.warn('failed to ack undecryptable stanza', {
|
|
256
|
+
id: context.stanzaId,
|
|
257
|
+
from: context.from,
|
|
258
|
+
participant: context.participant,
|
|
259
|
+
message: toError(error).message
|
|
260
|
+
});
|
|
212
261
|
}
|
|
213
262
|
}
|
|
214
263
|
async handleParsedRetryRequest(receiptNode, request) {
|
|
@@ -384,14 +433,14 @@ export class WaRetryCoordinator {
|
|
|
384
433
|
await this.deps.preKeyStore.markKeyAsUploaded(preKey.keyId);
|
|
385
434
|
const signedIdentity = this.deps.getCurrentCredentials()?.signedIdentity;
|
|
386
435
|
return {
|
|
387
|
-
identity,
|
|
436
|
+
identity: toRawPubKey(identity),
|
|
388
437
|
key: {
|
|
389
438
|
id: preKey.keyId,
|
|
390
|
-
publicKey: preKey.keyPair.pubKey
|
|
439
|
+
publicKey: toRawPubKey(preKey.keyPair.pubKey)
|
|
391
440
|
},
|
|
392
441
|
skey: {
|
|
393
442
|
id: signedPreKey.keyId,
|
|
394
|
-
publicKey: signedPreKey.keyPair.pubKey,
|
|
443
|
+
publicKey: toRawPubKey(signedPreKey.keyPair.pubKey),
|
|
395
444
|
signature: signedPreKey.signature
|
|
396
445
|
},
|
|
397
446
|
deviceIdentity: signedIdentity
|
|
@@ -719,6 +768,9 @@ export class WaRetryCoordinator {
|
|
|
719
768
|
if (!this.deps.peerDataOperation || !this.deps.emitIncomingMessage) {
|
|
720
769
|
return false;
|
|
721
770
|
}
|
|
771
|
+
if (this.deps.isMobilePrimary?.()) {
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
722
774
|
const subtype = context.messageNode.attrs.subtype;
|
|
723
775
|
if (typeof subtype === 'string' && PLACEHOLDER_RESEND_SKIP_SUBTYPES.has(subtype)) {
|
|
724
776
|
return false;
|
|
@@ -776,6 +828,7 @@ export class WaRetryCoordinator {
|
|
|
776
828
|
}
|
|
777
829
|
}))
|
|
778
830
|
});
|
|
831
|
+
const meJid = this.deps.getCurrentCredentials()?.meJid;
|
|
779
832
|
for (const result of results) {
|
|
780
833
|
const bytes = result.placeholderMessageResendResponse?.webMessageInfoBytes;
|
|
781
834
|
if (!bytes) {
|
|
@@ -783,7 +836,7 @@ export class WaRetryCoordinator {
|
|
|
783
836
|
}
|
|
784
837
|
try {
|
|
785
838
|
const recovered = proto.WebMessageInfo.decode(bytes);
|
|
786
|
-
emitIncomingMessage(buildRecoveredIncomingEvent(recovered));
|
|
839
|
+
emitIncomingMessage(buildRecoveredIncomingEvent(recovered, meJid));
|
|
787
840
|
}
|
|
788
841
|
catch (error) {
|
|
789
842
|
this.deps.logger.warn('placeholder resend: failed to decode WebMessageInfo', {
|
|
@@ -82,12 +82,14 @@ export function extractIgnoreKeyContext(node, meJid) {
|
|
|
82
82
|
const me = tryParseJid(meJid);
|
|
83
83
|
const fromCandidates = collectFromCandidates(kind, a);
|
|
84
84
|
const fromMe = me !== null && fromCandidates.some((f) => tryParseJid(f)?.address.user === me.address.user);
|
|
85
|
+
// Device-stripped to match the JID form used by events/keys; a userless
|
|
86
|
+
// server `from` like `s.whatsapp.net` is unparseable, so fall back to raw.
|
|
85
87
|
return {
|
|
86
88
|
kind,
|
|
87
|
-
remoteJid: a.from ?? null,
|
|
89
|
+
remoteJid: tryParseJid(a.from)?.userJid ?? a.from ?? null,
|
|
88
90
|
fromMe,
|
|
89
91
|
id: a.id,
|
|
90
|
-
participant: a.participant ?? null
|
|
92
|
+
participant: tryParseJid(a.participant)?.userJid ?? a.participant ?? null
|
|
91
93
|
};
|
|
92
94
|
}
|
|
93
95
|
/** Pure matcher. Exported for direct testing without a coordinator. */
|
|
@@ -69,13 +69,22 @@ function buildIncomingEventRawNode(node) {
|
|
|
69
69
|
content: children
|
|
70
70
|
};
|
|
71
71
|
}
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Rebuilds a `message` event from a recovered {@link proto.IWebMessageInfo}
|
|
74
|
+
* (placeholder resend). `meJid` is the current account user JID, used as the
|
|
75
|
+
* author fallback for self-sent group messages when the proto carries no
|
|
76
|
+
* participant and no `originalSelfAuthorUserJidString`.
|
|
77
|
+
*/
|
|
78
|
+
export function buildRecoveredIncomingEvent(webMessageInfo, meJid) {
|
|
73
79
|
const key = webMessageInfo.key ?? {};
|
|
74
80
|
const chatJid = key.remoteJid ?? undefined;
|
|
75
81
|
const fromMe = key.fromMe === true;
|
|
76
|
-
const participant = key.participant ?? undefined;
|
|
77
82
|
const isGroup = chatJid ? isGroupJid(chatJid) : false;
|
|
78
83
|
const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
|
|
84
|
+
const rawSelfAuthor = webMessageInfo.originalSelfAuthorUserJidString ?? meJid ?? undefined;
|
|
85
|
+
const selfAuthor = fromMe && (isGroup || isBroadcast) && rawSelfAuthor ? toUserJid(rawSelfAuthor) : undefined;
|
|
86
|
+
const participant = webMessageInfo.participant ?? key.participant ?? selfAuthor;
|
|
87
|
+
const pushName = webMessageInfo.pushName ?? undefined;
|
|
79
88
|
const rawSender = fromMe ? undefined : isGroup || isBroadcast ? participant : chatJid;
|
|
80
89
|
const sender = rawSender ? parseJidFull(rawSender) : null;
|
|
81
90
|
const senderDevice = sender?.address.device;
|
|
@@ -90,7 +99,8 @@ export function buildRecoveredIncomingEvent(webMessageInfo) {
|
|
|
90
99
|
attrs: {
|
|
91
100
|
...(stanzaId !== undefined ? { id: stanzaId } : {}),
|
|
92
101
|
...(chatJid !== undefined ? { from: chatJid } : {}),
|
|
93
|
-
...(participant !== undefined ? { participant } : {})
|
|
102
|
+
...(participant !== undefined ? { participant } : {}),
|
|
103
|
+
...(pushName !== undefined ? { notify: pushName } : {})
|
|
94
104
|
}
|
|
95
105
|
};
|
|
96
106
|
return {
|
|
@@ -107,6 +117,7 @@ export function buildRecoveredIncomingEvent(webMessageInfo) {
|
|
|
107
117
|
},
|
|
108
118
|
timestampSeconds,
|
|
109
119
|
...(expirationSeconds !== undefined ? { expirationSeconds } : {}),
|
|
120
|
+
...(pushName !== undefined ? { pushName } : {}),
|
|
110
121
|
message
|
|
111
122
|
};
|
|
112
123
|
}
|
|
@@ -229,7 +240,7 @@ function processMsmsgEncNode(node, encNode, senderJid, options) {
|
|
|
229
240
|
encPayload: decoded.encPayload
|
|
230
241
|
}
|
|
231
242
|
};
|
|
232
|
-
const chatJid = node.attrs.from;
|
|
243
|
+
const chatJid = node.attrs.from ? toUserJid(node.attrs.from) : node.attrs.from;
|
|
233
244
|
const sender = senderJid ? parseJidFull(senderJid) : null;
|
|
234
245
|
const isGroup = chatJid ? isGroupJid(chatJid) : false;
|
|
235
246
|
const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
|
|
@@ -302,7 +313,11 @@ async function decryptAndProcessEncNode(node, encNode, encType, senderJid, optio
|
|
|
302
313
|
}
|
|
303
314
|
}
|
|
304
315
|
if (shouldEmitIncomingMessage(message)) {
|
|
305
|
-
|
|
316
|
+
// remoteJid is the chat identity, which is deviceless: the device
|
|
317
|
+
// lives in senderDevice (from senderAddress), so strip any `:device`
|
|
318
|
+
// segment the `from` attr carries for 1:1 chats.
|
|
319
|
+
const fromAttr = node.attrs.from;
|
|
320
|
+
const chatJid = fromAttr ? toUserJid(fromAttr) : fromAttr;
|
|
306
321
|
const isGroup = chatJid ? isGroupJid(chatJid) : false;
|
|
307
322
|
const isBroadcast = chatJid ? isBroadcastJid(chatJid) : false;
|
|
308
323
|
const senderUserJid = `${senderAddress.user}@${senderAddress.server}`;
|
|
@@ -3,7 +3,7 @@ export { WA_SIGNALING, WA_PAIRING_KDF_INFO } from './auth.js';
|
|
|
3
3
|
export { WA_CONNECTION_REASONS, WA_DISCONNECT_REASONS, WA_FAILURE_REASONS, WA_LOGOUT_REASONS, WA_READY_STATES, WA_STREAM_SIGNALING } from './stream.js';
|
|
4
4
|
export { WA_IQ_TYPES, WA_NODE_TAGS, WA_XMLNS } from './nodes.js';
|
|
5
5
|
export { WA_CALL_CHILD_TAGS, WA_CALL_NODE_ATTRS, WA_CALL_PAYLOAD_TAGS, WA_CALL_RECEIPT_PAYLOAD_TAGS } from './call.js';
|
|
6
|
-
export { WA_EDIT_ATTRS, WA_ENC_MEDIA_TYPES, WA_EVENT_META_TYPES, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_POLL_META_TYPES, WA_RETRYABLE_ACK_CODES, WA_STANZA_MSG_TYPES } from './message.js';
|
|
6
|
+
export { WA_EDIT_ATTRS, WA_ENC_MEDIA_TYPES, WA_EVENT_META_TYPES, WA_MESSAGE_TAGS, WA_MESSAGE_TYPES, WA_NACK_REASONS, WA_POLL_META_TYPES, WA_RETRYABLE_ACK_CODES, WA_STANZA_MSG_TYPES } from './message.js';
|
|
7
7
|
export { WA_APP_STATE_COLLECTIONS, WA_APP_STATE_COLLECTION_STATES, WA_APP_STATE_ERROR_CODES, WA_APP_STATE_KDF_INFO, WA_APP_STATE_KEY_TYPES, WA_APP_STATE_SYNC_DATA_TYPE } from './appstate.js';
|
|
8
8
|
export { getWaMediaHkdfInfo, WA_MEDIA_HKDF_INFO, WA_PREVIEW_MEDIA_HKDF_INFO } from './media.js';
|
|
9
9
|
export { WA_ACCOUNT_SYNC_PROTOCOLS, WA_DIRTY_PROTOCOLS, WA_DIRTY_TYPES, WA_SUPPORTED_DIRTY_TYPES } from './dirty.js';
|
|
@@ -27,6 +27,28 @@ export const WA_MESSAGE_TYPES = Object.freeze({
|
|
|
27
27
|
RECEIPT_TYPE_VIEW: 'view'
|
|
28
28
|
});
|
|
29
29
|
export const WA_RETRYABLE_ACK_CODES = Object.freeze(['408', '429', '500', '503']);
|
|
30
|
+
/**
|
|
31
|
+
* Stanza-ack `error` attr codes (nack reasons): sent when nacking a stanza
|
|
32
|
+
* that could not be handled, and matched against the `error` carried by an
|
|
33
|
+
* inbound publish ack (e.g. stale group addressing mode).
|
|
34
|
+
*/
|
|
35
|
+
export const WA_NACK_REASONS = Object.freeze({
|
|
36
|
+
STALE_GROUP_ADDRESSING_MODE: 421,
|
|
37
|
+
NEW_CHAT_MESSAGES_CAPPED: 475,
|
|
38
|
+
PARSING_ERROR: 487,
|
|
39
|
+
UNRECOGNIZED_STANZA: 488,
|
|
40
|
+
UNRECOGNIZED_STANZA_CLASS: 489,
|
|
41
|
+
UNRECOGNIZED_STANZA_TYPE: 490,
|
|
42
|
+
INVALID_PROTOBUF: 491,
|
|
43
|
+
INVALID_HOSTED_COMPANION_STANZA: 493,
|
|
44
|
+
MISSING_MESSAGE_SECRET: 495,
|
|
45
|
+
SIGNAL_ERROR_OLD_COUNTER: 496,
|
|
46
|
+
MESSAGE_DELETED_ON_PEER: 499,
|
|
47
|
+
UNHANDLED_ERROR: 500,
|
|
48
|
+
UNSUPPORTED_ADMIN_REVOKE: 550,
|
|
49
|
+
UNSUPPORTED_LID_GROUP: 551,
|
|
50
|
+
DB_OPERATION_FAILED: 552
|
|
51
|
+
});
|
|
30
52
|
export const WA_STANZA_MSG_TYPES = Object.freeze({
|
|
31
53
|
TEXT: 'text',
|
|
32
54
|
MEDIA: 'media',
|
package/dist/esm/retry/replay.js
CHANGED
|
@@ -66,6 +66,9 @@ export class WaRetryReplayService {
|
|
|
66
66
|
const metaNode = isHostedDeviceJid(requesterJid)
|
|
67
67
|
? buildMetaNode({ sender_intent: 'hosted' })
|
|
68
68
|
: undefined;
|
|
69
|
+
const privacyTokenNode = requesterIsSelf
|
|
70
|
+
? undefined
|
|
71
|
+
: await this.resolvePrivacyToken(requesterJid);
|
|
69
72
|
await this.options.messageClient.sendEncrypted({
|
|
70
73
|
to: requesterJid,
|
|
71
74
|
encType: encrypted.type,
|
|
@@ -74,7 +77,8 @@ export class WaRetryReplayService {
|
|
|
74
77
|
id: outbound.messageId,
|
|
75
78
|
type: payload.type,
|
|
76
79
|
deviceIdentity,
|
|
77
|
-
metaNode
|
|
80
|
+
metaNode,
|
|
81
|
+
privacyTokenNode
|
|
78
82
|
});
|
|
79
83
|
return 'resent';
|
|
80
84
|
}
|
|
@@ -88,6 +92,32 @@ export class WaRetryReplayService {
|
|
|
88
92
|
}
|
|
89
93
|
return proto.ADVSignedDeviceIdentity.encode(signedIdentity).finish();
|
|
90
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Resolves the trusted-contact token node for the requester's user jid. A
|
|
97
|
+
* failure (or absent resolver) yields no node and the resend still goes out.
|
|
98
|
+
*/
|
|
99
|
+
async resolvePrivacyToken(requesterJid) {
|
|
100
|
+
if (!this.options.resolvePrivacyTokenNode) {
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
|
103
|
+
let recipientUserJid;
|
|
104
|
+
try {
|
|
105
|
+
recipientUserJid = toUserJid(requesterJid);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
return (await this.options.resolvePrivacyTokenNode(recipientUserJid)) ?? undefined;
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
this.options.logger.warn('retry resend privacy token resolution failed', {
|
|
115
|
+
to: recipientUserJid,
|
|
116
|
+
message: toError(error).message
|
|
117
|
+
});
|
|
118
|
+
return undefined;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
91
121
|
async refreshRetryPlaintext(payload, options) {
|
|
92
122
|
if (!options.wrapAsDeviceSent && !this.options.resolveUserIcdc) {
|
|
93
123
|
return null;
|
|
@@ -199,6 +229,9 @@ export class WaRetryReplayService {
|
|
|
199
229
|
const metaNode = isHostedDeviceJid(requesterJid)
|
|
200
230
|
? buildMetaNode({ sender_intent: 'hosted' })
|
|
201
231
|
: undefined;
|
|
232
|
+
const privacyTokenNode = this.isRequesterCurrentAccount(requesterJid)
|
|
233
|
+
? undefined
|
|
234
|
+
: await this.resolvePrivacyToken(requesterJid);
|
|
202
235
|
await this.options.messageClient.sendEncrypted({
|
|
203
236
|
to: requesterJid,
|
|
204
237
|
encType: payload.encType,
|
|
@@ -208,7 +241,8 @@ export class WaRetryReplayService {
|
|
|
208
241
|
type: payload.type,
|
|
209
242
|
participant: payload.participant,
|
|
210
243
|
deviceIdentity,
|
|
211
|
-
metaNode
|
|
244
|
+
metaNode,
|
|
245
|
+
privacyTokenNode
|
|
212
246
|
});
|
|
213
247
|
return 'resent';
|
|
214
248
|
}
|
|
@@ -156,9 +156,9 @@ export async function decryptMsg(session, parsed, onPrevSessionDecryptError) {
|
|
|
156
156
|
}
|
|
157
157
|
catch (error) {
|
|
158
158
|
for (let i = 0; i < session.prevSessions.length; i += 1) {
|
|
159
|
-
const decodedPrev = decodeSignalSessionSnapshot(session.prevSessions[i], `prevSessions[${i}]`);
|
|
160
|
-
const prevSession = snapshotToRecord(decodedPrev);
|
|
161
159
|
try {
|
|
160
|
+
const decodedPrev = decodeSignalSessionSnapshot(session.prevSessions[i], `prevSessions[${i}]`);
|
|
161
|
+
const prevSession = snapshotToRecord(decodedPrev);
|
|
162
162
|
const [updatedPrev, plaintext] = await decryptMsgFromSession(prevSession, parsed);
|
|
163
163
|
const updatedSession = {
|
|
164
164
|
...updatedPrev,
|
|
@@ -169,6 +169,10 @@ export class WaComms {
|
|
|
169
169
|
await this.socket.close(1000, 'stop_comms');
|
|
170
170
|
}
|
|
171
171
|
async closeSocketAndResume() {
|
|
172
|
+
if (!this.started || this.preventRetry) {
|
|
173
|
+
this.logger.debug('comms resume skipped: comms stopped or retry disabled');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
172
176
|
this.logger.debug('comms close socket and resume requested');
|
|
173
177
|
this.resetConnectionState({
|
|
174
178
|
started: true,
|
|
@@ -101,6 +101,10 @@ export class WaKeepAlive {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
catch (error) {
|
|
104
|
+
if (generation !== this.generation) {
|
|
105
|
+
this.logger.trace('keepalive stopped during in-flight ping, not resuming');
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
104
108
|
this.logger.warn('keepalive ping failed, reconnecting socket', {
|
|
105
109
|
message: toError(error).message
|
|
106
110
|
});
|
|
@@ -12,7 +12,7 @@ export class WaNodeOrchestrator {
|
|
|
12
12
|
this.sendNodeFn = options.sendNode;
|
|
13
13
|
this.defaultTimeoutMs = options.defaultTimeoutMs ?? WA_DEFAULTS.NODE_QUERY_TIMEOUT_MS;
|
|
14
14
|
this.hostDomain = options.hostDomain ?? WA_DEFAULTS.HOST_DOMAIN;
|
|
15
|
-
this.mobileIqIdFormat = options.mobileIqIdFormat
|
|
15
|
+
this.mobileIqIdFormat = options.mobileIqIdFormat ?? (() => false);
|
|
16
16
|
this.idGenerator = null;
|
|
17
17
|
this.idGeneratorReady = null;
|
|
18
18
|
this.pendingQueries = new Map();
|
|
@@ -134,7 +134,7 @@ export class WaNodeOrchestrator {
|
|
|
134
134
|
if (this.idGenerator) {
|
|
135
135
|
return this.idGenerator;
|
|
136
136
|
}
|
|
137
|
-
if (this.mobileIqIdFormat) {
|
|
137
|
+
if (this.mobileIqIdFormat()) {
|
|
138
138
|
this.idGenerator = createMobileNodeIdGenerator();
|
|
139
139
|
this.logger.debug('generated stanza prefix (mobile)', {
|
|
140
140
|
prefix: this.idGenerator.prefix
|
|
@@ -57,6 +57,9 @@ export function buildAckNode(input) {
|
|
|
57
57
|
to: input.to,
|
|
58
58
|
class: WA_MESSAGE_TYPES.ACK_CLASS_MESSAGE
|
|
59
59
|
};
|
|
60
|
+
if (input.error !== undefined) {
|
|
61
|
+
attrs.error = String(input.error);
|
|
62
|
+
}
|
|
60
63
|
const type = input.typeOverride ?? input.node.attrs.type;
|
|
61
64
|
if (type) {
|
|
62
65
|
attrs.type = type;
|