@zervonex/baileys 0.0.0-stage → 3.0.1

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.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3203 -2
  3. package/WAProto/WAProtoCompile.js +291 -0
  4. package/WAProto/fetcher.js +69 -0
  5. package/WAProto/index.js +88 -0
  6. package/WAProto/parser.js +313 -0
  7. package/engine-requirements.js +10 -0
  8. package/lib/Builders/AIRich.js +590 -0
  9. package/lib/Builders/base.js +130 -0
  10. package/lib/Builders/builders.js +448 -0
  11. package/lib/Builders/index.js +3 -0
  12. package/lib/Defaults/index.js +225 -0
  13. package/lib/Defaults/phonenumber-mcc.json +223 -0
  14. package/lib/Signal/index.js +2 -0
  15. package/lib/Signal/libsignal.js +523 -0
  16. package/lib/Signal/lid-mapping.js +283 -0
  17. package/lib/Socket/Client/index.js +2 -0
  18. package/lib/Socket/Client/types.js +10 -0
  19. package/lib/Socket/Client/websocket.js +84 -0
  20. package/lib/Socket/business.js +152 -0
  21. package/lib/Socket/chats.js +855 -0
  22. package/lib/Socket/communities.js +147 -0
  23. package/lib/Socket/groups.js +294 -0
  24. package/lib/Socket/index.js +15 -0
  25. package/lib/Socket/messages-recv.js +904 -0
  26. package/lib/Socket/messages-send.js +693 -0
  27. package/lib/Socket/mex.js +34 -0
  28. package/lib/Socket/newsletter.js +752 -0
  29. package/lib/Socket/nexus-handler.js +55 -0
  30. package/lib/Socket/registration.js +155 -0
  31. package/lib/Socket/socket.js +798 -0
  32. package/lib/Socket/spam-report.js +65 -0
  33. package/lib/Socket/username.js +82 -0
  34. package/lib/Store/index.js +6 -0
  35. package/lib/Store/make-cache-manager-store.js +81 -0
  36. package/lib/Store/make-in-memory-store.js +425 -0
  37. package/lib/Store/make-ordered-dictionary.js +82 -0
  38. package/lib/Store/object-repository.js +31 -0
  39. package/lib/Store/reporting-info-store.js +38 -0
  40. package/lib/Types/Auth.js +2 -0
  41. package/lib/Types/Bussines.js +2 -0
  42. package/lib/Types/Call.js +2 -0
  43. package/lib/Types/Chat.js +8 -0
  44. package/lib/Types/Contact.js +2 -0
  45. package/lib/Types/Events.js +2 -0
  46. package/lib/Types/GroupMetadata.js +2 -0
  47. package/lib/Types/Label.js +25 -0
  48. package/lib/Types/LabelAssociation.js +7 -0
  49. package/lib/Types/Message.js +15 -0
  50. package/lib/Types/Newsletter.js +100 -0
  51. package/lib/Types/Product.js +2 -0
  52. package/lib/Types/Signal.js +2 -0
  53. package/lib/Types/Socket.js +14 -0
  54. package/lib/Types/State.js +26 -0
  55. package/lib/Types/USync.js +2 -0
  56. package/lib/Types/index.js +30 -0
  57. package/lib/Utils/auth-utils.js +142 -0
  58. package/lib/Utils/baileys-event-stream.js +33 -0
  59. package/lib/Utils/bot-capabilities.js +40 -0
  60. package/lib/Utils/bot-planning-replay.js +44 -0
  61. package/lib/Utils/browser-utils.js +26 -0
  62. package/lib/Utils/business.js +75 -0
  63. package/lib/Utils/chat-utils.js +316 -0
  64. package/lib/Utils/companion-reg-client-utils.js +20 -0
  65. package/lib/Utils/consumer-application.js +38 -0
  66. package/lib/Utils/crypto.js +52 -0
  67. package/lib/Utils/decode-wa-message.js +197 -0
  68. package/lib/Utils/event-buffer.js +273 -0
  69. package/lib/Utils/extras.js +186 -0
  70. package/lib/Utils/generics.js +195 -0
  71. package/lib/Utils/history.js +51 -0
  72. package/lib/Utils/identity-change-handler.js +19 -0
  73. package/lib/Utils/index.js +39 -0
  74. package/lib/Utils/key-store.js +3 -0
  75. package/lib/Utils/link-preview.js +51 -0
  76. package/lib/Utils/logger.js +2 -0
  77. package/lib/Utils/lt-hash.js +3 -0
  78. package/lib/Utils/make-mutex.js +16 -0
  79. package/lib/Utils/message-composer.js +59 -0
  80. package/lib/Utils/message-retry-manager.js +48 -0
  81. package/lib/Utils/messages-media.js +520 -0
  82. package/lib/Utils/messages.js +742 -0
  83. package/lib/Utils/meta-ai-msmsg.js +122 -0
  84. package/lib/Utils/meta-compositing.js +75 -0
  85. package/lib/Utils/modern-messages.js +235 -0
  86. package/lib/Utils/native-flow.js +55 -0
  87. package/lib/Utils/noise-handler.js +161 -0
  88. package/lib/Utils/pre-key-manager.js +76 -0
  89. package/lib/Utils/process-message.js +399 -0
  90. package/lib/Utils/reporting-utils.js +171 -0
  91. package/lib/Utils/signal.js +167 -0
  92. package/lib/Utils/spam-report-utils.js +142 -0
  93. package/lib/Utils/sync-action-utils.js +33 -0
  94. package/lib/Utils/tc-token-utils.js +102 -0
  95. package/lib/Utils/use-multi-file-auth-state.js +307 -0
  96. package/lib/Utils/validate-connection.js +181 -0
  97. package/lib/WABinary/constants.js +1321 -0
  98. package/lib/WABinary/decode.js +152 -0
  99. package/lib/WABinary/encode.js +140 -0
  100. package/lib/WABinary/generic-utils.js +179 -0
  101. package/lib/WABinary/index.js +6 -0
  102. package/lib/WABinary/jid-utils.js +235 -0
  103. package/lib/WABinary/types.js +4 -0
  104. package/lib/WAM/BinaryInfo.js +10 -0
  105. package/lib/WAM/constants.js +22853 -0
  106. package/lib/WAM/encode.js +150 -0
  107. package/lib/WAM/index.js +3 -0
  108. package/lib/WAUSync/Protocols/USyncBotProfileProtocol.js +30 -0
  109. package/lib/WAUSync/Protocols/USyncBusinessProtocol.js +48 -0
  110. package/lib/WAUSync/Protocols/USyncContactProtocol.js +23 -0
  111. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +28 -0
  112. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +15 -0
  113. package/lib/WAUSync/Protocols/USyncFeatureProtocol.js +24 -0
  114. package/lib/WAUSync/Protocols/USyncLIDProtocol.js +9 -0
  115. package/lib/WAUSync/Protocols/USyncPictureProtocol.js +21 -0
  116. package/lib/WAUSync/Protocols/USyncSidelistProtocol.js +19 -0
  117. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +19 -0
  118. package/lib/WAUSync/Protocols/USyncTextStatusProtocol.js +22 -0
  119. package/lib/WAUSync/Protocols/USyncUsernameProtocol.js +16 -0
  120. package/lib/WAUSync/Protocols/index.js +12 -0
  121. package/lib/WAUSync/USyncQuery.js +100 -0
  122. package/lib/WAUSync/USyncUser.js +20 -0
  123. package/lib/WAUSync/index.js +3 -0
  124. package/lib/index.js +68 -0
  125. package/package.json +216 -5
