@nexustechpro/baileys 2.0.5 → 2.0.6

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,17 +4,18 @@ import { randomBytes } from "crypto"
4
4
  import { proto } from "../../WAProto/index.js"
5
5
  import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from "../Defaults/index.js"
6
6
  import { WAMessageStatus, WAMessageStubType } from "../Types/index.js"
7
- import { aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, handleIdentityChange, getStatusFromReceiptType, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey } from "../Utils/index.js"
7
+ import { 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, ACCOUNT_RESTRICTED_TEXT, buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resolveTcTokenJid, resolveIssuanceJid, storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY } from "../Utils/index.js"
8
8
  import { makeMutex } from "../Utils/make-mutex.js"
9
- import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET, jidEncode } from "../WABinary/index.js"
9
+ import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET, jidEncode } from "../WABinary/index.js"
10
10
  import { extractGroupMetadata } from "./groups.js"
11
11
  import { makeMessagesSocket } from "./messages-send.js"
12
12
 
13
13
  export const makeMessagesRecvSocket = (config) => {
14
14
  const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config
15
15
  const sock = makeMessagesSocket(config)
16
- const { ev, authState, ws, processingMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, triggerPreKeyCheck } = sock
16
+ const { ev, authState, ws, processingMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, registerSocketEndHandler, issuePrivacyTokens, fetchAccountReachoutTimelock } = sock
17
17
 
18
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
18
19
  const messageMutex = makeMutex()
19
20
  const notificationMutex = makeMutex()
20
21
  const retryMutex = makeMutex()
@@ -109,18 +110,23 @@ export const makeMessagesRecvSocket = (config) => {
109
110
  }
110
111
  }
111
112
 
112
- const sendMessageAck = async ({ tag, attrs, content }, errorCode) => {
113
- const stanza = { tag: "ack", attrs: { id: attrs.id, to: attrs.from, class: tag } }
114
- if (!!errorCode) stanza.attrs.error = errorCode.toString()
115
- if (!!attrs.participant) stanza.attrs.participant = attrs.participant
116
- if (!!attrs.recipient) stanza.attrs.recipient = attrs.recipient
117
- if (!!attrs.type && (tag !== "message" || getBinaryNodeChild({ tag, attrs, content }, "unavailable") || errorCode !== 0)) stanza.attrs.type = attrs.type
118
- if (tag === "message" && getBinaryNodeChild({ tag, attrs, content }, "unavailable")) stanza.attrs.from = authState.creds.me.id
119
- logger.debug({ recv: { tag, attrs }, sent: stanza.attrs }, "sent ack")
113
+ const sendMessageAck = async (node, errorCode) => {
114
+ const { tag, attrs, content } = node
115
+ const hasUnavailable = !!getBinaryNodeChild({ tag, attrs, content }, 'unavailable')
116
+ const stanza = { tag: 'ack', attrs: { id: attrs.id, to: attrs.from, class: tag } }
117
+ if (errorCode) stanza.attrs.error = errorCode.toString()
118
+ if (attrs.participant) stanza.attrs.participant = attrs.participant
119
+ if (attrs.recipient) stanza.attrs.recipient = attrs.recipient
120
+ // include type always when present (upstream), but also force-include on unavailable/error
121
+ if (attrs.type || hasUnavailable || errorCode) stanza.attrs.type = attrs.type
122
+ // include from for all message-class ACKs (upstream), not just unavailable
123
+ if (tag === 'message' && authState.creds.me?.id) stanza.attrs.from = authState.creds.me.id
124
+ logger.debug({ recv: { tag, attrs }, sent: stanza.attrs }, 'sent ack')
120
125
  try {
121
126
  await sendNode(stanza)
122
127
  } catch (error) {
123
- if (error?.output?.statusCode === 428 || error?.message?.includes("Connection")) logger.warn({ id: attrs.id, error: error?.message }, "Failed to send ACK (connection closed) - message already received")
128
+ if (error?.output?.statusCode === 428 || error?.message?.includes('Connection'))
129
+ logger.warn({ id: attrs.id, error: error?.message }, 'Failed to send ACK (connection closed) - message already received')
124
130
  else throw error
125
131
  }
126
132
  }
@@ -263,20 +269,42 @@ export const makeMessagesRecvSocket = (config) => {
263
269
  }, authState?.creds?.me?.id || "sendRetryRequest")
