@zervonex/baileys 3.0.1 → 3.0.3

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