@@ -0,0 +1,904 @@
1
+ import NodeCache from '@cacheable/node-cache'
2
+ import { Boom } from '@hapi/boom'
3
+ import { randomBytes } from 'crypto'
4
+ import { proto } from '../../WAProto/index.js'
5
+ import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js'
6
+ import { WAMessageStatus, WAMessageStubType } from '../Types/index.js'
7
+ import {
8
+ aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode,
9
+ decodeMessageNode, makeMutex, decryptMessageNode, delay, derivePairingCodeKey,
10
+ encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext,
11
+ extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg,
12
+ getNextPreKeys, getStatusFromReceiptType, extractMessageText,
13
+ handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS,
14
+ NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds,
15
+ xmppPreKey, xmppSignedPreKey, ACCOUNT_RESTRICTED_TEXT, buildMergedTcTokenIndexWrite,
16
+ isTcTokenExpired, readTcTokenIndex, resolveTcTokenJid, resolveIssuanceJid,
17
+ storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY, decryptMsmsgBotMessage,
18
+ decodeDecryptedMsmsgMessage, extractReportingInfoFromStanza, getContentType,
19
+ } from '../Utils/index.js'
20
+ import {
21
+ areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild,
22
+ getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString,
23
+ getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast,
24
+ isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET, jidEncode,
25
+ } from '../WABinary/index.js'
26
+ import { extractGroupMetadata } from './groups.js'
27
+ import { makeMessagesSocket } from './messages-send.js'
28
+
29
+ // ─── Pure stanza builders ─────────────────────────────────────────────────────
30
+
31
+ export const buildGroupCallStanza = ({ id, to, callId, callCreator, offerContent }) => ({
32
+ tag: 'call',
33
+ attrs: { id, to },
34
+ content: [{ tag: 'offer', attrs: { 'call-id': callId, 'call-creator': callCreator, type: 'group', 'group-jid': to }, content: offerContent }]
35
+ })
36
+
37
+ export const buildGroupCallCancelStanza = ({ to, callId, callCreator }) => ({
38
+ tag: 'call',
39
+ attrs: { from: callCreator, to },
40
+ content: [{ tag: 'terminate', attrs: { 'call-id': callId, 'call-creator': callCreator, reason: 'user-terminated', count: '0' }, content: undefined }]
41
+ })
42
+
43
+ // ─── Socket ───────────────────────────────────────────────────────────────────
44
+
45
+ export const makeMessagesRecvSocket = (config) => {
46
+ const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config
47
+ const sock = makeMessagesSocket(config)
48
+ const { ev, authState, ws, processingMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, registerSocketEndHandler, issuePrivacyTokens, fetchAccountReachoutTimelock, placeholderResendCache: sockPlaceholderResendCache } = sock
49
+
50
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
51
+ const { messageMutex, notificationMutex, receiptMutex: _receiptMutex, placeholderResendCache } = sock
52
+ const retryMutex = makeMutex()
53
+ const msgRetryCache = config.msgRetryCounterCache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, useClones: false })
54
+ const callOfferCache = config.callOfferCache || new NodeCache({ stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, useClones: false })
55
+ const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false })
56
+ const inFlightPreKeyLow = new Set()
57
+ const inFlight463Recoveries = new Set()
58
+
59
+ let sendActiveReceipts = false
60
+
61
+ // ── PDO helpers ───────────────────────────────────────────────────────────
62
+
63
+ const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
64
+ if (!authState.creds.me?.id) throw new Boom('Not authenticated')
65
+ return sendPeerDataOperationMessage({ historySyncOnDemandRequest: { chatJid: oldestMsgKey.remoteJid, oldestMsgFromMe: oldestMsgKey.fromMe, oldestMsgId: oldestMsgKey.id, oldestMsgTimestampMs: oldestMsgTimestamp, onDemandMsgCount: count }, peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND })
66
+ }
67
+
68
+ const requestPlaceholderResend = async (messageKey, msgData) => {
69
+ if (!authState.creds.me?.id) throw new Boom('Not authenticated')
70
+ if (await placeholderResendCache.get(messageKey?.id)) { logger.debug({ messageKey }, 'already requested resend'); return }
71
+ await placeholderResendCache.set(messageKey?.id, msgData || true)
72
+ await delay(2000)
73
+ if (!(await placeholderResendCache.get(messageKey?.id))) { logger.debug({ messageKey }, 'message received while resend requested'); return 'RESOLVED' }
74
+ setTimeout(async () => { if (await placeholderResendCache.get(messageKey?.id)) { logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline'); await placeholderResendCache.del(messageKey?.id) } }, 8000)
75
+ return sendPeerDataOperationMessage({ placeholderMessageResendRequest: [{ messageKey }], peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND })
76
+ }
77
+
78
+ const requestContactRefresh = async (jids) => {
79
+ if (!jids?.length) return
80
+ return sendPeerDataOperationMessage({ contactRefreshRequest: { jids: Array.isArray(jids) ? jids : [jids] }, peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.CONTACT_REFRESH_REQUEST })
81
+ }
82
+
83
+ // ── Newsletter notifications ──────────────────────────────────────────────
84
+
85
+ const handleMexNewsletterNotification = async (node) => {
86
+ const mexNode = getBinaryNodeChild(node, 'mex')
87
+ if (!mexNode?.content) return
88
+ let data
89
+ try { data = JSON.parse(mexNode.content.toString()) } catch { return }
90
+ const { operation, updates } = data || {}
91
+ if (!updates || !operation) return
92
+ logger.info({ operation, updates }, 'got mex newsletter notification')
93
+ switch (operation) {
94
+ case 'NotificationNewsletterUpdate':
95
+ for (const update of updates) if (update.jid && update.settings && Object.keys(update.settings).length > 0) ev.emit('newsletter-settings.update', { id: update.jid, update: update.settings })
96
+ break
97
+ case 'NotificationNewsletterAdminPromote':
98
+ for (const update of updates) if (update.jid && update.user) ev.emit('newsletter-participants.update', { id: update.jid, author: node.attrs.from, user: update.user, new_role: 'ADMIN', action: 'promote' })
99
+ break
100
+ default:
101
+ logger.info({ operation, data }, 'Unhandled mex newsletter notification')
102
+ }
103
+ }
104
+
105
+ const handleNewsletterNotification = async (node) => {
106
+ const from = node.attrs.from
107
+ const child = getAllBinaryNodeChildren(node)[0]
108
+ const author = node.attrs.participant
109
+ logger.info({ from, child }, 'got newsletter notification')
110
+ switch (child.tag) {
111
+ case 'reaction':
112
+ ev.emit('newsletter.reaction', { id: from, server_id: child.attrs.message_id, reaction: { code: getBinaryNodeChildString(child, 'reaction'), count: 1 } })
113
+ break
114
+ case 'view':
115
+ ev.emit('newsletter.view', { id: from, server_id: child.attrs.message_id, count: parseInt(child.content?.toString() || '0', 10) })
116
+ break
117
+ case 'participant':
118
+ ev.emit('newsletter-participants.update', { id: from, author, user: child.attrs.jid, action: child.attrs.action, new_role: child.attrs.role })
119
+ break
120
+ case 'update':
121
+ const settingsNode = getBinaryNodeChild(child, 'settings')
122
+ if (settingsNode) {
123
+ const update = {}
124
+ const nameNode = getBinaryNodeChild(settingsNode, 'name')
125
+ if (nameNode?.content) update.name = nameNode.content.toString()
126
+ const descNode = getBinaryNodeChild(settingsNode, 'description')
127
+ if (descNode?.content) update.description = descNode.content.toString()
128
+ ev.emit('newsletter-settings.update', { id: from, update })
129
+ }
130
+ break
131
+ case 'message':
132
+ const plaintextNode = getBinaryNodeChild(child, 'plaintext')
133
+ if (plaintextNode?.content) {
134
+ try {
135
+ const buf = typeof plaintextNode.content === 'string' ? Buffer.from(plaintextNode.content, 'binary') : Buffer.from(plaintextNode.content)
136
+ const messageProto = proto.Message.decode(buf).toJSON()
137
+ const fullMessage = proto.WebMessageInfo.fromObject({ key: { remoteJid: from, id: child.attrs.message_id || child.attrs.server_id, fromMe: false }, message: messageProto, messageTimestamp: +child.attrs.t }).toJSON()
138
+ await upsertMessage(fullMessage, 'append')
139
+ } catch (error) { logger.error({ error }, 'Failed to decode plaintext newsletter message') }
140
+ }
141
+ break
142
+ default:
143
+ logger.warn({ node }, 'Unknown newsletter notification')
144
+ }
145
+ }
146
+
147
+ // ── ACK / call helpers ────────────────────────────────────────────────────
148
+
149
+ const sendMessageAck = async (node, errorCode) => {
150
+ const { tag, attrs, content } = node
151
+ const hasUnavailable = !!getBinaryNodeChild({ tag, attrs, content }, 'unavailable')
152
+ const stanza = { tag: 'ack', attrs: { id: attrs.id, to: attrs.from, class: tag } }
153
+ if (errorCode) stanza.attrs.error = errorCode.toString()
154
+ if (attrs.participant) stanza.attrs.participant = attrs.participant
155
+ if (attrs.recipient) stanza.attrs.recipient = attrs.recipient
156
+ if (attrs.type || hasUnavailable || errorCode) stanza.attrs.type = attrs.type
157
+ if (tag === 'message' && authState.creds.me?.id) stanza.attrs.from = authState.creds.me.id
158
+ logger.debug({ recv: { tag, attrs }, sent: stanza.attrs }, 'sent ack')
159
+ try { await sendNode(stanza) } catch (error) {
160
+ 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')
161
+ else throw error
162
+ }
163
+ }
164
+
165
+ const rejectCall = async (callId, callFrom) => {
166
+ await query({ tag: 'call', attrs: { from: authState.creds.me.id, to: callFrom }, content: [{ tag: 'reject', attrs: { 'call-id': callId, 'call-creator': callFrom, count: '0' }, content: undefined }] })
167
+ }
168
+
169
+ const offerCall = async (toJid, isVideo = false) => {
170
+ const callId = randomBytes(16).toString('hex').toUpperCase().substring(0, 64)
171
+ const offerContent = []
172
+ offerContent.push({ tag: 'audio', attrs: { enc: 'opus', rate: '16000' }, content: undefined })
173
+ offerContent.push({ tag: 'audio', attrs: { enc: 'opus', rate: '8000' }, content: undefined })
174
+ if (isVideo) offerContent.push({ tag: 'video', attrs: { orientation: '0', screen_width: '1920', screen_height: '1080', device_orientation: '0', enc: 'vp8', dec: 'vp8' } })
175
+ offerContent.push({ tag: 'net', attrs: { medium: '3' }, content: undefined })
176
+ offerContent.push({ tag: 'capability', attrs: { ver: '1' }, content: new Uint8Array([1, 4, 255, 131, 207, 4]) })
177
+ offerContent.push({ tag: 'encopt', attrs: { keygen: '2' }, content: undefined })
178
+ const encKey = randomBytes(32)
179
+ const devices = (await getUSyncDevices([toJid], true, false)).map(({ user, device }) => jidEncode(user, 's.whatsapp.net', device))
180
+ await assertSessions(devices, true)
181
+ const { nodes: destinations, shouldIncludeDeviceIdentity } = await createParticipantNodes(devices, { call: { callKey: encKey } }, {})
182
+ offerContent.push({ tag: 'destination', attrs: {}, content: destinations })
183
+ if (shouldIncludeDeviceIdentity) offerContent.push({ tag: 'device-identity', attrs: {}, content: encodeSignedDeviceIdentity(authState.creds.account, true) })
184
+ await query({ tag: 'call', attrs: { to: toJid }, content: [{ tag: 'offer', attrs: { 'call-id': callId, 'call-creator': authState.creds.me.id }, content: offerContent }] })
185
+ return { callId, toJid, isVideo }
186
+ }
187
+
188
+ // ── Retry logic ───────────────────────────────────────────────────────────
189
+
190
+ const sendRetryRequest = async (node, forceIncludeKeys = false) => {
191
+ const { fullMessage } = decodeMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '')
192
+ const { key: msgKey } = fullMessage
193
+ const msgId = msgKey.id
194
+
195
+ if (messageRetryManager) {
196
+ if (messageRetryManager.hasExceededMaxRetries(msgId)) { logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing'); messageRetryManager.markRetryFailed(msgId); return }
197
+ const retryCount = messageRetryManager.incrementRetryCount(msgId)
198
+ await msgRetryCache.set(`${msgId}:${msgKey?.participant}`, retryCount)
199
+ } else {
200
+ const key = `${msgId}:${msgKey?.participant}`
201
+ let retryCount = (await msgRetryCache.get(key)) || 0
202
+ if (retryCount >= maxMsgRetryCount) { logger.debug({ retryCount, msgId }, 'reached retry limit, clearing'); await msgRetryCache.del(key); return }
203
+ await msgRetryCache.set(key, retryCount + 1)
204
+ }
205
+
206
+ const retryCount = (await msgRetryCache.get(`${msgId}:${msgKey?.participant}`)) || 1
207
+ const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
208
+ const fromJid = node.attrs.from
209
+ let shouldRecreateSession = false, recreateReason = ''
210
+
211
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
212
+ try {
213
+ const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
214
+ const hasSession = await signalRepository.validateSession(fromJid)
215
+ let lidJid = null
216
+ if (isPnUser(fromJid)) lidJid = await signalRepository.lidMapping.getLIDForPN(fromJid)
217
+ const result = messageRetryManager.shouldRecreateSession(fromJid, retryCount, hasSession.exists)
218
+ shouldRecreateSession = result.recreate; recreateReason = result.reason
219
+ if (shouldRecreateSession) {
220
+ logger.debug({ fromJid, lidJid, retryCount, reason: recreateReason }, 'recreating session for retry')
221
+ await authState.keys.set({ session: { [sessionId]: null } })
222
+ if (lidJid) await authState.keys.set({ session: { [signalRepository.jidToSignalProtocolAddress(lidJid)]: null } })
223
+ forceIncludeKeys = true
224
+ }
225
+ } catch (error) { logger.warn({ error, fromJid }, 'failed to check session recreation') }
226
+ }
227
+
228
+ if (retryCount <= 2) {
229
+ if (messageRetryManager) {
230
+ messageRetryManager.schedulePhoneRequest(msgId, async () => {
231
+ try { const requestId = await requestPlaceholderResend(msgKey); logger.debug(`sendRetryRequest: requested placeholder resend (${requestId}) for message ${msgId} (scheduled)`) }
232
+ catch (error) { logger.warn({ error, msgId }, 'failed to send scheduled phone request') }
233
+ })
234
+ } else {
235
+ const id = await requestPlaceholderResend(msgKey)
236
+ logger.debug(`sendRetryRequest: requested placeholder resend for message ${id}`)
237
+ }
238
+ }
239
+
240
+ const deviceIdentity = encodeSignedDeviceIdentity(account, true)
241
+ await authState.keys.transaction(async () => {
242
+ const receipt = { tag: 'receipt', attrs: { id: msgId, type: 'retry', to: node.attrs.from }, content: [{ tag: 'retry', attrs: { count: retryCount.toString(), id: node.attrs.id, t: node.attrs.t, v: '1', error: '0' } }, { tag: 'registration', attrs: {}, content: encodeBigEndian(authState.creds.registrationId) }] }
243
+ if (node.attrs.recipient) receipt.attrs.recipient = node.attrs.recipient
244
+ if (node.attrs.participant) receipt.attrs.participant = node.attrs.participant
245
+ if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
246
+ const { update, preKeys } = await getNextPreKeys(authState, 1)
247
+ const [keyId] = Object.keys(preKeys)
248
+ const key = preKeys[+keyId]
249
+ receipt.content.push({ tag: 'keys', attrs: {}, content: [{ tag: 'type', attrs: {}, content: Buffer.from(KEY_BUNDLE_TYPE) }, { tag: 'identity', attrs: {}, content: identityKey.public }, xmppPreKey(key, +keyId), xmppSignedPreKey(signedPreKey), { tag: 'device-identity', attrs: {}, content: deviceIdentity }] })
250
+ ev.emit('creds.update', update)
251
+ }
252
+ await sendNode(receipt)
253
+ logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt')
254
+ }, authState?.creds?.me?.id || 'sendRetryRequest')
255
+ }
256
+
257
+ // ── TC token helpers ──────────────────────────────────────────────────────
258
+
259
+ const reissueTcTokenAfterIdentityChange = (from) => {
260
+ void (async () => {
261
+ const normalizedJid = jidNormalizedUser(from)
262
+ const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN)
263
+ const tcTokenData = await authState.keys.get('tctoken', [tcJid])
264
+ const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
265
+ if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) return
266
+ logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken')
267
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
268
+ const issueToLid = sock.serverProps?.lidTrustedTokenIssueToLid ?? false
269
+ const issueJid = await resolveIssuanceJid(normalizedJid, issueToLid, getLIDForPN, getPNForLID)
270
+ const result = await issuePrivacyTokens([issueJid], senderTs)
271
+ await storeTcTokensFromIqResult({ result, fallbackJid: tcJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
272
+ })().catch(err => logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change'))
273
+ }
274
+
275
+ const handlePrivacyTokenNotification = async (node) => {
276
+ const tokensNode = getBinaryNodeChild(node, 'tokens')
277
+ if (!tokensNode) return
278
+ const from = jidNormalizedUser(node.attrs.from)
279
+ const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid)) ? jidNormalizedUser(node.attrs.sender_lid) : undefined
280
+ const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN))
281
+ logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification')
282
+ await storeTcTokensFromIqResult({ result: node, fallbackJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
283
+ }
284
+
285
+ const tcTokenKnownJids = new Set()
286
+ const tcTokenIndexLoaded = (async () => {
287
+ try { const jids = await readTcTokenIndex(authState.keys); for (const jid of jids) tcTokenKnownJids.add(jid); if (tcTokenKnownJids.size > 0) logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index') }
288
+ catch (err) { logger.warn({ err: err?.message }, 'failed to load tctoken index') }
289
+ })()
290
+
291
+ let tcTokenIndexTimer
292
+ const flushTcTokenIndex = async () => {
293
+ if (tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); tcTokenIndexTimer = undefined }
294
+ const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids)
295
+ return authState.keys.set({ tctoken: write })
296
+ }
297
+ const scheduleTcTokenIndexSave = () => {
298
+ if (tcTokenIndexTimer) clearTimeout(tcTokenIndexTimer)
299
+ tcTokenIndexTimer = setTimeout(() => { tcTokenIndexTimer = undefined; flushTcTokenIndex().catch(err => logger.warn({ err: err?.message }, 'failed to save tctoken index')) }, 5000)
300
+ }
301
+ const trackTcTokenJid = (jid) => { if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) { tcTokenKnownJids.add(jid); scheduleTcTokenIndexSave() } }
302
+
303
+ // ── Encrypt notification ──────────────────────────────────────────────────
304
+
305
+ const handleEncryptNotification = async (node) => {
306
+ const from = node.attrs.from
307
+ if (from === S_WHATSAPP_NET) {
308
+ const stanzaId = node.attrs.id
309
+ if (stanzaId && inFlightPreKeyLow.has(stanzaId)) return
310
+ const count = +getBinaryNodeChild(node, 'count').attrs.value
311
+ logger.debug({ count, shouldUploadMorePreKeys: count < MIN_PREKEY_COUNT }, 'recv pre-key count')
312
+ if (count < MIN_PREKEY_COUNT) {
313
+ if (stanzaId) inFlightPreKeyLow.add(stanzaId)
314
+ try { await uploadPreKeys() } finally { if (stanzaId) inFlightPreKeyLow.delete(stanzaId) }
315
+ }
316
+ } else {
317
+ const result = await handleIdentityChange(node, { meId: authState.creds.me?.id, meLid: authState.creds.me?.lid, validateSession: signalRepository.validateSession, assertSessions, debounceCache: identityAssertDebounce, logger, onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange })
318
+ if (result.action === 'no_identity_node') logger.info({ node }, 'unknown encrypt notification')
319
+ }
320
+ }
321
+
322
+ // ── Group notification ────────────────────────────────────────────────────
323
+
324
+ const handleGroupNotification = (fullNode, child, msg) => {
325
+ const actingParticipantLid = fullNode.attrs.participant
326
+ const actingParticipantPn = fullNode.attrs.participant_pn
327
+ const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid
328
+ const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn
329
+ switch (child?.tag) {
330
+ case 'create':
331
+ const metadata = extractGroupMetadata(child)
332
+ msg.messageStubType = WAMessageStubType.GROUP_CREATE
333
+ msg.messageStubParameters = [metadata.subject]
334
+ msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn }
335
+ ev.emit('chats.upsert', [{ id: metadata.id, name: metadata.subject, conversationTimestamp: metadata.creation }])
336
+ ev.emit('groups.upsert', [{ ...metadata, author: actingParticipantLid, authorPn: actingParticipantPn }])
337
+ break
338
+ case 'ephemeral':
339
+ case 'not_ephemeral':
340
+ msg.message = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: +(child.attrs.expiration || 0) } }
341
+ break
342
+ case 'modify':
343
+ const modifiedParticipants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => ({ id: attrs.jid, phoneNumber: isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, lid: isLidUser(attrs.lid) ? attrs.lid : undefined, admin: attrs.type || null }))
344
+ msg.messageStubParameters = modifiedParticipants.map(a => JSON.stringify(a))
345
+ msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER
346
+ break
347
+ case 'promote':
348
+ case 'demote':
349
+ case 'remove':
350
+ case 'add':
351
+ case 'leave':
352
+ msg.messageStubType = WAMessageStubType[`GROUP_PARTICIPANT_${child.tag.toUpperCase()}`]
353
+ const participants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => ({ id: attrs.jid, phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined, lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined, admin: attrs.type || null }))
354
+ if (participants.length === 1 && (areJidsSameUser(participants[0].id, actingParticipantLid) || areJidsSameUser(participants[0].id, actingParticipantPn)) && child.tag === 'remove') msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE
355
+ msg.messageStubParameters = participants.map(a => JSON.stringify(a))
356
+ break
357
+ case 'subject':
358
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT
359
+ msg.messageStubParameters = [child.attrs.subject]
360
+ break
361
+ case 'description':
362
+ const description = getBinaryNodeChild(child, 'body')?.content?.toString()
363
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_DESCRIPTION
364
+ msg.messageStubParameters = description ? [description] : undefined
365
+ break
366
+ case 'announcement':
367
+ case 'not_announcement':
368
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE
369
+ msg.messageStubParameters = [child.tag === 'announcement' ? 'on' : 'off']
370
+ break
371
+ case 'locked':
372
+ case 'unlocked':
373
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT
374
+ msg.messageStubParameters = [child.tag === 'locked' ? 'on' : 'off']
375
+ break
376
+ case 'invite':
377
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_INVITE_LINK
378
+ msg.messageStubParameters = [child.attrs.code]
379
+ break
380
+ case 'member_add_mode':
381
+ const addMode = child.content
382
+ if (addMode) { msg.messageStubType = WAMessageStubType.GROUP_MEMBER_ADD_MODE; msg.messageStubParameters = [addMode.toString()] }
383
+ break
384
+ case 'membership_approval_mode':
385
+ const approvalMode = getBinaryNodeChild(child, 'group_join')
386
+ if (approvalMode) { msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE; msg.messageStubParameters = [approvalMode.attrs.state] }
387
+ break
388
+ case 'created_membership_requests':
389
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
390
+ msg.messageStubParameters = [JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), 'created', child.attrs.request_method]
391
+ break
392
+ case 'revoked_membership_requests':
393
+ const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid)
394
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
395
+ msg.messageStubParameters = [JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }), isDenied ? 'revoked' : 'rejected']
396
+ break
397
+ }
398
+ }
399
+
400
+ // ── Devices notification ──────────────────────────────────────────────────
401
+
402
+ const handleDevicesNotification = async (node) => {
403
+ const [child] = getAllBinaryNodeChildren(node)
404
+ const from = jidNormalizedUser(node.attrs.from)
405
+ if (!child) { logger.debug({ from }, 'devices notification missing child, skipping'); return }
406
+ const tag = child.tag
407
+ const deviceHash = child.attrs.device_hash
408
+ const devices = getBinaryNodeChildren(child, 'device')
409
+ if (areJidsSameUser(from, authState.creds.me.id) || areJidsSameUser(from, authState.creds.me.lid)) logger.info({ deviceJids: devices.map(d => d.attrs.jid) }, 'got my own devices')
410
+ if (!devices.length) { logger.debug({ from, tag }, 'no devices in notification, skipping'); return }
411
+ const decoded = []
412
+ for (const d of devices) { const jid = d.attrs.jid; if (!jid) continue; const parts = jidDecode(jid); if (!parts) { logger.debug({ jid }, 'failed to decode device jid, skipping'); continue }; decoded.push({ jid, user: parts.user, server: parts.server, device: parts.device }) }
413
+ if (!decoded.length) return
414
+ await sock.devicesMutex.mutex(async () => {
415
+ const byUser = new Map()
416
+ for (const d of decoded) { const list = byUser.get(d.user) || []; list.push(d); byUser.set(d.user, list) }
417
+ for (const [user, entries] of byUser) {
418
+ if (tag === 'update') { logger.debug({ user }, `${user}'s device list updated, dropping cached devices`); await sock.userDevicesCache?.del(user); continue }
419
+ if (tag === 'remove') await signalRepository.deleteSession(entries.map(e => e.jid))
420
+ const existingCache = (await sock.userDevicesCache?.get(user)) || []
421
+ if (!existingCache.length) { logger.debug({ user, tag }, 'device list not cached, deferring to USync refresh'); continue }
422
+ const affected = new Set(entries.map(e => e.device))
423
+ let updatedDevices
424
+ switch (tag) {
425
+ case 'add': logger.info({ deviceHash, count: entries.length }, 'devices added'); updatedDevices = [...existingCache.filter(d => !affected.has(d.device)), ...entries.map(e => ({ user: e.user, server: e.server, device: e.device }))]; break
426
+ case 'remove': logger.info({ deviceHash, count: entries.length }, 'devices removed'); updatedDevices = existingCache.filter(d => !affected.has(d.device)); break
427
+ default: logger.debug({ tag }, 'unknown device list change tag'); continue
428
+ }
429
+ if (updatedDevices.length === 0) await sock.userDevicesCache?.del(user)
430
+ else await sock.userDevicesCache?.set(user, updatedDevices)
431
+ }
432
+ })
433
+ }
434
+
435
+ // ── processNotification ───────────────────────────────────────────────────
436
+
437
+ const processNotification = async (node) => {
438
+ const result = {}
439
+ const [child] = getAllBinaryNodeChildren(node)
440
+ const nodeType = node.attrs.type
441
+ const from = jidNormalizedUser(node.attrs.from)
442
+ switch (nodeType) {
443
+ case 'privacy_token': await handlePrivacyTokenNotification(node); break
444
+ case 'newsletter': await handleNewsletterNotification(node); break
445
+ case 'mex': await handleMexNewsletterNotification(node); break
446
+ case 'w:gp2': handleGroupNotification(node, child, result); break
447
+ case 'mediaretry': ev.emit('messages.media-update', [decodeMediaRetryNode(node)]); break
448
+ case 'encrypt': await handleEncryptNotification(node); break
449
+ case 'devices':
450
+ try { await handleDevicesNotification(node) } catch (error) { logger.error({ error, node }, 'failed to handle devices notification') }
451
+ break
452
+ case 'server_sync':
453
+ const update = getBinaryNodeChild(node, 'collection')
454
+ if (update) await resyncAppState([update.attrs.name], false)
455
+ break
456
+ case 'picture':
457
+ const setPicture = getBinaryNodeChild(node, 'set')
458
+ const delPicture = getBinaryNodeChild(node, 'delete')
459
+ ev.emit('contacts.update', [{ id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '', imgUrl: setPicture ? 'changed' : 'removed' }])
460
+ if (isJidGroup(from)) {
461
+ const pictureNode = setPicture || delPicture
462
+ result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON
463
+ if (setPicture) result.messageStubParameters = [setPicture.attrs.id]
464
+ result.participant = pictureNode?.attrs.author
465
+ result.key = { ...(result.key || {}), participant: setPicture?.attrs.author }
466
+ }
467
+ break
468
+ case 'account_sync':
469
+ if (child.tag === 'disappearing_mode') {
470
+ const newDuration = +child.attrs.duration
471
+ logger.info({ newDuration }, 'updated account disappearing mode')
472
+ ev.emit('creds.update', { accountSettings: { ...authState.creds.accountSettings, defaultDisappearingMode: { ephemeralExpiration: newDuration, ephemeralSettingTimestamp: +child.attrs.t } } })
473
+ } else if (child.tag === 'blocklist') {
474
+ for (const { attrs } of getBinaryNodeChildren(child, 'item')) ev.emit('blocklist.update', { blocklist: [attrs.jid], type: attrs.action === 'block' ? 'add' : 'remove' })
475
+ }
476
+ break
477
+ case 'link_code_companion_reg':
478
+ const linkCodeNode = getBinaryNodeChild(node, 'link_code_companion_reg')
479
+ const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeNode, 'link_code_pairing_ref'))
480
+ const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeNode, 'primary_identity_pub'))
481
+ const primaryEphemeralWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeNode, 'link_code_pairing_wrapped_primary_ephemeral_pub'))
482
+ const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralWrapped)
483
+ const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey)
484
+ const random = randomBytes(32)
485
+ const linkCodeSalt = randomBytes(32)
486
+ const linkCodePairingExpanded = hkdf(companionSharedKey, 32, { salt: linkCodeSalt, info: 'link_code_pairing_key_bundle_encryption_key' })
487
+ const encryptPayload = Buffer.concat([Buffer.from(authState.creds.signedIdentityKey.public), primaryIdentityPublicKey, random])
488
+ const encryptIv = randomBytes(12)
489
+ const encrypted = aesEncryptGCM(encryptPayload, linkCodePairingExpanded, encryptIv, Buffer.alloc(0))
490
+ const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted])
491
+ const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey)
492
+ const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random])
493
+ authState.creds.advSecretKey = Buffer.from(hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64')
494
+ await query({ tag: 'iq', attrs: { to: S_WHATSAPP_NET, type: 'set', id: sock.generateMessageTag(), xmlns: 'md' }, content: [{ tag: 'link_code_companion_reg', attrs: { jid: authState.creds.me.id, stage: 'companion_finish' }, content: [{ tag: 'link_code_pairing_wrapped_key_bundle', attrs: {}, content: encryptedPayload }, { tag: 'companion_identity_public', attrs: {}, content: authState.creds.signedIdentityKey.public }, { tag: 'link_code_pairing_ref', attrs: {}, content: ref }] }] })
495
+ authState.creds.registered = true
496
+ ev.emit('creds.update', authState.creds)
497
+ break
498
+ }
499
+ if (Object.keys(result).length) return result
500
+ }
501
+
502
+ // ── Link pairing helpers ──────────────────────────────────────────────────
503
+
504
+ const decipherLinkPublicKey = async (data) => {
505
+ const buffer = toRequiredBuffer(data)
506
+ const salt = buffer.slice(0, 32)
507
+ const secretKey = await derivePairingCodeKey(authState.creds.pairingCode, salt)
508
+ const iv = buffer.slice(32, 48)
509
+ const payload = buffer.slice(48, 80)
510
+ return aesDecryptCTR(payload, secretKey, iv)
511
+ }
512
+
513
+ const toRequiredBuffer = (data) => {
514
+ if (data === undefined) throw new Boom('Invalid buffer', { statusCode: 400 })
515
+ return data instanceof Buffer ? data : Buffer.from(data)
516
+ }
517
+
518
+ // ── Retry helpers ─────────────────────────────────────────────────────────
519
+
520
+ const willSendMessageAgain = async (id, participant) => { const retryCount = (await msgRetryCache.get(`${id}:${participant}`)) || 0; return retryCount < maxMsgRetryCount }
521
+ const updateSendMessageAgainCount = async (id, participant) => { const key = `${id}:${participant}`; await msgRetryCache.set(key, ((await msgRetryCache.get(key)) || 0) + 1) }
522
+
523
+ const sendMessagesAgain = async (key, ids, retryNode, receiptNode) => {
524
+ const remoteJid = key.remoteJid
525
+ const participant = key.participant || remoteJid
526
+ const retryCount = +retryNode.attrs.count || 1
527
+ const msgs = []
528
+
529
+ for (const id of ids) {
530
+ let msg
531
+ if (messageRetryManager) { const cached = messageRetryManager.getRecentMessage(remoteJid, id); if (cached) { msg = cached.message; logger.debug({ jid: remoteJid, id }, 'found message in retry cache'); messageRetryManager.markRetrySuccess(id) } }
532
+ if (!msg) { msg = await getMessage({ ...key, id }); if (msg) { logger.debug({ jid: remoteJid, id }, 'found message via getMessage'); if (messageRetryManager) messageRetryManager.markRetrySuccess(id) } }
533
+ msgs.push(msg)
534
+ }
535
+
536
+ const sendToAll = !jidDecode(participant)?.device
537
+ const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
538
+ let injectedFromBundle = false
539
+ const bundle = extractE2ESessionFromRetryReceipt(receiptNode)
540
+
541
+ if (bundle) {
542
+ try { await signalRepository.injectE2ESession({ jid: participant, session: bundle }); injectedFromBundle = true; logger.debug({ participant, retryCount }, 'injected session from retry receipt key bundle') }
543
+ catch (error) { logger.warn({ error, participant }, 'failed to inject session from retry receipt') }
544
+ }
545
+
546
+ if (!injectedFromBundle && typeof signalRepository.getSessionInfo === 'function') {
547
+ const receivedRegId = getBinaryNodeChildUInt(receiptNode, 'registration', 4)
548
+ if (typeof receivedRegId === 'number' && Number.isInteger(receivedRegId)) {
549
+ const info = await signalRepository.getSessionInfo(participant)
550
+ 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 } }) }
551
+ }
552
+ }
553
+
554
+ if (ids[0] && messageRetryManager && typeof messageRetryManager.saveBaseKey === 'function') {
555
+ const info = typeof signalRepository.getSessionInfo === 'function' ? await signalRepository.getSessionInfo(participant) : undefined
556
+ if (info) {
557
+ if (retryCount === 2) messageRetryManager.saveBaseKey(sessionId, ids[0], info.baseKey)
558
+ else if (retryCount > 2) {
559
+ if (messageRetryManager.hasSameBaseKey(sessionId, ids[0], info.baseKey)) { logger.warn({ participant, retryCount }, 'base key collision on retry, forcing fresh session'); await authState.keys.set({ session: { [sessionId]: null } }) }
560
+ messageRetryManager.deleteBaseKey(sessionId, ids[0])
561
+ }
562
+ }
563
+ }
564
+
565
+ let shouldRecreateSession = false, recreateReason = ''
566
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1 && !injectedFromBundle) {
567
+ try {
568
+ const hasSession = await signalRepository.validateSession(participant)
569
+ const result = messageRetryManager.shouldRecreateSession(participant, retryCount, hasSession.exists)
570
+ shouldRecreateSession = result.recreate; recreateReason = result.reason
571
+ if (shouldRecreateSession) { logger.debug({ participant, retryCount, reason: recreateReason }, 'recreating session for outgoing retry'); await authState.keys.set({ session: { [sessionId]: null } }) }
572
+ } catch (error) { logger.warn({ error, participant }, 'failed to check session recreation for outgoing retry') }
573
+ }
574
+
575
+ if (!injectedFromBundle) await assertSessions([participant], true)
576
+ if (isJidGroup(remoteJid)) await authState.keys.set({ 'sender-key-memory': { [remoteJid]: null } })
577
+ logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason, injectedFromBundle }, 'preparing retry recp')
578
+
579
+ for (const [i, msg] of msgs.entries()) {
580
+ if (!ids[i]) continue
581
+ if (msg && (await willSendMessageAgain(ids[i], participant))) {
582
+ await updateSendMessageAgainCount(ids[i], participant)
583
+ const msgRelayOpts = { messageId: ids[i] }
584
+ if (sendToAll) msgRelayOpts.useUserDevicesCache = false
585
+ else msgRelayOpts.participant = { jid: participant, count: +retryNode.attrs.count }
586
+ await relayMessage(key.remoteJid, msg, msgRelayOpts)
587
+ } else logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available')
588
+ }
589
+ }
590
+
591
+ // ── handleReceipt ─────────────────────────────────────────────────────────
592
+
593
+ const handleReceipt = async (node) => {
594
+ const { attrs, content } = node
595
+ const isLid = attrs.from.includes('lid')
596
+ const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, isLid ? authState.creds.me?.lid : authState.creds.me?.id)
597
+ const remoteJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient
598
+ const fromMe = !attrs.recipient || ((attrs.type === 'retry' || attrs.type === 'sender') && isNodeFromMe)
599
+ const key = { remoteJid, id: '', fromMe, participant: attrs.participant }
600
+ if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) { logger.debug({ remoteJid }, 'ignoring receipt from jid'); await sendMessageAck(node); return }
601
+ const ids = [attrs.id]
602
+ if (Array.isArray(content)) ids.push(...getBinaryNodeChildren(content[0], 'item').map(i => i.attrs.id))
603
+ try {
604
+ await Promise.all([processingMutex.mutex(async () => {
605
+ const status = getStatusFromReceiptType(attrs.type)
606
+ if (typeof status !== 'undefined' && (status >= proto.WebMessageInfo.Status.SERVER_ACK || !isNodeFromMe)) {
607
+ if (isJidGroup(remoteJid) || isJidStatusBroadcast(remoteJid)) {
608
+ if (attrs.participant) {
609
+ const updateKey = status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
610
+ ev.emit('message-receipt.update', ids.map(id => ({ key: { ...key, id }, receipt: { userJid: jidNormalizedUser(attrs.participant), [updateKey]: +attrs.t } })))
611
+ }
612
+ } else ev.emit('messages.update', ids.map(id => ({ key: { ...key, id }, update: { status, messageTimestamp: toNumber(+(attrs.t ?? 0)) } })))
613
+ }
614
+ if (attrs.type === 'retry') {
615
+ key.participant = key.participant || attrs.from
616
+ const retryNode = getBinaryNodeChild(node, 'retry')
617
+ if (ids[0] && key.participant && (await willSendMessageAgain(ids[0], key.participant))) {
618
+ if (key.fromMe) {
619
+ try { await updateSendMessageAgainCount(ids[0], key.participant); logger.debug({ attrs, key }, 'recv retry request'); await sendMessagesAgain(key, ids, retryNode, node) }
620
+ catch (error) { logger.error({ key, ids, trace: error instanceof Error ? error.stack : 'Unknown error' }, 'error in sending message again') }
621
+ } else logger.info({ attrs, key }, 'recv retry for not fromMe message')
622
+ } else {
623
+ logger.info({ attrs, key, participant: key.participant }, 'retry limit exhausted - clearing broken session')
624
+ try {
625
+ await signalRepository.deleteSession([key.participant])
626
+ await msgRetryCache.del(`${ids[0]}:${key.participant}`)
627
+ } catch (err) { logger.error({ err, participant: key.participant }, 'failed to clear session/cache at retry exhaustion') }
628
+ }
629
+ }
630
+ })])
631
+ } finally { await sendMessageAck(node) }
632
+ }
633
+
634
+ // ── handleNotification ────────────────────────────────────────────────────
635
+
636
+ const handleNotification = async (node) => {
637
+ const remoteJid = node.attrs.from
638
+ if (shouldIgnoreJid(remoteJid) && remoteJid !== S_WHATSAPP_NET) { logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification'); await sendMessageAck(node); return }
639
+ try {
640
+ await Promise.all([notificationMutex.mutex(async () => {
641
+ const msg = await processNotification(node)
642
+ if (msg) {
643
+ const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me.id)
644
+ const { senderAlt: participantAlt, addressingMode } = extractAddressingContext(node)
645
+ msg.key = { remoteJid, fromMe, participant: node.attrs.participant, participantAlt, addressingMode, id: node.attrs.id, ...(msg.key || {}) }
646
+ msg.participant ?? (msg.participant = node.attrs.participant)
647
+ msg.messageTimestamp = +node.attrs.t
648
+ await upsertMessage(proto.WebMessageInfo.fromObject(msg), 'append')
649
+ }
650
+ })])
651
+ } finally { await sendMessageAck(node) }
652
+ }
653
+
654
+ // ── handleMessage ─────────────────────────────────────────────────────────
655
+
656
+ const handleMessage = async (node) => {
657
+ if (shouldIgnoreJid(node.attrs.from) && node.attrs.from !== S_WHATSAPP_NET) { logger.debug({ key: node.attrs.key }, 'ignored message'); await sendMessageAck(node, NACK_REASONS.UnhandledError); return }
658
+
659
+ const encNode = getBinaryNodeChild(node, 'enc')
660
+ if (encNode && encNode.attrs.type === 'msmsg') {
661
+ const encIv = getBinaryNodeChildBuffer(encNode, 'enc_iv')
662
+ const encPayload = getBinaryNodeChildBuffer(encNode, 'enc_p') || encNode.content
663
+ if (!encIv || !encPayload) { logger.debug({ id: node.attrs.id, from: node.attrs.from, hasIv: !!encIv, hasPayload: !!encPayload }, 'msmsg missing enc_iv or enc_p, cannot decrypt'); await sendMessageAck(node); return }
664
+ try {
665
+ const messageSecret = authState.creds.myAppStateKeyId ? Buffer.from(authState.creds.myAppStateKeyId, 'base64') : undefined
666
+ if (!messageSecret) { logger.debug({ key: node.attrs.key }, 'no message secret for msmsg, skipping'); await sendMessageAck(node, NACK_REASONS.MissingMessageSecret); return }
667
+ const messageKey = { participant: node.attrs.participant || node.attrs.from, meId: authState.creds.me.id, meLid: authState.creds.me.lid, conversationJid: node.attrs.from, senderJid: node.attrs.participant, stanzaId: node.attrs.id, targetId: node.attrs.id }
668
+ const decrypted = await decryptMsmsgBotMessage(messageSecret, messageKey, { encPayload, encIv })
669
+ node.attrs._msmsgDecrypted = decodeDecryptedMsmsgMessage(decrypted)
670
+ logger.debug({ key: node.attrs.key }, 'decrypted msmsg bot message')
671
+ } catch (err) { logger.warn({ err, id: node.attrs.id }, 'failed to decrypt msmsg, falling back to nack'); await sendMessageAck(node, NACK_REASONS.MissingMessageSecret); return }
672
+ }
673
+
674
+ const { fullMessage: msg, category, author, decrypt } = decryptMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '', signalRepository, logger)
675
+ const alt = msg.key.participantAlt || msg.key.remoteJidAlt
676
+ if (!!alt) {
677
+ const altServer = jidDecode(alt)?.server
678
+ const primaryJid = msg.key.participant || msg.key.remoteJid
679
+ if (altServer === 'lid') { if (!(await signalRepository.lidMapping.getPNForLID(alt))) { await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]); await signalRepository.migrateSession(primaryJid, alt) } }
680
+ else { await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]); await signalRepository.migrateSession(alt, primaryJid) }
681
+ }
682
+
683
+ let acked = false
684
+ try {
685
+ await messageMutex.mutex(async () => {
686
+ await decrypt()
687
+ if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message)
688
+
689
+ if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
690
+ if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) { acked = true; return sendMessageAck(node, NACK_REASONS.ParsingError) }
691
+ if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
692
+ const unavailableNode = getBinaryNodeChild(node, 'unavailable')
693
+ const unavailableType = unavailableNode?.attrs?.type
694
+ if (['bot_unavailable_fanout', 'hosted_unavailable_fanout', 'view_once_unavailable_fanout'].includes(unavailableType)) { logger.debug({ msgId: msg.key.id, unavailableType }, 'skipping placeholder resend for excluded unavailable type'); acked = true; return sendMessageAck(node) }
695
+ const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
696
+ if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) { logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message'); acked = true; return sendMessageAck(node) }
697
+ const cleanKey = { remoteJid: msg.key.remoteJid, fromMe: msg.key.fromMe, id: msg.key.id, participant: msg.key.participant }
698
+ const msgData = { key: msg.key, messageTimestamp: msg.messageTimestamp, pushName: msg.pushName, participant: msg.participant, verifiedBizName: msg.verifiedBizName }
699
+ requestPlaceholderResend(cleanKey, msgData).then(requestId => { if (requestId && requestId !== 'RESOLVED') { logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message'); ev.emit('messages.update', [{ key: msg.key, update: { messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT, requestId] } }]) } }).catch(err => logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message'))
700
+ acked = true; await sendMessageAck(node)
701
+ } else {
702
+ if ((msg.messageStubParameters?.[0] || '').includes('InvalidPreKeyId')) { logger.debug({ msgId: msg.key.id, remoteJid: msg.key.remoteJid }, 'skipping retry for InvalidPreKeyId, sender must re-establish session'); acked = true; return sendMessageAck(node, NACK_REASONS.UnhandledError) }
703
+ if (isJidStatusBroadcast(msg.key.remoteJid)) {
704
+ const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp)
705
+ if (messageAge > STATUS_EXPIRY_SECONDS) { logger.debug({ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid }, 'skipping retry for expired status message'); acked = true; return sendMessageAck(node) }
706
+ }
707
+ retryMutex.mutex(async () => {
708
+ try { if (!ws.isOpen) { logger.debug({ node }, 'Connection closed, skipping retry'); return }; await sendRetryRequest(node, true); if (retryRequestDelayMs) await delay(retryRequestDelayMs) }
709
+ catch (err) { logger.error({ err }, 'Retry mechanism failed'); try { await sendRetryRequest(node, !getBinaryNodeChild(node, 'enc')) } catch (retryErr) { logger.error({ retryErr }, 'Final retry attempt failed') } }
710
+ acked = true; await sendMessageAck(node, NACK_REASONS.UnhandledError)
711
+ })
712
+ }
713
+ } else {
714
+ if (messageRetryManager && msg.key.id) messageRetryManager.cancelPendingPhoneRequest(msg.key.id)
715
+ const isNewsletter = isJidNewsletter(msg.key.remoteJid)
716
+ if (!isNewsletter) {
717
+ let type = undefined, participant = msg.key.participant
718
+ if (category === 'peer') type = 'peer_msg'
719
+ else if (msg.key.fromMe) { type = 'sender'; if (isLidUser(msg.key.remoteJid) || isLidUser(msg.key.remoteJidAlt)) participant = author }
720
+ else if (!sendActiveReceipts) type = 'inactive'
721
+ acked = true; await sendReceipt(msg.key.remoteJid, participant, [msg.key.id], type)
722
+ if (getHistoryMsg(msg.message)) await sendReceipt(jidNormalizedUser(msg.key.remoteJid), undefined, [msg.key.id], 'hist_sync')
723
+ } else { acked = true; await sendMessageAck(node); logger.debug({ key: msg.key }, 'processed newsletter message without receipts') }
724
+ }
725
+
726
+ cleanMessage(msg, authState.creds.me.id, authState.creds.me.lid)
727
+
728
+ if (!msg.key.fromMe) {
729
+ try {
730
+ const info = extractReportingInfoFromStanza(node)
731
+ if (info) {
732
+ const senderJid = msg.key.participant || author || msg.key.remoteJid
733
+ const contentType = msg.message ? getContentType(msg.message) : undefined
734
+ config.reportingInfoStore.add(senderJid, { stanzaId: info.stanzaId, timestamp: info.timestamp || Number(msg.messageTimestamp) || 0, reportingTag: info.reportingTag, text: extractMessageText(msg), pushName: msg.pushName || node.attrs?.notify || '', messageType: contentType === 'conversation' || contentType === 'extendedTextMessage' ? 'text' : contentType || 'text', fromJid: senderJid })
735
+ }
736
+ } catch (storeErr) { logger.warn({ err: storeErr, key: msg.key }, 'failed to store reporting_tag') }
737
+ }
738
+
739
+ await upsertMessage(msg, node.attrs.offline ? 'append' : 'notify')
740
+ })
741
+ } catch (error) {
742
+ const isClosed = error?.message?.includes('Connection Closed') || error?.output?.statusCode === 428
743
+ if (isClosed) logger.debug({ msg: error?.message }, 'Connection closed while handling message')
744
+ else logger.error({ error, stack: error?.stack, msg: error?.message || String(error), node: binaryNodeToString(node) }, 'error in handling message')
745
+ if (!acked) await sendMessageAck(node, NACK_REASONS.UnhandledError).catch(ackErr => logger.error({ ackErr }, 'failed to ack message after error'))
746
+ }
747
+ }
748
+
749
+ // ── handleCall ────────────────────────────────────────────────────────────
750
+
751
+ const handleCall = async (node) => {
752
+ const { attrs } = node
753
+ const [infoChild] = getAllBinaryNodeChildren(node)
754
+ if (!infoChild) throw new Boom('Missing call info in call node')
755
+ const status = getCallStatusFromNode(infoChild)
756
+ const callId = infoChild.attrs['call-id']
757
+ const from = infoChild.attrs.from || infoChild.attrs['call-creator']
758
+ const call = { chatId: attrs.from, from, callerPn: infoChild.attrs['caller_pn'], id: callId, date: new Date(+attrs.t * 1000), offline: !!attrs.offline, status }
759
+ if (status === 'offer') { call.isVideo = !!getBinaryNodeChild(infoChild, 'video'); call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']; call.groupJid = infoChild.attrs['group-jid']; await callOfferCache.set(call.id, call) }
760
+ const existingCall = await callOfferCache.get(call.id)
761
+ if (existingCall) { call.isVideo = existingCall.isVideo; call.isGroup = existingCall.isGroup; call.callerPn = call.callerPn || existingCall.callerPn }
762
+ if (['reject', 'accept', 'timeout', 'terminate'].includes(status)) await callOfferCache.del(call.id)
763
+ ev.emit('call', [call])
764
+ await sendMessageAck(node)
765
+ }
766
+
767
+ // ── handleBadAck ──────────────────────────────────────────────────────────
768
+
769
+ const handleBadAck = async ({ attrs }) => {
770
+ const key = { remoteJid: attrs.from, fromMe: true, id: attrs.id }
771
+ if (!attrs.error) return
772
+ const isReachoutTimelocked = attrs.error === String(NACK_REASONS.SenderReachoutTimelocked)
773
+ if (attrs.error === SERVER_ERROR_CODES.MessageAccountRestriction) {
774
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted or missing tctoken for contact')
775
+ const ackFrom = attrs.from
776
+ if (ackFrom && !inFlight463Recoveries.has(ackFrom)) {
777
+ inFlight463Recoveries.add(ackFrom)
778
+ void (async () => {
779
+ try {
780
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
781
+ const tcStorageJid = await resolveTcTokenJid(ackFrom, getLIDForPN)
782
+ const issueToLid = sock.serverProps?.lidTrustedTokenIssueToLid ?? false
783
+ const issueJid = await resolveIssuanceJid(ackFrom, issueToLid, getLIDForPN, getPNForLID)
784
+ const result = await issuePrivacyTokens([issueJid], unixTimestampSeconds())
785
+ await storeTcTokensFromIqResult({ result, fallbackJid: tcStorageJid, keys: authState.keys, getLIDForPN, onNewJidStored: trackTcTokenJid })
786
+ logger.debug({ from: ackFrom }, 'completed 463 token recovery issuance')
787
+ } catch (err) { logger.debug({ from: ackFrom, err: err?.message }, 'failed 463 token recovery issuance') }
788
+ finally { inFlight463Recoveries.delete(ackFrom) }
789
+ })()
790
+ }
791
+ } else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
792
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'smax-invalid (479): stale device session or malformed addressing')
793
+ } else if (isReachoutTimelocked) {
794
+ await fetchAccountReachoutTimelock().catch(err => logger.warn({ err }, 'failed to fetch reachout timelock'))
795
+ logger.warn({ attrs }, 'received error in ack')
796
+ } else {
797
+ logger.warn({ attrs }, 'received error in ack')
798
+ }
799
+ ev.emit('messages.update', [{ key, update: { status: WAMessageStatus.ERROR, messageStubParameters: isReachoutTimelocked ? [attrs.error, ACCOUNT_RESTRICTED_TEXT] : [attrs.error] } }])
800
+ }
801
+
802
+ // ── Offline node processor ────────────────────────────────────────────────
803
+
804
+ const processNodeWithBuffer = async (node, identifier, exec) => { ev.buffer(); await exec(node, false).catch(err => onUnexpectedError(err, identifier)); ev.flush() }
805
+ const yieldToEventLoop = () => new Promise(resolve => setImmediate(resolve))
806
+
807
+ const makeOfflineNodeProcessor = () => {
808
+ const nodeProcessorMap = new Map([['message', handleMessage], ['call', handleCall], ['receipt', handleReceipt], ['notification', handleNotification]])
809
+ const nodes = []
810
+ let isProcessing = false
811
+ const enqueue = (type, node) => {
812
+ nodes.push({ type, node })
813
+ if (isProcessing) return
814
+ isProcessing = true;
815
+ (async () => {
816
+ let processedInBatch = 0
817
+ while (nodes.length && ws.isOpen) {
818
+ const { type, node } = nodes.shift()
819
+ const processor = nodeProcessorMap.get(type)
820
+ if (!processor) { onUnexpectedError(new Error(`unknown offline node type: ${type}`), 'processing offline node'); continue }
821
+ await processor(node).catch(err => onUnexpectedError(err, `processing offline ${type}`))
822
+ if (++processedInBatch >= 10) { processedInBatch = 0; await yieldToEventLoop() }
823
+ }
824
+ isProcessing = false
825
+ })().catch(error => onUnexpectedError(error, 'processing offline nodes'))
826
+ }
827
+ return { enqueue }
828
+ }
829
+
830
+ const offlineNodeProcessor = makeOfflineNodeProcessor()
831
+
832
+ const processNode = async (type, node, identifier, exec) => {
833
+ const from = node.attrs.from
834
+ if (from && from !== S_WHATSAPP_NET && shouldIgnoreJid(from)) { await sendMessageAck(node, type === 'message' ? NACK_REASONS.UnhandledError : undefined); return }
835
+ if (node.attrs.offline) offlineNodeProcessor.enqueue(type, node)
836
+ else await processNodeWithBuffer(node, identifier, exec)
837
+ }
838
+
839
+ // ── WS event bindings ─────────────────────────────────────────────────────
840
+
841
+ ws.on('CB:message', async (node) => { await processNode('message', node, 'processing message', handleMessage) })
842
+ ws.on('CB:call', async (node) => { await processNode('call', node, 'handling call', handleCall) })
843
+ ws.on('CB:receipt', async (node) => { await processNode('receipt', node, 'handling receipt', handleReceipt) })
844
+ ws.on('CB:notification', async (node) => { await processNode('notification', node, 'handling notification', handleNotification) })
845
+ ws.on('CB:ack,class:message', (node) => { handleBadAck(node).catch(error => onUnexpectedError(error, 'handling bad ack')) })
846
+
847
+ ev.on('call', async ([call]) => {
848
+ if (!call) return
849
+ if (call.status === 'timeout' || (call.status === 'offer' && call.isGroup)) {
850
+ const msg = { key: { remoteJid: call.chatId, id: call.id, fromMe: false }, messageTimestamp: unixTimestampSeconds(call.date) }
851
+ if (call.status === 'timeout') msg.messageStubType = call.isGroup ? (call.isVideo ? WAMessageStubType.CALL_MISSED_GROUP_VIDEO : WAMessageStubType.CALL_MISSED_GROUP_VOICE) : (call.isVideo ? WAMessageStubType.CALL_MISSED_VIDEO : WAMessageStubType.CALL_MISSED_VOICE)
852
+ else msg.message = { call: { callKey: Buffer.from(call.id) } }
853
+ await upsertMessage(proto.WebMessageInfo.fromObject(msg), call.offline ? 'append' : 'notify')
854
+ }
855
+ })
856
+
857
+ // ── TC token pruning ──────────────────────────────────────────────────────
858
+
859
+ let lastTcTokenPruneTs = 0
860
+ const pruneExpiredTcTokens = async () => {
861
+ try {
862
+ await tcTokenIndexLoaded
863
+ const persisted = await readTcTokenIndex(authState.keys)
864
+ const allJids = new Set([...tcTokenKnownJids, ...persisted])
865
+ if (!allJids.size) return
866
+ const jids = [...allJids]
867
+ const allTokens = await authState.keys.get('tctoken', jids)
868
+ const writes = {}, survivors = new Set()
869
+ let mutated = 0
870
+ for (const jid of jids) {
871
+ const entry = allTokens[jid]
872
+ if (!entry) { mutated++; continue }
873
+ const hasPeerToken = !!entry.token?.length
874
+ const peerTokenExpired = hasPeerToken && isTcTokenExpired(entry.timestamp)
875
+ const hasSenderTs = entry.senderTimestamp !== undefined
876
+ const senderTsExpired = hasSenderTs && isTcTokenExpired(entry.senderTimestamp)
877
+ const keepPeer = hasPeerToken && !peerTokenExpired
878
+ const keepSender = hasSenderTs && !senderTsExpired
879
+ if (!keepPeer && !keepSender) { writes[jid] = null; mutated++ }
880
+ else if (peerTokenExpired && keepSender) { writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp }; survivors.add(jid); mutated++ }
881
+ else survivors.add(jid)
882
+ }
883
+ if (mutated === 0) return
884
+ await authState.keys.set({ tctoken: { ...writes, [TC_TOKEN_INDEX_KEY]: { token: Buffer.from(JSON.stringify([...survivors])) } } })
885
+ tcTokenKnownJids.clear(); for (const jid of survivors) tcTokenKnownJids.add(jid)
886
+ logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens')
887
+ } catch (err) { logger.warn({ err: err?.message }, 'failed to prune expired tctokens') }
888
+ }
889
+
890
+ ev.on('connection.update', ({ isOnline, connection }) => {
891
+ if (typeof isOnline !== 'undefined') { sendActiveReceipts = isOnline; logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`) }
892
+ if (connection === 'close' && tcTokenIndexTimer) { clearTimeout(tcTokenIndexTimer); tcTokenIndexTimer = undefined; try { void Promise.resolve(flushTcTokenIndex()).catch(() => { }) } catch { } }
893
+ if (isOnline) { const now = Date.now(); if (now - lastTcTokenPruneTs >= 86_400_000) { lastTcTokenPruneTs = now; void pruneExpiredTcTokens() } }
894
+ })
895
+
896
+ registerSocketEndHandler(() => {
897
+ if (!config.msgRetryCounterCache && msgRetryCache.close) msgRetryCache.close()
898
+ if (!config.callOfferCache && callOfferCache.close) callOfferCache.close()
899
+ identityAssertDebounce.close()
900
+ sendActiveReceipts = false
901
+ })
902
+
903
+ return { ...sock, sendMessageAck, sendRetryRequest, rejectCall, offerCall, fetchMessageHistory, requestPlaceholderResend, requestContactRefresh, messageRetryManager, tcTokenIndexLoaded }
904
+ }