264
270
  }
265
271
 
272
+ const reissueTcTokenAfterIdentityChange = (from) => {
273
+ void (async () => {
274
+ const normalizedJid = jidNormalizedUser(from)
275
+ const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
276
+ const tcTokenData = await authState.keys.get('tctoken', [tcJid])
277
+ const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
278
+ if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) return
279
+ logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken')
280
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
281
+ const issueJid = await resolveIssuanceJid(normalizedJid, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID)
282
+ const result = await issuePrivacyTokens([issueJid], senderTs)
283
+ await storeTcTokensFromIqResult({ result, fallbackJid: tcJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
284
+ })().catch(err => logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change'))
285
+ }
286
+
266
287
  const handleEncryptNotification = async (node) => {
267
288
  const from = node.attrs.from
268
289
  if (from === S_WHATSAPP_NET) {
290
+ const stanzaId = node.attrs.id
291
+ if (stanzaId && inFlightPreKeyLow.has(stanzaId)) return
269
292
  const countChild = getBinaryNodeChild(node, "count")
270
293
  const count = +countChild.attrs.value
271
294
  const shouldUploadMorePreKeys = count < MIN_PREKEY_COUNT
272
295
  logger.debug({ count, shouldUploadMorePreKeys }, "recv pre-key count")
273
- if (shouldUploadMorePreKeys) await uploadPreKeys()
296
+ if (shouldUploadMorePreKeys) {
297
+ if (stanzaId) inFlightPreKeyLow.add(stanzaId)
298
+ try { await uploadPreKeys() } finally { if (stanzaId) inFlightPreKeyLow.delete(stanzaId) }
299
+ }
274
300
  } else {
275
- const identityNode = getBinaryNodeChild(node, 'identity')
276
- if (identityNode) { logger.info({ jid: from }, 'identity changed') } else { logger.info({ node }, 'unknown encrypt notification') }
301
+ const result = await handleIdentityChange(node, { meId: authState.creds.me?.id, meLid: authState.creds.me?.lid, validateSession: signalRepository.validateSession, assertSessions, debounceCache: identityAssertDebounce, logger, onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange })
302
+ if (result.action === 'no_identity_node') logger.info({ node }, 'unknown encrypt notification')
277
303
  }
278
304
  }
279
305
 
306
+ const inFlightPreKeyLow = new Set()
307
+
280
308
  const handleGroupNotification = (fullNode, child, msg) => {
281
309
  const actingParticipantLid = fullNode.attrs.participant
282
310
  const actingParticipantPn = fullNode.attrs.participant_pn
@@ -356,19 +384,57 @@ export const makeMessagesRecvSocket = (config) => {
356
384
 
357
385
  const handlePrivacyTokenNotification = async (node) => {
358
386
  const tokensNode = getBinaryNodeChild(node, "tokens")
359
- const from = jidNormalizedUser(node.attrs.from)
360
387
  if (!tokensNode) return
361
- const tokenNodes = getBinaryNodeChildren(tokensNode, "token")
362
- for (const tokenNode of tokenNodes) {
363
- const { attrs, content } = tokenNode
364
- const type = attrs.type
365
- const timestamp = attrs.t
366
- if (type === "trusted_contact" && content instanceof Buffer) {
367
- logger.debug({ from, timestamp, tcToken: content }, "received trusted contact token")
368
- await authState.keys.set({ tctoken: { [from]: { token: content, timestamp } } })
369
- ev.emit("chats.update", [{ id: from, tcToken: content }])
370
- }
388
+ const from = jidNormalizedUser(node.attrs.from)
389
+ const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid)) ? jidNormalizedUser(node.attrs.sender_lid) : undefined
390
+ const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN))
391
+ logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification')
392
+ await storeTcTokensFromIqResult({ result: node, fallbackJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
393
+ }
394
+
395
+ const handleDevicesNotification = async (node) => {
396
+ const [child] = getAllBinaryNodeChildren(node)
397
+ const from = jidNormalizedUser(node.attrs.from)
398
+ if (!child) { logger.debug({ from }, 'devices notification missing child, skipping'); return }
399
+ const tag = child.tag
400
+ const deviceHash = child.attrs.device_hash
401
+ const devices = getBinaryNodeChildren(child, 'device')
402
+ if (areJidsSameUser(from, authState.creds.me.id) || areJidsSameUser(from, authState.creds.me.lid)) { const deviceJids = devices.map(d => d.attrs.jid); logger.info({ deviceJids }, 'got my own devices') }
403
+ if (!devices.length) { logger.debug({ from, tag }, 'no devices in notification, skipping'); return }
404
+ const decoded = []
405
+ for (const d of devices) {
406
+ const jid = d.attrs.jid
407
+ if (!jid) continue
408
+ const parts = jidDecode(jid)
409
+ if (!parts) { logger.debug({ jid }, 'failed to decode device jid, skipping'); continue }
410
+ decoded.push({ jid, user: parts.user, server: parts.server, device: parts.device })
371
411
  }
412
+ if (!decoded.length) return
413
+ await sock.devicesMutex.mutex(async () => {
414
+ const byUser = new Map()
415
+ for (const d of decoded) { const list = byUser.get(d.user) || []; list.push(d); byUser.set(d.user, list) }
416
+ for (const [user, entries] of byUser) {
417
+ if (tag === 'update') { logger.debug({ user }, `${user}'s device list updated, dropping cached devices`); await sock.userDevicesCache?.del(user); continue }
418
+ if (tag === 'remove') await signalRepository.deleteSession(entries.map(e => e.jid))
419
+ const existingCache = (await sock.userDevicesCache?.get(user)) || []
420
+ if (!existingCache.length) { logger.debug({ user, tag }, 'device list not cached, deferring to USync refresh'); continue }
421
+ const affected = new Set(entries.map(e => e.device))
422
+ let updatedDevices
423
+ switch (tag) {
424
+ case 'add':
425
+ logger.info({ deviceHash, count: entries.length }, 'devices added')
426
+ updatedDevices = [...existingCache.filter(d => !affected.has(d.device)), ...entries.map(e => ({ user: e.user, server: e.server, device: e.device }))]
427
+ break
428
+ case 'remove':
429
+ logger.info({ deviceHash, count: entries.length }, 'devices removed')
430
+ updatedDevices = existingCache.filter(d => !affected.has(d.device))
431
+ break
432
+ default: logger.debug({ tag }, 'unknown device list change tag'); continue
433
+ }
434
+ if (updatedDevices.length === 0) await sock.userDevicesCache?.del(user)
435
+ else await sock.userDevicesCache?.set(user, updatedDevices)
436
+ }
437
+ })
372
438
  }
373
439
 
374
440
  const processNotification = async (node) => {
@@ -397,11 +463,7 @@ export const makeMessagesRecvSocket = (config) => {
397
463
  await handleEncryptNotification(node)
398
464
  break
399
465
  case "devices":
400
- const devices = getBinaryNodeChildren(child, "device")
401
- if (areJidsSameUser(child.attrs.jid, authState.creds.me.id) || areJidsSameUser(child.attrs.lid, authState.creds.me.lid)) {
402
- const deviceData = devices.map((d) => ({ id: d.attrs.jid, lid: d.attrs.lid }))
403
- logger.info({ deviceData }, "my own devices changed")
404
- }
466
+ try { await handleDevicesNotification(node) } catch (error) { logger.error({ error, node }, 'failed to handle devices notification') }
405
467
  break
406
468
  case "server_sync":
407
469
  const update = getBinaryNodeChild(node, "collection")
@@ -462,6 +524,28 @@ export const makeMessagesRecvSocket = (config) => {
462
524
  if (Object.keys(result).length) return result
463
525
  }
464
526
 
527
+ const tcTokenKnownJids = new Set()
528
+ const tcTokenIndexLoaded = (async () => {
529
+ try { const jids = await readTcTokenIndex(authState.keys); for (const jid of jids) tcTokenKnownJids.add(jid); logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index') }
530
+ catch (err) { logger.warn({ err: err?.message }, 'failed to load tctoken index') }
531
+ })()
532
+
533
+ let tcTokenIndexTimer
534
+ async function flushTcTokenIndex() {
535
+ if (tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); tcTokenIndexTimer = undefined }
536
+ const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
537
+ return authState.keys.set({ tctoken: write })
538
+ }
539
+
540
+ function scheduleTcTokenIndexSave() {
541
+ if (tcTokenIndexTimer) clearTimeout(tcTokenIndexTimer)
542
+ tcTokenIndexTimer = setTimeout(() => { tcTokenIndexTimer = undefined; flushTcTokenIndex().catch(err => logger.warn({ err: err?.message }, 'failed to save tctoken index')) }, 5000)
543
+ }
544
+
545
+ function trackTcTokenJid(jid) {
546
+ if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) { tcTokenKnownJids.add(jid); scheduleTcTokenIndexSave() }
547
+ }
548
+
465
549
  async function decipherLinkPublicKey(data) {
466
550
  const buffer = toRequiredBuffer(data)
467
551
  const salt = buffer.slice(0, 32)
@@ -488,10 +572,11 @@ export const makeMessagesRecvSocket = (config) => {
488
572
  await msgRetryCache.set(key, newValue)
489
573
  }
490
574
 
491
- const sendMessagesAgain = async (key, ids, retryNode) => {
575
+ const sendMessagesAgain = async (key, ids, retryNode, receiptNode) => {
492
576
  const remoteJid = key.remoteJid
493
577
  const participant = key.participant || remoteJid
494
578
  const retryCount = +retryNode.attrs.count || 1
579
+ const msgId = ids[0]
495
580
  const msgs = []
496
581
  for (const id of ids) {
497
582
  let msg
@@ -506,11 +591,35 @@ export const makeMessagesRecvSocket = (config) => {
506
591
  msgs.push(msg)
507
592
  }
508
593
  const sendToAll = !jidDecode(participant)?.device
594
+ const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
595
+ let injectedFromBundle = false
596
+ const bundle = extractE2ESessionFromRetryReceipt(receiptNode)
597
+ if (bundle) {
598
+ try { await signalRepository.injectE2ESession({ jid: participant, session: bundle }); injectedFromBundle = true; logger.debug({ participant, retryCount }, 'injected session from retry receipt key bundle') }
599
+ catch (error) { logger.warn({ error, participant }, 'failed to inject session from retry receipt') }
600
+ }
601
+ if (!injectedFromBundle && typeof signalRepository.getSessionInfo === 'function') {
602
+ const receivedRegId = getBinaryNodeChildUInt(receiptNode, 'registration', 4)
603
+ if (typeof receivedRegId === 'number' && Number.isInteger(receivedRegId)) {
604
+ const info = await signalRepository.getSessionInfo(participant)
605
+ if (info && info.registrationId !== 0 && info.registrationId !== receivedRegId) { logger.info({ participant, stored: info.registrationId, received: receivedRegId }, 'reg id mismatch on retry without bundle, deleting session'); await authState.keys.set({ session: { [sessionId]: null } }) }
606
+ }
607
+ }
608
+ const BASE_KEY_CHECK_RETRY = 2
609
+ if (msgId && messageRetryManager && typeof messageRetryManager.saveBaseKey === 'function') {
610
+ const info = typeof signalRepository.getSessionInfo === 'function' ? await signalRepository.getSessionInfo(participant) : undefined
611
+ if (info) {
612
+ if (retryCount === BASE_KEY_CHECK_RETRY) messageRetryManager.saveBaseKey(sessionId, msgId, info.baseKey)
613
+ else if (retryCount > BASE_KEY_CHECK_RETRY) {
614
+ if (messageRetryManager.hasSameBaseKey(sessionId, msgId, info.baseKey)) { logger.warn({ participant, retryCount }, 'base key collision on retry, forcing fresh session'); await authState.keys.set({ session: { [sessionId]: null } }) }
615
+ messageRetryManager.deleteBaseKey(sessionId, msgId)
616
+ }
617
+ }
618
+ }
509
619
  let shouldRecreateSession = false
510
620
  let recreateReason = ""
511
- if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
621
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1 && !injectedFromBundle) {
512
622
  try {
513
- const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
514
623
  const hasSession = await signalRepository.validateSession(participant)
515
624
  const result = messageRetryManager.shouldRecreateSession(participant, retryCount, hasSession.exists)
516
625
  shouldRecreateSession = result.recreate
@@ -518,9 +627,9 @@ export const makeMessagesRecvSocket = (config) => {
518
627
  if (shouldRecreateSession) { logger.debug({ participant, retryCount, reason: recreateReason }, "recreating session for outgoing retry"); await authState.keys.set({ session: { [sessionId]: null } }) }
519
628
  } catch (error) { logger.warn({ error, participant }, "failed to check session recreation for outgoing retry") }
520
629
  }
521
- await assertSessions([participant], false);
630
+ if (!injectedFromBundle) await assertSessions([participant], true)
522
631
  if (isJidGroup(remoteJid)) await authState.keys.set({ "sender-key-memory": { [remoteJid]: null } })
523
- logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason }, "preparing retry recp")
632
+ logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason, injectedFromBundle }, "preparing retry recp")
524
633
  for (const [i, msg] of msgs.entries()) {
525
634
  if (!ids[i]) continue
526
635
  if (msg && (await willSendMessageAgain(ids[i], participant))) {
@@ -565,7 +674,7 @@ export const makeMessagesRecvSocket = (config) => {
565
674
  try {
566
675
  await updateSendMessageAgainCount(ids[0], key.participant)
567
676
  logger.debug({ attrs, key }, "recv retry request")
568
- await sendMessagesAgain(key, ids, retryNode)
677
+ await sendMessagesAgain(key, ids, retryNode, node)
569
678
  } catch (error) { logger.error({ key, ids, trace: error instanceof Error ? error.stack : "Unknown error" }, "error in sending message again") }
570
679
  } else logger.info({ attrs, key }, "recv retry for not fromMe message")
571
680
  } else {
@@ -681,7 +790,14 @@ export const makeMessagesRecvSocket = (config) => {
681
790
  cleanMessage(msg, authState.creds.me.id, authState.creds.me.lid)
682
791
  await upsertMessage(msg, node.attrs.offline ? "append" : "notify")
683
792
  })
684
- } catch (error) { logger.error({ error, stack: error?.stack, msg: error?.message || String(error), node: binaryNodeToString(node) }, "error in handling message") }
793
+ } catch (error) {
794
+ const isClosed = error?.message?.includes('Connection Closed') || error?.output?.statusCode === 428
795
+ if (isClosed) {
796
+ logger.debug({ msg: error?.message }, "Connection closed while handling message")
797
+ } else {
798
+ logger.error({ error, stack: error?.stack, msg: error?.message || String(error), node: binaryNodeToString(node) }, "error in handling message")
799
+ }
800
+ }
685
801
  }
686
802
 
687
803
  const handleCall = async (node) => {
@@ -700,11 +816,38 @@ export const makeMessagesRecvSocket = (config) => {
700
816
  await sendMessageAck(node)
701
817
  }
702
818
 
819
+ const inFlight463Recoveries = new Set()
820
+
703
821
  const handleBadAck = async ({ attrs }) => {
704
822
  const key = { remoteJid: attrs.from, fromMe: true, id: attrs.id }
705
823
  if (attrs.error) {
706
- logger.warn({ attrs }, "received error in ack")
707
- ev.emit("messages.update", [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: [attrs.error] } }])
824
+ const isReachoutTimelocked = attrs.error === String(NACK_REASONS.SenderReachoutTimelocked)
825
+ if (attrs.error === SERVER_ERROR_CODES.MessageAccountRestriction) {
826
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted or missing tctoken for contact')
827
+ const ackFrom = attrs.from
828
+ if (ackFrom && !inFlight463Recoveries.has(ackFrom)) {
829
+ inFlight463Recoveries.add(ackFrom)
830
+ void (async () => {
831
+ try {
832
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
833
+ const tcStorageJid = await resolveTcTokenJid(ackFrom, getLIDForPN)
834
+ const issueJid = await resolveIssuanceJid(ackFrom, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID)
835
+ const result = await issuePrivacyTokens([issueJid], unixTimestampSeconds())
836
+ await storeTcTokensFromIqResult({ result, fallbackJid: tcStorageJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
837
+ logger.debug({ from: ackFrom }, 'completed 463 token recovery issuance')
838
+ } catch (err) { logger.debug({ from: ackFrom, err: err?.message }, 'failed 463 token recovery issuance') }
839
+ finally { inFlight463Recoveries.delete(ackFrom) }
840
+ })()
841
+ }
842
+ } else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
843
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'smax-invalid (479): stale device session or malformed addressing')
844
+ } else if (isReachoutTimelocked) {
845
+ await fetchAccountReachoutTimelock().catch(err => logger.warn({ err }, 'failed to fetch reachout timelock'))
846
+ logger.warn({ attrs }, 'received error in ack')
847
+ } else {
848
+ logger.warn({ attrs }, 'received error in ack')
849
+ }
850
+ ev.emit("messages.update", [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: isReachoutTimelocked ? [attrs.error, ACCOUNT_RESTRICTED_TEXT] : [attrs.error] } }])
708
851
  }
