@crysnovax/baileys 2.0.0 → 2.5.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.
@@ -4,21 +4,26 @@ import { randomBytes } from 'crypto';
4
4
  import Long from 'long';
5
5
  import { proto } from '../../WAProto/index.js';
6
6
  import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js';
7
- import { WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
- import { aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
7
+ import { ReachoutTimelockEnforcementType, WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
+ import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from '../Utils/index.js';
9
9
  import { makeMutex } from '../Utils/make-mutex.js';
10
10
  import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js';
11
11
  import { buildAckStanza } from '../Utils/stanza-ack.js';
12
- import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
12
+ import { buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resolveIssuanceJid, resolveTcTokenJid, storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY } from '../Utils/tc-token-utils.js';
13
+ import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
13
14
  import { extractGroupMetadata } from './groups.js';
14
15
  import { makeMessagesSocket } from './messages-send.js';
16
+ const ENFORCEMENT_TYPE_VALUES = new Set(Object.values(ReachoutTimelockEnforcementType));
17
+ function isValidEnforcementType(value) {
18
+ return typeof value === 'string' && ENFORCEMENT_TYPE_VALUES.has(value);
19
+ }
15
20
  export const makeMessagesRecvSocket = (config) => {
16
21
  const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config;
17
22
  const sock = makeMessagesSocket(config);
18
- const { ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, generateMessageTag, messageRetryManager, registerSocketEndHandler } = sock;
23
+ const { userDevicesCache, devicesMutex, ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, registerSocketEndHandler, issuePrivacyTokens, fetchAccountReachoutTimelock, placeholderResendCache } = sock;
24
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping);
19
25
  /** this mutex ensures that each retryRequest will wait for the previous one to finish */
20
26
  const retryMutex = makeMutex();
21
- const devicesMutex = makeMutex();
22
27
  const msgRetryCache = config.msgRetryCounterCache ||
23
28
  new NodeCache({
24
29
  stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
@@ -29,16 +34,6 @@ export const makeMessagesRecvSocket = (config) => {
29
34
  stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins
30
35
  useClones: false
31
36
  });
32
- const placeholderResendCache = config.placeholderResendCache ||
33
- new NodeCache({
34
- stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
35
- useClones: false
36
- });
37
- const userDevicesCache = config.userDevicesCache ??=
38
- new NodeCache({
39
- stdTTL: DEFAULT_CACHE_TTLS.USER_DEVICES, // 5 minutes
40
- useClones: false
41
- });
42
37
  // Debounce identity-change session refreshes per JID to avoid bursts
43
38
  const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false });
44
39
  let sendActiveReceipts = false;
@@ -92,25 +87,140 @@ export const makeMessagesRecvSocket = (config) => {
92
87
  }, 8000);
93
88
  return sendPeerDataOperationMessage(pdoMessage);
94
89
  };
95
- // Handles mex newsletter notifications
96
- const handleMexNewsletterNotification = async (node) => {
90
+ const handleMexNotification = async (node) => {
91
+ const updateNode = getBinaryNodeChild(node, 'update');
92
+ if (updateNode) {
93
+ const opName = updateNode.attrs?.op_name;
94
+ if (!opName) {
95
+ logger.warn({ node: binaryNodeToString(node) }, 'mex notification missing op_name, fallback to legacy');
96
+ await handleLegacyMexNewsletterNotification(node);
97
+ return;
98
+ }
99
+ let mexResponse;
100
+ try {
101
+ mexResponse = JSON.parse(updateNode.content.toString());
102
+ }
103
+ catch (error) {
104
+ logger.error({ err: error, opName }, 'failed to parse mex notification JSON');
105
+ return;
106
+ }
107
+ if (mexResponse.errors?.length) {
108
+ logger.warn({ errors: mexResponse.errors, opName }, 'mex notification has GQL errors');
109
+ return;
110
+ }
111
+ const data = mexResponse.data;
112
+ if (!data) {
113
+ logger.warn({ opName }, 'mex notification has null data');
114
+ return;
115
+ }
116
+ logger.debug({ opName }, 'processing mex notification');
117
+ switch (opName) {
118
+ case 'NotificationUserReachoutTimelockUpdate':
119
+ handleReachoutTimelockNotification(data);
120
+ break;
121
+ case 'MessageCappingInfoNotification':
122
+ handleMessageCappingNotification(data);
123
+ break;
124
+ // newsletter ops still use the legacy <mex> child structure
125
+ case 'NotificationNewsletterUpdate':
126
+ case 'NotificationLinkedProfilesUpdates':
127
+ case 'NotificationNewsletterAdminPromote':
128
+ case 'NotificationNewsletterAdminDemote':
129
+ case 'NotificationNewsletterUserSettingChange':
130
+ case 'NotificationNewsletterJoin':
131
+ case 'NotificationNewsletterLeave':
132
+ case 'NotificationNewsletterStateChange':
133
+ case 'NotificationNewsletterAdminMetadataUpdate':
134
+ case 'NotificationNewsletterOwnerUpdate':
135
+ case 'NotificationNewsletterAdminInviteRevoke':
136
+ case 'NotificationNewsletterWamoSubStatusChange':
137
+ case 'NotificationNewsletterBlockUser':
138
+ case 'NotificationNewsletterPaidPartnership':
139
+ case 'NotificationNewsletterMilestone':
140
+ case 'NewsletterResponseStateUpdate':
141
+ await handleLegacyMexNewsletterNotification(node);
142
+ break;
143
+ default:
144
+ logger.debug({ opName }, 'unhandled mex notification');
145
+ break;
146
+ }
147
+ return;
148
+ }
149
+ await handleLegacyMexNewsletterNotification(node);
150
+ };
151
+ const handleReachoutTimelockNotification = (data) => {
152
+ const payload = data.xwa2_notify_account_reachout_timelock;
153
+ if (!payload) {
154
+ logger.warn('reachout timelock notification missing payload');
155
+ return;
156
+ }
157
+ if (!payload.is_active) {
158
+ logger.info('reachout timelock restriction lifted');
159
+ ev.emit('connection.update', {
160
+ reachoutTimeLock: {
161
+ isActive: false,
162
+ enforcementType: ReachoutTimelockEnforcementType.DEFAULT
163
+ }
164
+ });
165
+ return;
166
+ }
167
+ // WA Web defaults to now+60s when the server omits the expiry
168
+ const timeEnforcementEnds = payload.time_enforcement_ends
169
+ ? new Date(parseInt(payload.time_enforcement_ends, 10) * 1000)
170
+ : new Date(Date.now() + 60000);
171
+ const enforcementType = isValidEnforcementType(payload.enforcement_type)
172
+ ? payload.enforcement_type
173
+ : ReachoutTimelockEnforcementType.DEFAULT;
174
+ logger.info({ enforcementType, timeEnforcementEnds }, 'reachout timelock restriction set');
175
+ ev.emit('connection.update', {
176
+ reachoutTimeLock: {
177
+ isActive: true,
178
+ timeEnforcementEnds,
179
+ enforcementType
180
+ }
181
+ });
182
+ };
183
+ const handleMessageCappingNotification = (data) => {
184
+ const payload = data.xwa2_notify_new_chat_messages_capping_info_update;
185
+ if (!payload) {
186
+ logger.warn('message capping notification missing payload');
187
+ return;
188
+ }
189
+ logger.info({ payload }, 'received message capping update');
190
+ ev.emit('message-capping.update', payload);
191
+ };
192
+ const handleLegacyMexNewsletterNotification = async (node) => {
97
193
  const mexNode = getBinaryNodeChild(node, 'mex');
98
- if (!mexNode?.content) {
99
- logger.warn({ node }, 'Invalid mex newsletter notification');
194
+ const updateNode = mexNode?.content ? null : getBinaryNodeChild(node, 'update') || getAllBinaryNodeChildren(node)[0];
195
+ const payloadNode = mexNode?.content ? mexNode : updateNode;
196
+ if (!payloadNode?.content) {
197
+ logger.warn({ node: binaryNodeToString(node) }, 'invalid mex newsletter notification');
100
198
  return;
101
199
  }
102
200
  let data;
103
201
  try {
104
- data = JSON.parse(mexNode.content.toString());
202
+ const payloadContent = payloadNode.content;
203
+ if (Array.isArray(payloadContent)) {
204
+ logger.warn({ payloadNode }, 'invalid mex newsletter notification payload format');
205
+ return;
206
+ }
207
+ const contentBuf = typeof payloadContent === 'string' ? Buffer.from(payloadContent, 'binary') : Buffer.from(payloadContent);
208
+ data = JSON.parse(contentBuf.toString());
105
209
  }
106
210
  catch (error) {
107
- logger.error({ err: error, node }, 'Failed to parse mex newsletter notification');
211
+ logger.error({ err: error, node: binaryNodeToString(node) }, 'failed to parse mex newsletter notification');
108
212
  return;
109
213
  }
110
- const operation = data?.operation;
111
- const updates = data?.updates;
214
+ const operation = data?.operation ?? payloadNode?.attrs?.op_name;
215
+ let updates = data?.updates;
216
+ if (!updates) {
217
+ const linkedProfiles = data?.data?.xwa2_notify_linked_profiles;
218
+ if (linkedProfiles) {
219
+ updates = [linkedProfiles];
220
+ }
221
+ }
112
222
  if (!updates || !operation) {
113
- logger.warn({ data }, 'Invalid mex newsletter notification content');
223
+ logger.warn({ data }, 'invalid mex newsletter notification content');
114
224
  return;
115
225
  }
116
226
  logger.info({ operation, updates }, 'got mex newsletter notification');
@@ -138,91 +248,114 @@ export const makeMessagesRecvSocket = (config) => {
138
248
  }
139
249
  }
140
250
  break;
251
+ case 'NotificationLinkedProfilesUpdates':
252
+ for (const update of updates) {
253
+ const lid = update?.jid;
254
+ const addedProfiles = Array.isArray(update?.added_profiles) ? update.added_profiles : [];
255
+ const mappings = [];
256
+ for (const profile of addedProfiles) {
257
+ const pn = typeof profile === 'string' ? profile : (profile?.pn ?? profile?.jid ?? null);
258
+ if (lid && pn) {
259
+ const mapping = { lid, pn };
260
+ ev.emit('lid-mapping.update', mapping);
261
+ mappings.push(mapping);
262
+ }
263
+ }
264
+ await signalRepository.lidMapping.storeLIDPNMappings(mappings);
265
+ }
266
+ break;
141
267
  default:
142
- logger.info({ operation, data }, 'Unhandled mex newsletter notification');
268
+ logger.info({ operation, data }, 'unhandled mex newsletter notification');
143
269
  break;
144
270
  }
145
271
  };