709
852
  }
710
853
 
@@ -770,8 +913,51 @@ export const makeMessagesRecvSocket = (config) => {
770
913
  }
771
914
  })
772
915
 
773
- ev.on("connection.update", ({ isOnline }) => {
916
+ let lastTcTokenPruneTs = 0
917
+
918
+ async function pruneExpiredTcTokens() {
919
+ try {
920
+ await tcTokenIndexLoaded
921
+ const persisted = await readTcTokenIndex(authState.keys)
922
+ const allJids = new Set(tcTokenKnownJids)
923
+ for (const jid of persisted) allJids.add(jid)
924
+ if (!allJids.size) return
925
+ const jids = [...allJids]
926
+ const allTokens = await authState.keys.get('tctoken', jids)
927
+ const writes = {}, survivors = new Set()
928
+ let mutated = 0
929
+ for (const jid of jids) {
930
+ const entry = allTokens[jid]
931
+ if (!entry) { mutated++; continue }
932
+ const hasPeerToken = !!entry.token?.length
933
+ const peerTokenExpired = hasPeerToken && isTcTokenExpired(entry.timestamp)
934
+ const hasSenderTs = entry.senderTimestamp !== undefined
935
+ const senderTsExpired = hasSenderTs && isTcTokenExpired(entry.senderTimestamp)
936
+ const keepPeerToken = hasPeerToken && !peerTokenExpired
937
+ const keepSenderTs = hasSenderTs && !senderTsExpired
938
+ if (!keepPeerToken && !keepSenderTs) { writes[jid] = null; mutated++ }
939
+ else if (peerTokenExpired && keepSenderTs) { writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp }; survivors.add(jid); mutated++ }
940
+ else survivors.add(jid)
941
+ }
942
+ if (mutated === 0) return
943
+ await authState.keys.set({ tctoken: { ...writes, [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...survivors])) } } })
944
+ tcTokenKnownJids.clear()
945
+ for (const jid of survivors) tcTokenKnownJids.add(jid)
946
+ logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens')
947
+ } catch (err) { logger.warn({ err: err?.message }, 'failed to prune expired tctokens') }
948
+ }
949
+
950
+ ev.on("connection.update", ({ isOnline, connection }) => {
774
951
  if (typeof isOnline !== "undefined") { sendActiveReceipts = isOnline; logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`) }
952
+ if (connection === 'close' && tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); tcTokenIndexTimer = undefined; try { void Promise.resolve(flushTcTokenIndex()).catch(() => { }) } catch { } }
953
+ if (isOnline) { const now = Date.now(); const DAY_MS = 24 * 60 * 60 * 1000; if (now - lastTcTokenPruneTs >= DAY_MS) { lastTcTokenPruneTs = now; void pruneExpiredTcTokens() } }
954
+ })
955
+
956
+ registerSocketEndHandler(() => {
957
+ if (!config.msgRetryCounterCache && msgRetryCache.close) msgRetryCache.close()
958
+ if (!config.callOfferCache && callOfferCache.close) callOfferCache.close()
959
+ identityAssertDebounce.close()
960
+ sendActiveReceipts = false
775
961
  })
776
962
 
777
963
  return { ...sock, sendMessageAck, sendRetryRequest, rejectCall, offerCall, fetchMessageHistory, requestPlaceholderResend, messageRetryManager }