146
272
  // Handles newsletter notifications
147
273
  const handleNewsletterNotification = async (node) => {
148
274
  const from = node.attrs.from;
149
- const child = getAllBinaryNodeChildren(node)[0];
275
+ const children = getAllBinaryNodeChildren(node);
150
276
  const author = node.attrs.participant;
151
- logger.info({ from, child }, 'got newsletter notification');
152
- switch (child.tag) {
153
- case 'reaction':
154
- const reactionUpdate = {
155
- id: from,
156
- server_id: child.attrs.message_id,
157
- reaction: {
158
- code: getBinaryNodeChildString(child, 'reaction'),
159
- count: 1
160
- }
161
- };
162
- ev.emit('newsletter.reaction', reactionUpdate);
163
- break;
164
- case 'view':
165
- const viewUpdate = {
166
- id: from,
167
- server_id: child.attrs.message_id,
168
- count: parseInt(child.content?.toString() || '0', 10)
169
- };
170
- ev.emit('newsletter.view', viewUpdate);
171
- break;
172
- case 'participant':
173
- const participantUpdate = {
174
- id: from,
175
- author,
176
- user: child.attrs.jid,
177
- action: child.attrs.action,
178
- new_role: child.attrs.role
179
- };
180
- ev.emit('newsletter-participants.update', participantUpdate);
181
- break;
182
- case 'update':
183
- const settingsNode = getBinaryNodeChild(child, 'settings');
184
- if (settingsNode) {
185
- const update = {};
186
- const nameNode = getBinaryNodeChild(settingsNode, 'name');
187
- if (nameNode?.content)
188
- update.name = nameNode.content.toString();
189
- const descriptionNode = getBinaryNodeChild(settingsNode, 'description');
190
- if (descriptionNode?.content)
191
- update.description = descriptionNode.content.toString();
192
- ev.emit('newsletter-settings.update', {
277
+ for (const child of children) {
278
+ logger.debug({ from, child }, 'got newsletter notification');
279
+ switch (child.tag) {
280
+ case 'reaction': {
281
+ const reactionUpdate = {
193
282
  id: from,
194
- update
195
- });
283
+ server_id: child.attrs.message_id,
284
+ reaction: {
285
+ code: getBinaryNodeChildString(child, 'reaction'),
286
+ count: 1
287
+ }
288
+ };
289
+ ev.emit('newsletter.reaction', reactionUpdate);
290
+ break;
196
291
  }
197
- break;
198
- case 'message':
199
- const plaintextNode = getBinaryNodeChild(child, 'plaintext');
200
- if (plaintextNode?.content) {
201
- try {
202
- const contentBuf = typeof plaintextNode.content === 'string'
203
- ? Buffer.from(plaintextNode.content, 'binary')
204
- : Buffer.from(plaintextNode.content);
205
- const messageProto = proto.Message.decode(contentBuf).toJSON();
206
- const fullMessage = proto.WebMessageInfo.fromObject({
207
- key: {
208
- remoteJid: from,
209
- id: child.attrs.message_id || child.attrs.server_id,
210
- fromMe: false // TODO: is this really true though
211
- },
212
- message: messageProto,
213
- messageTimestamp: +child.attrs.t
214
- }).toJSON();
215
- await upsertMessage(fullMessage, 'append');
216
- logger.info('Processed plaintext newsletter message');
292
+ case 'view': {
293
+ const viewUpdate = {
294
+ id: from,
295
+ server_id: child.attrs.message_id,
296
+ count: parseInt(child.content?.toString() || '0', 10)
297
+ };
298
+ ev.emit('newsletter.view', viewUpdate);
299
+ break;
300
+ }
301
+ case 'participant': {
302
+ const participantUpdate = {
303
+ id: from,
304
+ author,
305
+ user: child.attrs.jid,
306
+ action: child.attrs.action,
307
+ new_role: child.attrs.role
308
+ };
309
+ ev.emit('newsletter-participants.update', participantUpdate);
310
+ break;
311
+ }
312
+ case 'update': {
313
+ const settingsNode = getBinaryNodeChild(child, 'settings');
314
+ if (settingsNode) {
315
+ const update = {};
316
+ const nameNode = getBinaryNodeChild(settingsNode, 'name');
317
+ if (nameNode?.content)
318
+ update.name = nameNode.content.toString();
319
+ const descriptionNode = getBinaryNodeChild(settingsNode, 'description');
320
+ if (descriptionNode?.content)
321
+ update.description = descriptionNode.content.toString();
322
+ ev.emit('newsletter-settings.update', {
323
+ id: from,
324
+ update
325
+ });
217
326
  }
218
- catch (error) {
219
- logger.error({ error }, 'Failed to decode plaintext newsletter message');
327
+ break;
328
+ }
329
+ case 'message': {
330
+ const plaintextNode = getBinaryNodeChild(child, 'plaintext');
331
+ if (plaintextNode?.content) {
332
+ try {
333
+ const contentBuf = typeof plaintextNode.content === 'string'
334
+ ? Buffer.from(plaintextNode.content, 'binary')
335
+ : Buffer.from(plaintextNode.content);
336
+ const messageProto = proto.Message.decode(contentBuf).toJSON();
337
+ const fullMessage = proto.WebMessageInfo.fromObject({
338
+ key: {
339
+ remoteJid: from,
340
+ id: child.attrs.message_id || child.attrs.server_id,
341
+ fromMe: false // TODO: is this really true though
342
+ },
343
+ message: messageProto,
344
+ messageTimestamp: +child.attrs.t
345
+ }).toJSON();
346
+ await upsertMessage(fullMessage, 'append');
347
+ logger.debug('Processed plaintext newsletter message');
348
+ }
349
+ catch (error) {
350
+ logger.error({ error }, 'Failed to decode plaintext newsletter message');
351
+ }
220
352
  }
353
+ break;
221
354
  }
222
- break;
223
- default:
224
- logger.warn({ node }, 'Unknown newsletter notification');
225
- break;
355
+ default:
356
+ logger.warn({ node, child }, 'Unknown newsletter notification child');
357
+ break;
358
+ }
226
359
  }
227
360
  };
228
361
  const sendMessageAck = async (node, errorCode) => {
@@ -251,97 +384,6 @@ export const makeMessagesRecvSocket = (config) => {
251
384
  };
252
385
  await query(stanza);
253
386
  };
254
- // Lia@Note 01-03-26 --- Source: https://github.com/koptereli/Baileys/commit/575cd41e6f01a9b3e1d7e2708c2292fa93de91f2
255
- const initiateCall = async (jid, options = {}) => {
256
- const meId = authState.creds.me?.id;
257
- if (!meId) {
258
- throw new Boom('Not authenticated');
259
- }
260
- const callId = randomBytes(8).toString('hex');
261
- const isVideo = !!options.isVideo;
262
- const isGroup = isJidGroup(jid);
263
- const stanza = {
264
- tag: 'call',
265
- attrs: {
266
- id: generateMessageTag(),
267
- from: meId,
268
- to: jid,
269
- t: String(unixTimestampSeconds()),
270
- ...(authState.creds.me?.name ? { notify: authState.creds.me.name } : {})
271
- },
272
- content: [
273
- {
274
- tag: 'offer',
275
- attrs: {
276
- 'call-id': callId,
277
- 'call-creator': meId,
278
- count: '0'
279
- },
280
- content: [
281
- {
282
- tag: isVideo ? 'video' : 'audio',
283
- attrs: {}
284
- },
285
- {
286
- tag: 'net',
287
- attrs: {}
288
- },
289
- {
290
- tag: 'encopt',
291
- attrs: { key: randomBytes(2).toString('hex') }
292
- },
293
- {
294
- tag: 'relaylatency',
295
- attrs: {}
296
- },
297
- {
298
- tag: 'te',
299
- attrs: {}
300
- }
301
- ]
302
- }
303
- ]
304
- };
305
- await query(stanza);
306
- await callOfferCache.set(callId, {
307
- chatId: jid,
308
- from: meId,
309
- id: callId,
310
- date: new Date(),
311
- offline: false,
312
- status: 'offer',
313
- isVideo,
314
- isGroup,
315
- groupJid: isGroup ? jid : undefined
316
- });
317
- // TODO: implement ICE/DTLS-SRTP call media setup once full signaling requirements are mapped.
318
- return { callId, to: jid, isVideo };
319
- };
320
- const cancelCall = async (callId, callTo) => {
321
- const meId = authState.creds.me?.id;
322
- if (!meId) {
323
- throw new Boom('Not authenticated');
324
- }
325
- const stanza = {
326
- tag: 'call',
327
- attrs: {
328
- from: meId,
329
- to: callTo
330
- },
331
- content: [
332
- {
333
- tag: 'terminate',
334
- attrs: {
335
- 'call-id': callId,
336
- 'call-creator': meId,
337
- count: '0'
338
- }
339
- }
340
- ]
341
- };
342
- await query(stanza);
343
- await callOfferCache.del(callId);
344
- };
345
387
  const sendRetryRequest = async (node, forceIncludeKeys = false) => {
346
388
  const { fullMessage } = decodeMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '');
347
389
  const { key: msgKey } = fullMessage;
@@ -473,15 +515,58 @@ export const makeMessagesRecvSocket = (config) => {
473
515
  logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt');
474
516
  }, authState?.creds?.me?.id || 'sendRetryRequest');
475
517
  };
518
+ // Mirrors WAWeb/Handle/PreKeyLow.js: skip a re-issued notification with the same stanza id.
519
+ const inFlightPreKeyLow = new Set();
520
+ /**
521
+ * Fire-and-forget tctoken re-issuance after a peer's device identity changed.
522
+ * Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in parallel with
523
+ * the session refresh (not after it).
524
+ */
525
+ const reissueTcTokenAfterIdentityChange = (from) => {
526
+ void (async () => {
527
+ const normalizedJid = jidNormalizedUser(from);
528
+ const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN);
529
+ const tcTokenData = await authState.keys.get('tctoken', [tcJid]);
530
+ const senderTs = tcTokenData?.[tcJid]?.senderTimestamp;
531
+ if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
532
+ return;
533
+ }
534
+ logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken');
535
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
536
+ const issueJid = await resolveIssuanceJid(normalizedJid, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID);
537
+ const result = await issuePrivacyTokens([issueJid], senderTs);
538
+ await storeTcTokensFromIqResult({
539
+ result,
540
+ fallbackJid: tcJid,
541
+ keys: authState.keys,
542
+ getLIDForPN,
543
+ onNewJidStored: trackTcTokenJid
544
+ });
545
+ })().catch(err => {
546
+ logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change');
547
+ });
548
+ };
476
549
  const handleEncryptNotification = async (node) => {
477
550
  const from = node.attrs.from;
478
551
  if (from === S_WHATSAPP_NET) {
552
+ const stanzaId = node.attrs.id;
553
+ if (stanzaId && inFlightPreKeyLow.has(stanzaId)) {
554
+ return;
555
+ }
479
556
  const countChild = getBinaryNodeChild(node, 'count');
480
557
  const count = +countChild.attrs.value;
481
558
  const shouldUploadMorePreKeys = count < MIN_PREKEY_COUNT;
482
559
  logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count');
483
560
  if (shouldUploadMorePreKeys) {
484
- await uploadPreKeys();
561
+ if (stanzaId)
562
+ inFlightPreKeyLow.add(stanzaId);
563
+ try {
564
+ await uploadPreKeys();
565
+ }
566
+ finally {
567
+ if (stanzaId)
568
+ inFlightPreKeyLow.delete(stanzaId);
569
+ }
485
570
  }
486
571
  }
487
572
  else {
@@ -491,7 +576,8 @@ export const makeMessagesRecvSocket = (config) => {
491
576
  validateSession: signalRepository.validateSession,
492
577
  assertSessions,
493
578
  debounceCache: identityAssertDebounce,
494
- logger
579
+ logger,
580
+ onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
495
581
  });
496
582
  if (result.action === 'no_identity_node') {
497
583
  logger.info({ node }, 'unknown encrypt notification');
@@ -502,6 +588,7 @@ export const makeMessagesRecvSocket = (config) => {
502
588
  // TODO: Support PN/LID (Here is only LID now)
503
589
  const actingParticipantLid = fullNode.attrs.participant;
504
590
  const actingParticipantPn = fullNode.attrs.participant_pn;
591
+ const actingParticipantUsername = fullNode.attrs.participant_username;
505
592
  const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid;
506
593
  const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn;
507
594
  switch (child?.tag) {
@@ -521,7 +608,8 @@ export const makeMessagesRecvSocket = (config) => {
521
608
  {
522
609
  ...metadata,
523
610
  author: actingParticipantLid,
524
- authorPn: actingParticipantPn
611
+ authorPn: actingParticipantPn,
612
+ authorUsername: actingParticipantUsername
525
613
  }
526
614
  ]);
527
615
  break;
@@ -552,6 +640,7 @@ export const makeMessagesRecvSocket = (config) => {
552
640
  id: attrs.jid,
553
641
  phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
554
642
  lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
643
+ username: attrs.participant_username || attrs.username || undefined,
555
644
  admin: (attrs.type || null)
556
645
  };
557
646
  });
@@ -624,58 +713,83 @@ export const makeMessagesRecvSocket = (config) => {
624
713
  const handleDevicesNotification = async (node) => {
625
714
  const [child] = getAllBinaryNodeChildren(node);
626
715
  const from = jidNormalizedUser(node.attrs.from);
716
+ if (!child) {
717
+ logger.debug({ from }, 'devices notification missing child, skipping');
718
+ return;
719
+ }
720
+ const tag = child.tag;
721
+ const deviceHash = child.attrs.device_hash;
627
722
  const devices = getBinaryNodeChildren(child, 'device');
628
- if (areJidsSameUser(from, authState.creds.me.id) ||
629
- areJidsSameUser(from, authState.creds.me.lid)) {
723
+ if (areJidsSameUser(from, authState.creds.me.id) || areJidsSameUser(from, authState.creds.me.lid)) {
630
724
  const deviceJids = devices.map(d => d.attrs.jid);
631
725
  logger.info({ deviceJids }, 'got my own devices');
632
726
  }
633
- if (!devices || !devices.length || !devices[0]) {
634
- logger.debug({ from }, 'no devices in notification, skipping');
727
+ if (!devices.length) {
728
+ logger.debug({ from, tag }, 'no devices in notification, skipping');
635
729
  return;
636
730
  }
637
- const deviceJid = devices[0].attrs.jid;
638
- const decoded = jidDecode(deviceJid);
639
- if (!decoded) return;
640
- const { user, device } = decoded;
641
- const tag = child.tag;
642
- if (!deviceJid) {
643
- logger.debug({ tag }, 'no device jid in notification, skipping');
644
- return;
731
+ const decoded = [];
732
+ for (const d of devices) {
733
+ const jid = d.attrs.jid;
734
+ if (!jid)
735
+ continue;
736
+ const parts = jidDecode(jid);
737
+ if (!parts) {
738
+ logger.debug({ jid }, 'failed to decode device jid, skipping');
739
+ continue;
740
+ }
741
+ decoded.push({ jid, user: parts.user, server: parts.server, device: parts.device });
645
742
  }
743
+ if (!decoded.length)
744
+ return;
646
745
  await devicesMutex.mutex(async () => {
647
- if (tag === 'update') {
648
- logger.debug({ user }, `${user}'s device list updated, dropping cached devices`);
649
- if (userDevicesCache) {
650
- await userDevicesCache.del(user);
651
- }
652
- return;
746
+ const byUser = new Map();
747
+ for (const d of decoded) {
748
+ const list = byUser.get(d.user) || [];
749
+ list.push(d);
750
+ byUser.set(d.user, list);
653
751
  }
654
- const existingCache = (await (userDevicesCache?.get(user))) || [];
655
- if (!existingCache.length) {
656
- logger.debug({ user, tag }, 'device list not cached, skipping cache update')
657
- return;
658
- }
659
- const deviceHash = child.attrs.device_hash;
660
- let updatedDevices = [];
661
- switch (tag) {
662
- case 'add':
663
- logger.info({ deviceHash }, 'device added');
664
- updatedDevices = [
665
- ...existingCache.filter(d => d.device !== device),
666
- { user, device }
667
- ];
668
- break;
669
- case 'remove':
670
- logger.info({ deviceHash }, 'device removed');
671
- updatedDevices = existingCache.filter(d => d.device !== device);
672
- break;
673
- default:
674
- logger.debug({ tag }, 'Unknown device list change tag');
675
- return;
676
- }
677
- if (updatedDevices.length > 0 && userDevicesCache) {
678
- await userDevicesCache.set(user, updatedDevices);
752
+ for (const [user, entries] of byUser) {
753
+ if (tag === 'update') {
754
+ logger.debug({ user }, `${user}'s device list updated, dropping cached devices`);
755
+ await userDevicesCache?.del(user);
756
+ continue;
757
+ }
758
+ if (tag === 'remove') {
759
+ await signalRepository.deleteSession(entries.map(e => e.jid));
760
+ }
761
+ const existingCache = (await userDevicesCache?.get(user)) || [];
762
+ if (!existingCache.length) {
763
+ // No baseline yet; skip applying the delta so getUSyncDevices can
764
+ // later fetch the full device list. Caching just the notification
765
+ // entries would make a partial list look authoritative.
766
+ logger.debug({ user, tag }, 'device list not cached, deferring to USync refresh');
767
+ continue;
768
+ }
769
+ const affected = new Set(entries.map(e => e.device));
770
+ let updatedDevices;
771
+ switch (tag) {
772
+ case 'add':
773
+ logger.info({ deviceHash, count: entries.length }, 'devices added');
774
+ updatedDevices = [
775
+ ...existingCache.filter(d => !affected.has(d.device)),
776
+ ...entries.map(e => ({ user: e.user, server: e.server, device: e.device }))
777
+ ];
778
+ break;
779
+ case 'remove':
780
+ logger.info({ deviceHash, count: entries.length }, 'devices removed');
781
+ updatedDevices = existingCache.filter(d => !affected.has(d.device));
782
+ break;
783
+ default:
784
+ logger.debug({ tag }, 'Unknown device list change tag');
785
+ continue;
786
+ }
787
+ if (updatedDevices.length === 0) {
788
+ await userDevicesCache?.del(user);
789
+ }
790
+ else {
791
+ await userDevicesCache?.set(user, updatedDevices);
792
+ }
679
793
  }
680
794
  });
681
795
  };
@@ -689,7 +803,7 @@ export const makeMessagesRecvSocket = (config) => {
689
803
  await handleNewsletterNotification(node);
690
804
  break;
691
805
  case 'mex':
692
- await handleMexNewsletterNotification(node);
806
+ await handleMexNotification(node);
693
807
  break;
694
808
  case 'w:gp2':
695
809
  // TODO: HANDLE PARTICIPANT_PN
@@ -707,7 +821,7 @@ export const makeMessagesRecvSocket = (config) => {
707
821
  await handleDevicesNotification(node);
708
822
  }
709
823
  catch (error) {
710
- logger.error({ error, node }, 'failed to handle devices notification');
824
+ logger.error({ error, node }, 'failed to handle devices notification');
711
825
  }
712
826
  break;
713
827
  case 'server_sync':
@@ -834,27 +948,70 @@ export const makeMessagesRecvSocket = (config) => {
834
948
  return result;
835
949
  }
836
950
  };
951
+ /**
952
+ * In-memory cache of storage JIDs with stored tctokens, seeded from the persisted index.
953
+ * Used to coalesce writes during a session; pruning always re-reads the persisted index
954
+ * to cover writes made by other layers (e.g. history sync).
955
+ */
956
+ const tcTokenKnownJids = new Set();
957
+ const tcTokenIndexLoaded = (async () => {
958
+ try {
959
+ const jids = await readTcTokenIndex(authState.keys);
960
+ for (const jid of jids)
961
+ tcTokenKnownJids.add(jid);
962
+ logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index');
963
+ }
964
+ catch (err) {
965
+ logger.warn({ err: err?.message }, 'failed to load tctoken index');
966
+ }
967
+ })();
968
+ let tcTokenIndexTimer;
969
+ async function flushTcTokenIndex() {
970
+ if (tcTokenIndexTimer) {
971
+ clearTimeout(tcTokenIndexTimer);
972
+ tcTokenIndexTimer = undefined;
973
+ }
974
+ // Merge with whatever is already persisted so we don't clobber writes from other
975
+ // paths (history sync, concurrent sessions on the same store).
976
+ const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids);
977
+ return authState.keys.set({ tctoken: write });
978
+ }
979
+ function scheduleTcTokenIndexSave() {
980
+ if (tcTokenIndexTimer) {
981
+ clearTimeout(tcTokenIndexTimer);
982
+ }
983
+ tcTokenIndexTimer = setTimeout(() => {
984
+ tcTokenIndexTimer = undefined;
985
+ flushTcTokenIndex().catch(err => {
986
+ logger.warn({ err: err?.message }, 'failed to save tctoken index');
987
+ });
988
+ }, 5000);
989
+ }
990
+ function trackTcTokenJid(jid) {
991
+ if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) {
992
+ tcTokenKnownJids.add(jid);
993
+ scheduleTcTokenIndexSave();
994
+ }
995
+ }
837
996
  const handlePrivacyTokenNotification = async (node) => {
838
997
  const tokensNode = getBinaryNodeChild(node, 'tokens');
839
- const from = jidNormalizedUser(node.attrs.from);
840
998
  if (!tokensNode)
841
999
  return;
842
- const tokenNodes = getBinaryNodeChildren(tokensNode, 'token');
843
- for (const tokenNode of tokenNodes) {
844
- const { attrs, content } = tokenNode;
845
- const type = attrs.type;
846
- const timestamp = attrs.t;
847
- if (type === 'trusted_contact' && content instanceof Buffer) {
848
- logger.debug({
849
- from,
850
- timestamp,
851
- tcToken: content
852
- }, 'received trusted contact token');
853
- await authState.keys.set({
854
- tctoken: { [from]: { token: content, timestamp } }
855
- });
856
- }
857
- }
1000
+ const from = jidNormalizedUser(node.attrs.from);
1001
+ // WA Web uses: senderLid ?? toLid(from) for the storage key
1002
+ // The sender_lid attribute provides the LID directly when available
1003
+ const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid))
1004
+ ? jidNormalizedUser(node.attrs.sender_lid)
1005
+ : undefined;
1006
+ const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN));
1007
+ logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification');
1008
+ await storeTcTokensFromIqResult({
1009
+ result: node,
1010
+ fallbackJid,
1011
+ keys: authState.keys,
1012
+ getLIDForPN,
1013
+ onNewJidStored: trackTcTokenJid
1014
+ });
858
1015
  };
859
1016
  async function decipherLinkPublicKey(data) {
860
1017
  const buffer = toRequiredBuffer(data);
@@ -880,10 +1037,11 @@ export const makeMessagesRecvSocket = (config) => {
880
1037
  const newValue = ((await msgRetryCache.get(key)) || 0) + 1;
881
1038
  await msgRetryCache.set(key, newValue);
882
1039
  };
883
- const sendMessagesAgain = async (key, ids, retryNode) => {
1040
+ const sendMessagesAgain = async (key, ids, retryNode, receiptNode) => {
884
1041
  const remoteJid = key.remoteJid;
885
1042
  const participant = key.participant || remoteJid;
886
1043
  const retryCount = +retryNode.attrs.count || 1;
1044
+ const msgId = ids[0];
887
1045
  // Try to get messages from cache first, then fallback to getMessage
888
1046
  const msgs = [];
889
1047
  for (const id of ids) {
@@ -915,12 +1073,49 @@ export const makeMessagesRecvSocket = (config) => {
915
1073
  // just re-send the message to everyone
916
1074
  // prevents the first message decryption failure
917
1075
  const sendToAll = !jidDecode(participant)?.device;
918
- // Check if we should recreate session for this retry
1076
+ const sessionId = signalRepository.jidToSignalProtocolAddress(participant);
1077
+ let injectedFromBundle = false;
1078
+ const bundle = extractE2ESessionFromRetryReceipt(receiptNode);
1079
+ if (bundle) {
1080
+ try {
1081
+ await signalRepository.injectE2ESession({ jid: participant, session: bundle });
1082
+ injectedFromBundle = true;
1083
+ logger.debug({ participant, retryCount }, 'injected session from retry receipt key bundle');
1084
+ }
1085
+ catch (error) {
1086
+ logger.warn({ error, participant }, 'failed to inject session from retry receipt');
1087
+ }
1088
+ }
1089
+ if (!injectedFromBundle) {
1090
+ const receivedRegId = getBinaryNodeChildUInt(receiptNode, 'registration', 4);
1091
+ if (typeof receivedRegId === 'number' && Number.isInteger(receivedRegId)) {
1092
+ const info = await signalRepository.getSessionInfo(participant);
1093
+ if (info && info.registrationId !== 0 && info.registrationId !== receivedRegId) {
1094
+ logger.info({ participant, stored: info.registrationId, received: receivedRegId }, 'reg id mismatch on retry without bundle, deleting session');
1095
+ await authState.keys.set({ session: { [sessionId]: null } });
1096
+ }
1097
+ }
1098
+ }
1099
+ const BASE_KEY_CHECK_RETRY = 2;
1100
+ if (msgId && messageRetryManager) {
1101
+ const info = await signalRepository.getSessionInfo(participant);
1102
+ if (info) {
1103
+ if (retryCount === BASE_KEY_CHECK_RETRY) {
1104
+ messageRetryManager.saveBaseKey(sessionId, msgId, info.baseKey);
1105
+ }
1106
+ else if (retryCount > BASE_KEY_CHECK_RETRY) {
1107
+ if (messageRetryManager.hasSameBaseKey(sessionId, msgId, info.baseKey)) {
1108
+ logger.warn({ participant, retryCount }, 'base key collision on retry, forcing fresh session');
1109
+ await authState.keys.set({ session: { [sessionId]: null } });
1110
+ }
1111
+ messageRetryManager.deleteBaseKey(sessionId, msgId);
1112
+ }
1113
+ }
1114
+ }
919
1115
  let shouldRecreateSession = false;
920
1116
  let recreateReason = '';
921
- if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
1117
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1 && !injectedFromBundle) {
922
1118
  try {
923
- const sessionId = signalRepository.jidToSignalProtocolAddress(participant);
924
1119
  const hasSession = await signalRepository.validateSession(participant);
925
1120
  const result = messageRetryManager.shouldRecreateSession(participant, hasSession.exists);
926
1121
  shouldRecreateSession = result.recreate;
@@ -934,11 +1129,13 @@ export const makeMessagesRecvSocket = (config) => {
934
1129
  logger.warn({ error, participant }, 'failed to check session recreation for outgoing retry');
935
1130
  }
936
1131
  }
937
- await assertSessions([participant], true);
1132
+ if (!injectedFromBundle) {
1133
+ await assertSessions([participant], true);
1134
+ }
938
1135
  if (isJidGroup(remoteJid)) {
939
1136
  await authState.keys.set({ 'sender-key-memory': { [remoteJid]: null } });
940
1137
  }
941
- logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason }, 'forced new session for retry recp');
1138
+ logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason, injectedFromBundle }, 'prepared session for retry resend');
942
1139
  for (const [i, msg] of msgs.entries()) {
943
1140
  if (!ids[i])
944
1141
  continue;
@@ -973,11 +1170,6 @@ export const makeMessagesRecvSocket = (config) => {
973
1170
  fromMe,
974
1171
  participant: attrs.participant
975
1172
  };
976
- if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) {
977
- logger.debug({ remoteJid }, 'ignoring receipt from jid');
978
- await sendMessageAck(node);
979
- return;
980
- }
981
1173
  const ids = [attrs.id];
982
1174
  if (Array.isArray(content)) {
983
1175
  const items = getBinaryNodeChildren(content[0], 'item');
@@ -1019,7 +1211,7 @@ export const makeMessagesRecvSocket = (config) => {
1019
1211
  try {
1020
1212
  await updateSendMessageAgainCount(ids[0], key.participant);
1021
1213
  logger.debug({ attrs, key }, 'recv retry request');
1022
- await sendMessagesAgain(key, ids, retryNode);
1214
+ await sendMessagesAgain(key, ids, retryNode, node);
1023
1215
  }
1024
1216
  catch (error) {
1025
1217
  logger.error({ key, ids, trace: error instanceof Error ? error.stack : 'Unknown error' }, 'error in sending message again');
@@ -1042,11 +1234,6 @@ export const makeMessagesRecvSocket = (config) => {
1042
1234
  };
1043
1235
  const handleNotification = async (node) => {
1044
1236
  const remoteJid = node.attrs.from;
1045
- if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) {
1046
- logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification');
1047
- await sendMessageAck(node);
1048
- return;
1049
- }
1050
1237
  try {
1051
1238
  await Promise.all([
1052
1239
  notificationMutex.mutex(async () => {
@@ -1059,6 +1246,7 @@ export const makeMessagesRecvSocket = (config) => {
1059
1246
  fromMe,
1060
1247
  participant: node.attrs.participant,
1061
1248
  participantAlt,
1249
+ participantUsername: node.attrs.participant_username,
1062
1250
  addressingMode,
1063
1251
  id: node.attrs.id,
1064
1252
  ...(msg.key || {})
@@ -1076,11 +1264,6 @@ export const makeMessagesRecvSocket = (config) => {
1076
1264
  }
1077
1265
  };
1078
1266
  const handleMessage = async (node) => {
1079
- if (shouldIgnoreJid(node.attrs.from) && node.attrs.from !== S_WHATSAPP_NET) {
1080
- logger.debug({ key: node.attrs.key }, 'ignored message');
1081
- await sendMessageAck(node, NACK_REASONS.UnhandledError);
1082
- return;
1083
- }
1084
1267
  const encNode = getBinaryNodeChild(node, 'enc');
1085
1268
  // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
1086
1269
  if (encNode?.attrs.type === 'msmsg') {
@@ -1184,29 +1367,14 @@ export const makeMessagesRecvSocket = (config) => {
1184
1367
  return sendMessageAck(node);
1185
1368
  }
1186
1369
  }
1187
- const errorMessage = msg?.messageStubParameters?.[0] || '';
1188
- const isPreKeyError = errorMessage.includes('PreKey');
1189
- logger.debug(`[handleMessage] Attempting retry request for failed decryption`);
1190
- // Handle both pre-key and normal retries in single mutex
1370
+ logger.debug('[handleMessage] Attempting retry request for failed decryption');
1371
+ // WAWeb only retry-receipts here; server emits PreKeyLow if prekeys run low.
1191
1372
  await retryMutex.mutex(async () => {
1192
1373
  try {
1193
1374
  if (!ws.isOpen) {
1194
1375
  logger.debug({ node }, 'Connection closed, skipping retry');
1195
1376
  return;
1196
1377
  }
1197
- // Handle pre-key errors with upload and delay
1198
- if (isPreKeyError) {
1199
- logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying');
1200
- try {
1201
- logger.debug('Uploading pre-keys for error recovery');
1202
- await uploadPreKeys(5);
1203
- logger.debug('Waiting for server to process new pre-keys');
1204
- await delay(1000);
1205
- }
1206
- catch (uploadErr) {
1207
- logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway');
1208
- }
1209
- }
1210
1378
  const encNode = getBinaryNodeChild(node, 'enc');
1211
1379
  await sendRetryRequest(node, !encNode);
1212
1380
  if (retryRequestDelayMs) {
@@ -1214,15 +1382,7 @@ export const makeMessagesRecvSocket = (config) => {
1214
1382
  }
1215
1383
  }
1216
1384
  catch (err) {
1217
- logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry');
1218
- // Still attempt retry even if pre-key upload failed
1219
- try {
1220
- const encNode = getBinaryNodeChild(node, 'enc');
1221
- await sendRetryRequest(node, !encNode);
1222
- }
1223
- catch (retryErr) {
1224
- logger.error({ retryErr }, 'Failed to send retry after error handling');
1225
- }
1385
+ logger.error({ err }, 'Failed to send retry');
1226
1386
  }
1227
1387
  acked = true;
1228
1388
  await sendMessageAck(node, NACK_REASONS.UnhandledError);
@@ -1298,6 +1458,13 @@ export const makeMessagesRecvSocket = (config) => {
1298
1458
  offline: !!attrs.offline,
1299
1459
  status
1300
1460
  };
1461
+ if (status === 'relaylatency') {
1462
+ const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'];
1463
+ const latencyMs = latencyValue ? Number(latencyValue) : undefined;
1464
+ if (Number.isFinite(latencyMs)) {
1465
+ call.latencyMs = latencyMs;
1466
+ }
1467
+ }
1301
1468
  if (status === 'offer') {
1302
1469
  call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1303
1470
  call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
@@ -1342,29 +1509,61 @@ export const makeMessagesRecvSocket = (config) => {
1342
1509
  // error in acknowledgement,
1343
1510
  // device could not display the message
1344
1511
  if (attrs.error) {
1345
- logger.warn({ attrs }, 'received error in ack');
1512
+ const isReachoutTimelocked = attrs.error === String(NACK_REASONS.SenderReachoutTimelocked);
1513
+ if (attrs.error === SERVER_ERROR_CODES.MessageAccountRestriction) {
1514
+ // 463 = 1:1 message missing privacy token (tctoken). Usually means the
1515
+ // account is restricted: WhatsApp blocks starting new chats but preserves
1516
+ // existing ones, since established chats already carry a tctoken.
1517
+ // WA Web prevents this client-side (disables the compose bar).
1518
+ // No retry — retrying counts as another "reach out" and worsens the restriction.
1519
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted or missing tctoken for contact');
1520
+ const ackFrom = attrs.from;
1521
+ if (ackFrom && !inFlight463Recoveries.has(ackFrom)) {
1522
+ inFlight463Recoveries.add(ackFrom);
1523
+ void (async () => {
1524
+ try {
1525
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
1526
+ const tcStorageJid = await resolveTcTokenJid(ackFrom, getLIDForPN);
1527
+ const issueJid = await resolveIssuanceJid(ackFrom, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID);
1528
+ const result = await issuePrivacyTokens([issueJid], unixTimestampSeconds());
1529
+ await storeTcTokensFromIqResult({
1530
+ result,
1531
+ fallbackJid: tcStorageJid,
1532
+ keys: authState.keys,
1533
+ getLIDForPN,
1534
+ onNewJidStored: trackTcTokenJid
1535
+ });
1536
+ logger.debug({ from: ackFrom }, 'completed 463 token recovery issuance');
1537
+ }
1538
+ catch (err) {
1539
+ logger.debug({ from: ackFrom, err: err?.message }, 'failed 463 token recovery issuance');
1540
+ }
1541
+ finally {
1542
+ inFlight463Recoveries.delete(ackFrom);
1543
+ }
1544
+ })();
1545
+ }
1546
+ }
1547
+ else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
1548
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'smax-invalid (479): stanza rejected by server — likely stale device session or malformed addressing');
1549
+ }
1550
+ else if (isReachoutTimelocked) {
1551
+ // user is temporarily restricted, fetch current restriction details
1552
+ await fetchAccountReachoutTimelock().catch(err => logger.warn({ err }, 'failed to fetch reachout timelock'));
1553
+ logger.warn({ attrs }, 'received error in ack');
1554
+ }
1555
+ else {
1556
+ logger.warn({ attrs }, 'received error in ack');
1557
+ }
1346
1558
  ev.emit('messages.update', [
1347
1559
  {
1348
1560
  key,
1349
1561
  update: {
1350
1562
  status: WAMessageStatus.ERROR,
1351
- messageStubParameters: [attrs.error]
1563
+ messageStubParameters: isReachoutTimelocked ? [attrs.error, ACCOUNT_RESTRICTED_TEXT] : [attrs.error]
1352
1564
  }
1353
1565
  }
1354
1566
  ]);
1355
- // resend the message with device_fanout=false, use at your own risk
1356
- // if (attrs.error === '475') {
1357
- // const msg = await getMessage(key)
1358
- // if (msg) {
1359
- // await relayMessage(key.remoteJid!, msg, {
1360
- // messageId: key.id!,
1361
- // useUserDevicesCache: false,
1362
- // additionalAttributes: {
1363
- // device_fanout: 'false'
1364
- // }
1365
- // })
1366
- // }
1367
- // }
1368
1567
  }
1369
1568
  };
1370
1569
  /// processes a node with the given function
@@ -1388,6 +1587,19 @@ export const makeMessagesRecvSocket = (config) => {
1388
1587
  yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
1389
1588
  });
1390
1589
  const processNode = async (type, node, identifier, exec) => {
1590
+ // Fast path: ack and drop ignored JIDs before entering the buffer/queue
1591
+ const from = node.attrs.from;
1592
+ let ignoreJid = from;
1593
+ if (type === 'receipt' && from) {
1594
+ const attrs = node.attrs;
1595
+ const isLid = attrs.from.includes('lid');
1596
+ const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, isLid ? authState.creds.me?.lid : authState.creds.me?.id);
1597
+ ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient;
1598
+ }
1599
+ if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) {
1600
+ await sendMessageAck(node, type === 'message' ? NACK_REASONS.UnhandledError : undefined);
1601
+ return;
1602
+ }
1391
1603
  const isOffline = !!node.attrs.offline;
1392
1604
  if (isOffline) {
1393
1605
  offlineNodeProcessor.enqueue(type, node);
@@ -1443,11 +1655,38 @@ export const makeMessagesRecvSocket = (config) => {
1443
1655
  await upsertMessage(protoMsg, call.offline ? 'append' : 'notify');
1444
1656
  }
1445
1657
  });
1446
- ev.on('connection.update', ({ isOnline }) => {
1658
+ /** timestamp of last tctoken prune run — throttles to once per 24h */
1659
+ let lastTcTokenPruneTs = 0;
1660
+ /** dedupe in-flight 463 recovery token issuance by target JID */
1661
+ const inFlight463Recoveries = new Set();
1662
+ ev.on('connection.update', ({ isOnline, connection }) => {
1447
1663
  if (typeof isOnline !== 'undefined') {
1448
1664
  sendActiveReceipts = isOnline;
1449
1665
  logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`);
1450
1666
  }
1667
+ // Flush pending tctoken index save on disconnect to avoid writing after close
1668
+ if (connection === 'close' && tcTokenIndexTimer) {
1669
+ clearTimeout(tcTokenIndexTimer);
1670
+ tcTokenIndexTimer = undefined;
1671
+ // Best-effort flush — may fail if store is already closed
1672
+ try {
1673
+ void Promise.resolve(flushTcTokenIndex()).catch(() => { });
1674
+ }
1675
+ catch {
1676
+ /* ignore sync errors */
1677
+ }
1678
+ }
1679
+ // Prune expired tctokens when coming online, at most once per 24 hours
1680
+ // Matches WA Web's CLEAN_TC_TOKENS task
1681
+ // Note: don't gate on tcTokenKnownJids.size — the index may still be loading
1682
+ if (isOnline) {
1683
+ const now = Date.now();
1684
+ const DAY_MS = 24 * 60 * 60 * 1000;
1685
+ if (now - lastTcTokenPruneTs >= DAY_MS) {
1686
+ lastTcTokenPruneTs = now;
1687
+ void pruneExpiredTcTokens();
1688
+ }
1689
+ }
1451
1690
  });
1452
1691
  registerSocketEndHandler(() => {
1453
1692
  if (!config.msgRetryCounterCache && msgRetryCache.close) {
@@ -1456,21 +1695,78 @@ export const makeMessagesRecvSocket = (config) => {
1456
1695
  if (!config.callOfferCache && callOfferCache.close) {
1457
1696
  callOfferCache.close();
1458
1697
  }
1459
- if (!config.placeholderResendCache && placeholderResendCache.close) {
1460
- placeholderResendCache.close();
1461
- }
1462
1698
  identityAssertDebounce.close();
1463
1699
  sendActiveReceipts = false;
1464
1700
  });
1701
+ async function pruneExpiredTcTokens() {
1702
+ try {
1703
+ await tcTokenIndexLoaded;
1704
+ // Union with the persisted index picks up JIDs added by other layers
1705
+ // (history sync) without needing inter-module wiring.
1706
+ const persisted = await readTcTokenIndex(authState.keys);
1707
+ const allJids = new Set(tcTokenKnownJids);
1708
+ for (const jid of persisted)
1709
+ allJids.add(jid);
1710
+ if (!allJids.size)
1711
+ return;
1712
+ const jids = [...allJids];
1713
+ const allTokens = await authState.keys.get('tctoken', jids);
1714
+ const writes = {};
1715
+ const survivors = new Set();
1716
+ let mutated = 0;
1717
+ for (const jid of jids) {
1718
+ const entry = allTokens[jid];
1719
+ if (!entry) {
1720
+ // Tracked but nothing in store — drop from index.
1721
+ mutated++;
1722
+ continue;
1723
+ }
1724
+ const hasPeerToken = !!entry.token?.length;
1725
+ const peerTokenExpired = hasPeerToken && isTcTokenExpired(entry.timestamp);
1726
+ const hasSenderTs = entry.senderTimestamp !== undefined;
1727
+ const senderTsExpired = hasSenderTs && isTcTokenExpired(entry.senderTimestamp);
1728
+ const keepPeerToken = hasPeerToken && !peerTokenExpired;
1729
+ const keepSenderTs = hasSenderTs && !senderTsExpired;
1730
+ if (!keepPeerToken && !keepSenderTs) {
1731
+ writes[jid] = null;
1732
+ mutated++;
1733
+ }
1734
+ else if (peerTokenExpired && keepSenderTs) {
1735
+ writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp };
1736
+ survivors.add(jid);
1737
+ mutated++;
1738
+ }
1739
+ else {
1740
+ survivors.add(jid);
1741
+ }
1742
+ }
1743
+ if (mutated === 0)
1744
+ return;
1745
+ await authState.keys.set({
1746
+ tctoken: {
1747
+ ...writes,
1748
+ [TC_TOKEN_INDEX_KEY]: {
1749
+ token: Buffer.from(JSON.stringify([...survivors]))
1750
+ }
1751
+ }
1752
+ });
1753
+ tcTokenKnownJids.clear();
1754
+ for (const jid of survivors)
1755
+ tcTokenKnownJids.add(jid);
1756
+ logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens');
1757
+ }
1758
+ catch (err) {
1759
+ logger.warn({ err: err?.message }, 'failed to prune expired tctokens');
1760
+ }
1761
+ }
1465
1762
  return {
1466
1763
  ...sock,
1467
1764
  sendMessageAck,
1468
1765
  sendRetryRequest,
1469
1766
  rejectCall,
1470
- initiateCall,
1471
- cancelCall,
1472
1767
  fetchMessageHistory,
1473
1768
  requestPlaceholderResend,
1474
1769
  messageRetryManager
1475
1770
  };
1476
- };
1771
+ };
1772
+ //# sourceMappingURL=messages-recv.js.map