@badzz88/baileys 8.5.7 → 8.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +337 -1055
  2. package/WAProto/WAProto.proto +284 -264
  3. package/WAProto/index.js +5 -5
  4. package/engine-requirements.js +7 -15
  5. package/lib/Defaults/index.js +130 -187
  6. package/lib/Signal/Group/ciphertext-message.js +11 -14
  7. package/lib/Signal/Group/group-session-builder.js +29 -91
  8. package/lib/Signal/Group/group_cipher.js +81 -88
  9. package/lib/Signal/Group/index.js +12 -136
  10. package/lib/Signal/Group/keyhelper.js +15 -70
  11. package/lib/Signal/Group/sender-chain-key.js +25 -31
  12. package/lib/Signal/Group/sender-key-distribution-message.js +62 -65
  13. package/lib/Signal/Group/sender-key-message.js +65 -68
  14. package/lib/Signal/Group/sender-key-name.js +41 -43
  15. package/lib/Signal/Group/sender-key-record.js +40 -43
  16. package/lib/Signal/Group/sender-key-state.js +83 -96
  17. package/lib/Signal/Group/sender-message-key.js +25 -29
  18. package/lib/Signal/libsignal.js +425 -453
  19. package/lib/Signal/lid-mapping.js +276 -259
  20. package/lib/Socket/Client/index.js +3 -30
  21. package/lib/Socket/Client/types.js +10 -12
  22. package/lib/Socket/Client/websocket.js +53 -61
  23. package/lib/Socket/business.js +379 -422
  24. package/lib/Socket/chats.js +1193 -1816
  25. package/lib/Socket/communities.js +431 -549
  26. package/lib/Socket/graphql.js +672 -612
  27. package/lib/Socket/groups.js +374 -764
  28. package/lib/Socket/index.js +17 -38
  29. package/lib/Socket/interop.js +417 -503
  30. package/lib/Socket/messages-recv.js +1915 -2441
  31. package/lib/Socket/messages-send.js +1213 -1630
  32. package/lib/Socket/mex.js +9 -15
  33. package/lib/Socket/newsletter.js +747 -808
  34. package/lib/Socket/privacy.js +310 -441
  35. package/lib/Socket/socket.js +1025 -1022
  36. package/lib/Socket/username.js +8 -15
  37. package/lib/Store/index.js +10 -36
  38. package/lib/Store/make-cache-manager-store.js +80 -85
  39. package/lib/Store/make-in-memory-store.js +231 -591
  40. package/lib/Store/make-ordered-dictionary.js +72 -78
  41. package/lib/Store/object-repository.js +28 -25
  42. package/lib/Types/Auth.js +2 -38
  43. package/lib/Types/Bussines.js +2 -2
  44. package/lib/Types/Call.js +2 -2
  45. package/lib/Types/Chat.js +8 -4
  46. package/lib/Types/Contact.js +2 -2
  47. package/lib/Types/Events.js +2 -2
  48. package/lib/Types/GroupMetadata.js +2 -2
  49. package/lib/Types/Label.js +24 -26
  50. package/lib/Types/LabelAssociation.js +6 -8
  51. package/lib/Types/Message.js +11 -95
  52. package/lib/Types/Mex.js +111 -112
  53. package/lib/Types/Product.js +2 -2
  54. package/lib/Types/Signal.js +2 -2
  55. package/lib/Types/Socket.js +3 -2
  56. package/lib/Types/State.js +56 -70
  57. package/lib/Types/USync.js +2 -2
  58. package/lib/Types/index.js +26 -55
  59. package/lib/Utils/auth-utils.js +288 -292
  60. package/lib/Utils/browser-utils.js +47 -113
  61. package/lib/Utils/business.js +224 -240
  62. package/lib/Utils/chat-utils.js +869 -1205
  63. package/lib/Utils/companion-reg-client-utils.js +34 -41
  64. package/lib/Utils/crypto.js +107 -144
  65. package/lib/Utils/decode-wa-message.js +308 -518
  66. package/lib/Utils/event-buffer.js +611 -596
  67. package/lib/Utils/generics.js +355 -515
  68. package/lib/Utils/history.js +134 -244
  69. package/lib/Utils/identity-change-handler.js +49 -51
  70. package/lib/Utils/index.js +23 -57
  71. package/lib/Utils/link-preview.js +77 -135
  72. package/lib/Utils/logger.js +3 -9
  73. package/lib/Utils/lt-hash.js +6 -37
  74. package/lib/Utils/make-mutex.js +33 -36
  75. package/lib/Utils/message-composer.js +233 -439
  76. package/lib/Utils/message-retry-manager.js +260 -265
  77. package/lib/Utils/messages-media.js +829 -855
  78. package/lib/Utils/messages.js +961 -2528
  79. package/lib/Utils/noise-handler.js +200 -195
  80. package/lib/Utils/offline-node-processor.js +33 -35
  81. package/lib/Utils/pre-key-manager.js +102 -103
  82. package/lib/Utils/process-message.js +560 -978
  83. package/lib/Utils/reporting-utils.js +253 -257
  84. package/lib/Utils/signal.js +195 -218
  85. package/lib/Utils/stanza-ack.js +29 -31
  86. package/lib/Utils/sticker.js +109 -144
  87. package/lib/Utils/sync-action-utils.js +45 -50
  88. package/lib/Utils/tc-token-utils.js +139 -137
  89. package/lib/Utils/use-multi-file-auth-state.js +109 -109
  90. package/lib/Utils/validate-connection.js +202 -218
  91. package/lib/WABinary/constants.js +1299 -1302
  92. package/lib/WABinary/decode.js +262 -343
  93. package/lib/WABinary/encode.js +4 -12
  94. package/lib/WABinary/generic-utils.js +187 -223
  95. package/lib/WABinary/index.js +6 -33
  96. package/lib/WABinary/jid-utils.js +88 -351
  97. package/lib/WABinary/types.js +2 -2
  98. package/lib/WAM/BinaryInfo.js +9 -12
  99. package/lib/WAM/constants.js +22853 -39486
  100. package/lib/WAM/encode.js +140 -132
  101. package/lib/WAM/index.js +4 -31
  102. package/lib/WAUSync/Protocols/USyncBotProfileProtocol.js +21 -23
  103. package/lib/WAUSync/Protocols/USyncBusinessProtocol.js +9 -9
  104. package/lib/WAUSync/Protocols/USyncContactProtocol.js +7 -7
  105. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +10 -10
  106. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +7 -7
  107. package/lib/WAUSync/Protocols/USyncFeatureProtocol.js +11 -12
  108. package/lib/WAUSync/Protocols/USyncLIDProtocol.js +4 -5
  109. package/lib/WAUSync/Protocols/USyncPictureProtocol.js +7 -7
  110. package/lib/WAUSync/Protocols/USyncSidelistProtocol.js +7 -8
  111. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +7 -7
  112. package/lib/WAUSync/Protocols/USyncTextStatusProtocol.js +8 -8
  113. package/lib/WAUSync/Protocols/USyncUsernameProtocol.js +7 -7
  114. package/lib/WAUSync/Protocols/index.js +12 -39
  115. package/lib/WAUSync/USyncQuery.js +67 -49
  116. package/lib/WAUSync/USyncUser.js +11 -5
  117. package/lib/WAUSync/index.js +3 -31
  118. package/lib/index.js +15 -48
  119. package/package.json +129 -99
  120. package/LICENSE +0 -21
  121. package/lib/Defaults/phonenumber-mcc.json +0 -223
  122. package/lib/Socket/aigroups.js +0 -240
  123. package/lib/Socket/interactive-handler.js +0 -522
  124. package/lib/Socket/managed-account.js +0 -183
  125. package/lib/Socket/registration.js +0 -427
  126. package/lib/Socket/text-router.js +0 -83
  127. package/lib/Types/Newsletter.js +0 -121
  128. package/lib/Utils/command-loader.d.ts +0 -31
  129. package/lib/Utils/command-loader.js +0 -100
  130. package/lib/Utils/consumer-application.js +0 -107
  131. package/lib/Utils/curve25519-js.js +0 -275
  132. package/lib/Utils/group-history.js +0 -60
  133. package/lib/Utils/jid-display-normalization.js +0 -218
  134. package/lib/Utils/meta-ai-msmsg.js +0 -262
  135. package/lib/Utils/native-bridge.js +0 -82
  136. package/lib/Utils/session-pool.d.ts +0 -16
  137. package/lib/Utils/session-pool.js +0 -94
  138. package/lib/Utils/sticker.d.ts +0 -17
  139. package/lib/Utils/view-once-cache.d.ts +0 -9
  140. package/lib/Utils/view-once-cache.js +0 -79
  141. package/lib/Utils/voip-rekey.js +0 -22
  142. package/lib/antiban.js +0 -4637
@@ -1,2442 +1,1916 @@
1
- 'use strict'
2
- var __importDefault =
3
- (this && this.__importDefault) ||
4
- function (mod) {
5
- return mod && mod.__esModule ? mod : { default: mod }
6
- }
7
- Object.defineProperty(exports, '__esModule', { value: true })
8
- exports.makeMessagesRecvSocket = void 0
9
- const node_cache_1 = __importDefault(require('@cacheable/node-cache'))
10
- const boom_1 = require('@hapi/boom')
11
- const crypto_1 = require('crypto')
12
- const index_js_1 = require('../../WAProto/index.js')
13
- const Defaults_1 = require('../Defaults')
14
- const Types_1 = require('../Types')
15
- const Utils_1 = require('../Utils')
16
- const jid_display_normalization_1 = require('../Utils/jid-display-normalization')
17
- const make_mutex_1 = require('../Utils/make-mutex')
18
- const offline_node_processor_1 = require('../Utils/offline-node-processor')
19
- const stanza_ack_1 = require('../Utils/stanza-ack')
20
- const tc_token_utils_1 = require('../Utils/tc-token-utils')
21
- const WABinary_1 = require('../WABinary')
22
- const groups_1 = require('./groups')
23
- const aigroup_1 = require('./aigroups')
24
- const messages_send_1 = require('./messages-send')
25
-
26
- const makeMessagesRecvSocket = config => {
27
- const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } =
28
- config
29
- const sock = (0, messages_send_1.makeMessagesSocket)(config)
30
- const {
31
- ev,
32
- authState,
33
- ws,
34
- messageMutex,
35
- notificationMutex,
36
- receiptMutex,
37
- signalRepository,
38
- query,
39
- upsertMessage,
40
- getUSyncDevices,
41
- createParticipantNodes,
42
- resyncAppState,
43
- onUnexpectedError,
44
- assertSessions,
45
- sendNode,
46
- relayMessage,
47
- sendReceipt,
48
- uploadPreKeys,
49
- sendPeerDataOperationMessage,
50
- messageRetryManager,
51
- issuePrivacyTokens
52
- } = sock
53
- const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping)
54
- // Track when the socket fully opens so pending pre-connect messages are treated as history
55
- const socketCreatedAt = Math.floor(Date.now() / 1000)
56
- let isConnected = false
57
- /** this mutex ensures that each retryRequest will wait for the previous one to finish */
58
- const retryMutex = (0, make_mutex_1.makeMutex)()
59
- const msgRetryCache =
60
- config.msgRetryCounterCache ||
61
- new node_cache_1.default({
62
- stdTTL: Defaults_1.DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
63
- useClones: false
64
- })
65
- const callOfferCache =
66
- config.callOfferCache ||
67
- new node_cache_1.default({
68
- stdTTL: Defaults_1.DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins
69
- useClones: false
70
- })
71
- const placeholderResendCache =
72
- config.placeholderResendCache ||
73
- new node_cache_1.default({
74
- stdTTL: Defaults_1.DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
75
- useClones: false
76
- })
77
- // Debounce identity-change session refreshes per JID to avoid bursts
78
- const identityAssertDebounce = new node_cache_1.default({ stdTTL: 5, useClones: false })
79
- let sendActiveReceipts = false
80
- const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
81
- if (!authState.creds.me?.id) {
82
- throw new boom_1.Boom('Not authenticated')
83
- }
84
- const pdoMessage = {
85
- historySyncOnDemandRequest: {
86
- chatJid: oldestMsgKey.remoteJid,
87
- oldestMsgFromMe: oldestMsgKey.fromMe,
88
- oldestMsgId: oldestMsgKey.id,
89
- oldestMsgTimestampMs: oldestMsgTimestamp,
90
- onDemandMsgCount: count
91
- },
92
- peerDataOperationRequestType: index_js_1.proto.Message.PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND
93
- }
94
- return sendPeerDataOperationMessage(pdoMessage)
95
- }
96
- const requestPlaceholderResend = async (messageKey, msgData) => {
97
- if (!authState.creds.me?.id) {
98
- throw new boom_1.Boom('Not authenticated')
99
- }
100
- if (await placeholderResendCache.get(messageKey?.id)) {
101
- logger.debug({ messageKey }, 'already requested resend')
102
- return
103
- } else {
104
- // Store original message data so PDO response handler can preserve
105
- // metadata (LID details, timestamps, etc.) that the phone may omit
106
- await placeholderResendCache.set(messageKey?.id, msgData || true)
107
- }
108
- await (0, Utils_1.delay)(2000)
109
- if (!(await placeholderResendCache.get(messageKey?.id))) {
110
- logger.debug({ messageKey }, 'message received while resend requested')
111
- return 'RESOLVED'
112
- }
113
- const pdoMessage = {
114
- placeholderMessageResendRequest: [
115
- {
116
- messageKey
117
- }
118
- ],
119
- peerDataOperationRequestType: index_js_1.proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND
120
- }
121
- setTimeout(async () => {
122
- if (await placeholderResendCache.get(messageKey?.id)) {
123
- logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline')
124
- await placeholderResendCache.del(messageKey?.id)
125
- }
126
- }, 8000)
127
- return sendPeerDataOperationMessage(pdoMessage)
128
- }
129
- /**
130
- * Request a Waffle (Meta-account) linking nonce from the paired phone.
131
- * The phone responds via a PeerDataOperationRequestResponseMessage containing
132
- * a WaffleNonceFetchResponse with the nonce needed for Meta account linking.
133
- */
134
- const requestWaffleNonce = async () => {
135
- if (!authState.creds.me?.id) {
136
- throw new boom_1.Boom('Not authenticated')
137
- }
138
- return sendPeerDataOperationMessage({
139
- peerDataOperationRequestType: index_js_1.proto.Message.PeerDataOperationRequestType.WAFFLE_LINKING_NONCE_FETCH
140
- })
141
- }
142
- /**
143
- * Request a Companion Canonical User nonce from the paired phone.
144
- * Used during companion linking to canonicalize the user identity across devices.
145
- * The phone responds with a CompanionCanonicalUserNonceFetchResponse (nonce + waFbid).
146
- *
147
- * @param {string} [registrationTraceId] - Optional trace ID for this registration attempt.
148
- */
149
- const requestCompanionCanonicalNonce = async (registrationTraceId) => {
150
- if (!authState.creds.me?.id) {
151
- throw new boom_1.Boom('Not authenticated')
152
- }
153
- return sendPeerDataOperationMessage({
154
- companionCanonicalUserNonceFetchRequest: registrationTraceId ? { registrationTraceId } : {},
155
- peerDataOperationRequestType: index_js_1.proto.Message.PeerDataOperationRequestType.COMPANION_CANONICAL_USER_NONCE_FETCH
156
- })
157
- }
158
- /**
159
- * Request a Companion Meta nonce from the paired phone.
160
- * Used during Meta-account companion linking flow.
161
- * The phone responds with a CompanionMetaNonceFetchResponse (nonce).
162
- */
163
- const requestCompanionMetaNonce = async () => {
164
- if (!authState.creds.me?.id) {
165
- throw new boom_1.Boom('Not authenticated')
166
- }
167
- return sendPeerDataOperationMessage({
168
- peerDataOperationRequestType: index_js_1.proto.Message.PeerDataOperationRequestType.COMPANION_META_NONCE_FETCH
169
- })
170
- }
171
- // Handles mex newsletter notifications
172
- const handleMexNewsletterNotification = async node => {
173
- const mexNode = (0, WABinary_1.getBinaryNodeChild)(node, 'mex')
174
- if (!mexNode?.content) {
175
- logger.warn({ node }, 'Invalid mex newsletter notification')
176
- return
177
- }
178
- let data
179
- try {
180
- data = JSON.parse(mexNode.content.toString())
181
- } catch (error) {
182
- logger.error({ err: error, node }, 'Failed to parse mex newsletter notification')
183
- return
184
- }
185
- const operation = data?.operation
186
- const updates = data?.updates
187
- if (!updates || !operation) {
188
- logger.warn({ data }, 'Invalid mex newsletter notification content')
189
- return
190
- }
191
- logger.info({ operation, updates }, 'got mex newsletter notification')
192
- switch (operation) {
193
- case 'NotificationNewsletterUpdate':
194
- for (const update of updates) {
195
- if (update.jid && update.settings && Object.keys(update.settings).length > 0) {
196
- ev.emit('newsletter-settings.update', {
197
- id: update.jid,
198
- update: update.settings
199
- })
200
- }
201
- }
202
- break
203
- case 'NotificationNewsletterAdminPromote':
204
- for (const update of updates) {
205
- if (update.jid && update.user) {
206
- ev.emit('newsletter-participants.update', {
207
- id: update.jid,
208
- author: node.attrs.from,
209
- user: update.user,
210
- new_role: 'ADMIN',
211
- action: 'promote'
212
- })
213
- }
214
- }
215
- break
216
- default:
217
- logger.info({ operation, data }, 'Unhandled mex newsletter notification')
218
- break
219
- }
220
- }
221
- // Handles newsletter notifications
222
- const handleNewsletterNotification = async node => {
223
- const from = node.attrs.from
224
- const child = (0, WABinary_1.getAllBinaryNodeChildren)(node)[0]
225
- const author = node.attrs.participant
226
- logger.info({ from, child }, 'got newsletter notification')
227
- switch (child.tag) {
228
- case 'reaction':
229
- const reactionUpdate = {
230
- id: from,
231
- server_id: child.attrs.message_id,
232
- reaction: {
233
- code: (0, WABinary_1.getBinaryNodeChildString)(child, 'reaction'),
234
- count: 1
235
- }
236
- }
237
- ev.emit('newsletter.reaction', reactionUpdate)
238
- break
239
- case 'view':
240
- const viewUpdate = {
241
- id: from,
242
- server_id: child.attrs.message_id,
243
- count: parseInt(child.content?.toString() || '0', 10)
244
- }
245
- ev.emit('newsletter.view', viewUpdate)
246
- break
247
- case 'participant':
248
- const participantUpdate = {
249
- id: from,
250
- author,
251
- user: child.attrs.jid,
252
- action: child.attrs.action,
253
- new_role: child.attrs.role
254
- }
255
- ev.emit('newsletter-participants.update', participantUpdate)
256
- break
257
- case 'update':
258
- const settingsNode = (0, WABinary_1.getBinaryNodeChild)(child, 'settings')
259
- if (settingsNode) {
260
- const update = {}
261
- const nameNode = (0, WABinary_1.getBinaryNodeChild)(settingsNode, 'name')
262
- if (nameNode?.content) update.name = nameNode.content.toString()
263
- const descriptionNode = (0, WABinary_1.getBinaryNodeChild)(settingsNode, 'description')
264
- if (descriptionNode?.content) update.description = descriptionNode.content.toString()
265
- ev.emit('newsletter-settings.update', {
266
- id: from,
267
- update
268
- })
269
- }
270
- break
271
- case 'message':
272
- const plaintextNode = (0, WABinary_1.getBinaryNodeChild)(child, 'plaintext')
273
- if (plaintextNode?.content) {
274
- try {
275
- const contentBuf =
276
- typeof plaintextNode.content === 'string'
277
- ? Buffer.from(plaintextNode.content, 'binary')
278
- : Buffer.from(plaintextNode.content)
279
- const messageProto = index_js_1.proto.Message.decode(contentBuf).toJSON()
280
- const fullMessage = index_js_1.proto.WebMessageInfo.fromObject({
281
- key: {
282
- remoteJid: from,
283
- id: child.attrs.message_id || child.attrs.server_id,
284
- fromMe: false // TODO: is this really true though
285
- },
286
- message: messageProto,
287
- messageTimestamp: +child.attrs.t
288
- }).toJSON()
289
- await upsertMessage(fullMessage, 'append')
290
- logger.info('Processed plaintext newsletter message')
291
- } catch (error) {
292
- logger.error({ error }, 'Failed to decode plaintext newsletter message')
293
- }
294
- }
295
- break
296
- case 'live_updates':
297
- // Live view count / engagement update
298
- const liveCount = parseInt(child.attrs.count || child.content?.toString() || '0', 10)
299
- ev.emit('newsletter.live-update', {
300
- id: from,
301
- server_id: child.attrs.message_id || child.attrs.server_id,
302
- liveViewers: liveCount,
303
- timestamp: child.attrs.t ? +child.attrs.t : undefined
304
- })
305
- break
306
- case 'pin':
307
- ev.emit('newsletter.pin', {
308
- id: from,
309
- server_id: child.attrs.message_id || child.attrs.server_id,
310
- pinned: child.attrs.action !== 'unpin'
311
- })
312
- break
313
- case 'category':
314
- ev.emit('newsletter-settings.update', {
315
- id: from,
316
- update: { category: child.attrs.value || child.content?.toString() }
317
- })
318
- break
319
- case 'invite':
320
- ev.emit('newsletter.invite', {
321
- id: from,
322
- inviteCode: child.attrs.code,
323
- inviter: child.attrs.jid || author,
324
- role: child.attrs.role || 'SUBSCRIBER'
325
- })
326
- break
327
- default:
328
- logger.warn({ node }, 'Unknown newsletter notification')
329
- break
330
- }
331
- }
332
- const sendMessageAck = async (node, errorCode) => {
333
- const stanza = (0, stanza_ack_1.buildAckStanza)(node, errorCode, authState.creds.me.id)
334
- logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack')
335
- await sendNode(stanza)
336
- }
337
-
338
- const offerCall = async (toJid, isVideo = false) => {
339
- const callId = crypto_1.randomBytes(16).toString('hex').toUpperCase().substring(0, 64)
340
- const offerContent = []
341
- offerContent.push({
342
- tag: 'audio',
343
- attrs: { enc: 'opus', rate: '16000' },
344
- content: undefined
345
- })
346
- offerContent.push({
347
- tag: 'audio',
348
- attrs: { enc: 'opus', rate: '8000' },
349
- content: undefined
350
- })
351
-
352
- if (isVideo) {
353
- offerContent.push({
354
- tag: 'video',
355
- attrs: {
356
- enc: 'vp8',
357
- dec: 'vp8',
358
- orientation: '0',
359
- screen_width: '1920',
360
- screen_height: '1080',
361
- device_orientation: '0'
362
- },
363
- content: undefined
364
- })
365
- }
366
- offerContent.push({
367
- tag: 'net',
368
- attrs: { medium: '3' },
369
- content: undefined
370
- })
371
- offerContent.push({
372
- tag: 'capability',
373
- attrs: { ver: '1' },
374
- content: new Uint8Array([1, 4, 255, 131, 207, 4])
375
- })
376
- offerContent.push({
377
- tag: 'encopt',
378
- attrs: { keygen: '2' },
379
- content: undefined
380
- })
381
-
382
- const encKey = crypto_1.randomBytes(32)
383
- const devices = (await getUSyncDevices([toJid], true, false)).map(({ user, device }) =>
384
- WABinary_1.jidEncode(user, 's.whatsapp.net', device)
385
- )
386
- await assertSessions(devices, true)
387
-
388
- const { nodes: destinations, shouldIncludeDeviceIdentity } = await createParticipantNodes(
389
- devices,
390
- {
391
- call: {
392
- callKey: new Uint8Array(encKey)
393
- }
394
- },
395
- { count: '0' }
396
- )
397
- offerContent.push({ tag: 'destination', attrs: {}, content: destinations })
398
-
399
- if (shouldIncludeDeviceIdentity) {
400
- offerContent.push({
401
- tag: 'device-identity',
402
- attrs: {},
403
- content: Utils_1.encodeSignedDeviceIdentity(authState.creds.account, true)
404
- })
405
- }
406
-
407
- const stanza = {
408
- tag: 'call',
409
- attrs: {
410
- id: Utils_1.generateMessageID(),
411
- to: toJid
412
- },
413
- content: [
414
- {
415
- tag: 'offer',
416
- attrs: {
417
- 'call-id': callId,
418
- 'call-creator': authState.creds.me.id
419
- },
420
- content: offerContent
421
- }
422
- ]
423
- }
424
-
425
- await query(stanza)
426
-
427
- return {
428
- id: callId,
429
- to: toJid
430
- }
431
- }
432
-
433
- const rejectCall = async (callId, callFrom) => {
434
- const stanza = {
435
- tag: 'call',
436
- attrs: {
437
- from: authState.creds.me.id,
438
- to: callFrom
439
- },
440
- content: [
441
- {
442
- tag: 'reject',
443
- attrs: {
444
- 'call-id': callId,
445
- 'call-creator': callFrom,
446
- count: '0'
447
- },
448
- content: undefined
449
- }
450
- ]
451
- }
452
- await query(stanza)
453
- }
454
-
455
- const acceptCall = async (callId, callFrom) => {
456
- const stanza = {
457
- tag: 'call',
458
- attrs: {
459
- from: authState.creds.me.id,
460
- to: callFrom
461
- },
462
- content: [
463
- {
464
- tag: 'accept',
465
- attrs: {
466
- 'call-id': callId,
467
- 'call-creator': callFrom,
468
- count: '0'
469
- },
470
- content: undefined
471
- }
472
- ]
473
- }
474
- await query(stanza)
475
- }
476
-
477
- const terminateCall = async (callId, callFrom) => {
478
- const stanza = {
479
- tag: 'call',
480
- attrs: {
481
- from: authState.creds.me.id,
482
- to: callFrom
483
- },
484
- content: [
485
- {
486
- tag: 'terminate',
487
- attrs: {
488
- 'call-id': callId,
489
- 'call-creator': callFrom,
490
- reason: 'user-terminated',
491
- count: '0'
492
- },
493
- content: undefined
494
- }
495
- ]
496
- }
497
- await query(stanza)
498
- }
499
-
500
- /**
501
- * Re-encrypt call key for a device that reconnected mid-call.
502
- * Source: OutgoingSignalingHandler.java enc_rekey / rekeyEncryptionTask
503
- *
504
- * @param {string} callId - Active call ID
505
- * @param {string} callFrom - JID of the call creator
506
- * @param {Buffer} encryptedKeyBytes - Re-encrypted call session key bytes
507
- * @param {number} [count=0] - Retry counter (0–4)
508
- */
509
- const rekeyCall = async (callId, callFrom, encryptedKeyBytes, count = 0) => {
510
- const stanza = {
511
- tag: 'call',
512
- attrs: {
513
- from: authState.creds.me.id,
514
- to: callFrom
515
- },
516
- content: [
517
- {
518
- tag: 'enc_rekey',
519
- attrs: {
520
- 'call-id': callId,
521
- 'call-creator': callFrom,
522
- count: count.toString()
523
- },
524
- content: [
525
- {
526
- tag: 'enc',
527
- attrs: { v: '2', type: 'msg' },
528
- content: encryptedKeyBytes
529
- }
530
- ]
531
- }
532
- ]
533
- }
534
- await query(stanza)
535
- }
536
-
537
- /**
538
- * Join a call via an invite link.
539
- * Source: OutgoingSignalingHandler.java link_join tag
540
- *
541
- * @param {string} callId - Call ID from the link
542
- * @param {string} callCreator - JID of the call creator
543
- * @param {string} linkToken - Token from the call link
544
- */
545
- const joinCallLink = async (callId, callCreator, linkToken) => {
546
- const stanza = {
547
- tag: 'call',
548
- attrs: {
549
- from: authState.creds.me.id,
550
- to: callCreator
551
- },
552
- content: [
553
- {
554
- tag: 'link_join',
555
- attrs: {
556
- 'call-id': callId,
557
- 'call-creator': callCreator,
558
- token: linkToken
559
- },
560
- content: undefined
561
- }
562
- ]
563
- }
564
- await query(stanza)
565
- }
566
-
567
- /**
568
- * Query info about a call link before joining.
569
- * Source: OutgoingSignalingHandler.java link_query tag
570
- *
571
- * @param {string} callLinkCode - The call link code to query
572
- * @param {string} to - JID to send the query to
573
- */
574
- const queryCallLink = async (callLinkCode, to) => {
575
- const stanza = {
576
- tag: 'call',
577
- attrs: {
578
- from: authState.creds.me.id,
579
- to
580
- },
581
- content: [
582
- {
583
- tag: 'link_query',
584
- attrs: { code: callLinkCode },
585
- content: undefined
586
- }
587
- ]
588
- }
589
- return query(stanza)
590
- }
591
- const sendRetryRequest = async (node, forceIncludeKeys = false) => {
592
- const { fullMessage } = (0, Utils_1.decodeMessageNode)(node, authState.creds.me.id, authState.creds.me.lid || '')
593
- const { key: msgKey } = fullMessage
594
- const msgId = msgKey.id
595
- if (messageRetryManager) {
596
- // Check if we've exceeded max retries using the new system
597
- if (messageRetryManager.hasExceededMaxRetries(msgId)) {
598
- logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing')
599
- messageRetryManager.markRetryFailed(msgId)
600
- return
601
- }
602
- // Increment retry count using new system
603
- const retryCount = messageRetryManager.incrementRetryCount(msgId)
604
- // Use the new retry count for the rest of the logic
605
- const key = `${msgId}:${msgKey?.participant}`
606
- await msgRetryCache.set(key, retryCount)
607
- } else {
608
- // Fallback to old system
609
- const key = `${msgId}:${msgKey?.participant}`
610
- let retryCount = (await msgRetryCache.get(key)) || 0
611
- if (retryCount >= maxMsgRetryCount) {
612
- logger.debug({ retryCount, msgId }, 'reached retry limit, clearing')
613
- await msgRetryCache.del(key)
614
- return
615
- }
616
- retryCount += 1
617
- await msgRetryCache.set(key, retryCount)
618
- }
619
- const key = `${msgId}:${msgKey?.participant}`
620
- const retryCount = (await msgRetryCache.get(key)) || 1
621
- const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds
622
- const fromJid = node.attrs.from
623
- // Check if we should recreate the session
624
- let shouldRecreateSession = false
625
- let recreateReason = ''
626
- if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
627
- try {
628
- // Check if we have a session with this JID
629
- const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid)
630
- const hasSession = await signalRepository.validateSession(fromJid)
631
- const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists)
632
- shouldRecreateSession = result.recreate
633
- recreateReason = result.reason
634
- if (shouldRecreateSession) {
635
- logger.debug({ fromJid, retryCount, reason: recreateReason }, 'recreating session for retry')
636
- // Delete existing session to force recreation
637
- await authState.keys.set({ session: { [sessionId]: null } })
638
- forceIncludeKeys = true
639
- }
640
- } catch (error) {
641
- logger.warn({ error, fromJid }, 'failed to check session recreation')
642
- }
643
- }
644
- if (retryCount <= 2) {
645
- // Use new retry manager for phone requests if available
646
- if (messageRetryManager) {
647
- // Schedule phone request with delay (like whatsmeow)
648
- messageRetryManager.schedulePhoneRequest(msgId, async () => {
649
- try {
650
- const requestId = await requestPlaceholderResend(msgKey)
651
- logger.debug(
652
- `sendRetryRequest: requested placeholder resend (${requestId}) for message ${msgId} (scheduled)`
653
- )
654
- } catch (error) {
655
- logger.warn({ error, msgId }, 'failed to send scheduled phone request')
656
- }
657
- })
658
- } else {
659
- // Fallback to immediate request
660
- const msgId = await requestPlaceholderResend(msgKey)
661
- logger.debug(`sendRetryRequest: requested placeholder resend for message ${msgId}`)
662
- }
663
- }
664
- const deviceIdentity = (0, Utils_1.encodeSignedDeviceIdentity)(account, true)
665
- await authState.keys.transaction(async () => {
666
- const receipt = {
667
- tag: 'receipt',
668
- attrs: {
669
- id: msgId,
670
- type: 'retry',
671
- to: node.attrs.from
672
- },
673
- content: [
674
- {
675
- tag: 'retry',
676
- attrs: {
677
- count: retryCount.toString(),
678
- id: node.attrs.id,
679
- t: node.attrs.t,
680
- v: '1',
681
- // ADD ERROR FIELD
682
- error: '0'
683
- }
684
- },
685
- {
686
- tag: 'registration',
687
- attrs: {},
688
- content: (0, Utils_1.encodeBigEndian)(authState.creds.registrationId)
689
- }
690
- ]
691
- }
692
- if (node.attrs.recipient) {
693
- receipt.attrs.recipient = node.attrs.recipient
694
- }
695
- if (node.attrs.participant) {
696
- receipt.attrs.participant = node.attrs.participant
697
- }
698
- if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
699
- const { update, preKeys } = await (0, Utils_1.getNextPreKeys)(authState, 1)
700
- const [keyId] = Object.keys(preKeys)
701
- const key = preKeys[+keyId]
702
- const content = receipt.content
703
- content.push({
704
- tag: 'keys',
705
- attrs: {},
706
- content: [
707
- { tag: 'type', attrs: {}, content: Buffer.from(Defaults_1.KEY_BUNDLE_TYPE) },
708
- { tag: 'identity', attrs: {}, content: identityKey.public },
709
- (0, Utils_1.xmppPreKey)(key, +keyId),
710
- (0, Utils_1.xmppSignedPreKey)(signedPreKey),
711
- { tag: 'device-identity', attrs: {}, content: deviceIdentity }
712
- ]
713
- })
714
- ev.emit('creds.update', update)
715
- }
716
- await sendNode(receipt)
717
- logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt')
718
- }, authState?.creds?.me?.id || 'sendRetryRequest')
719
- }
720
- /**
721
- * Fire-and-forget tctoken re-issuance after a peer's device identity changed.
722
- * Runs in parallel with the session refresh (not after it).
723
- */
724
- const reissueTcTokenAfterIdentityChange = from => {
725
- void (async () => {
726
- const normalizedJid = (0, WABinary_1.jidNormalizedUser)(from)
727
- const tcJid = await (0, tc_token_utils_1.resolveTcTokenJid)(normalizedJid, getLIDForPN)
728
- const tcTokenData = await authState.keys.get('tctoken', [tcJid])
729
- const senderTs = tcTokenData?.[tcJid]?.senderTimestamp
730
- if (senderTs === null || senderTs === undefined || (0, tc_token_utils_1.isTcTokenExpired)(senderTs)) {
731
- return
732
- }
733
- logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken')
734
- const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping)
735
- const issueJid = await (0, tc_token_utils_1.resolveIssuanceJid)(
736
- normalizedJid,
737
- sock.serverProps.lidTrustedTokenIssueToLid,
738
- getLIDForPN,
739
- getPNForLID
740
- )
741
- const result = await issuePrivacyTokens([issueJid], senderTs)
742
- await (0, tc_token_utils_1.storeTcTokensFromIqResult)({
743
- result,
744
- fallbackJid: tcJid,
745
- keys: authState.keys,
746
- getLIDForPN,
747
- onNewJidStored: trackTcTokenJid
748
- })
749
- })().catch(err => {
750
- logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change')
751
- })
752
- }
753
- const handleEncryptNotification = async node => {
754
- const from = node.attrs.from
755
- if (from === WABinary_1.S_WHATSAPP_NET) {
756
- const countChild = (0, WABinary_1.getBinaryNodeChild)(node, 'count')
757
- const count = +countChild.attrs.value
758
- const shouldUploadMorePreKeys = count < Defaults_1.MIN_PREKEY_COUNT
759
- logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count')
760
- if (shouldUploadMorePreKeys) {
761
- await uploadPreKeys()
762
- }
763
- } else {
764
- const result = await (0, Utils_1.handleIdentityChange)(node, {
765
- meId: authState.creds.me?.id,
766
- meLid: authState.creds.me?.lid,
767
- validateSession: signalRepository.validateSession,
768
- assertSessions,
769
- debounceCache: identityAssertDebounce,
770
- logger,
771
- onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
772
- })
773
- if (result.action === 'no_identity_node') {
774
- logger.info({ node }, 'unknown encrypt notification')
775
- }
776
- }
777
- }
778
- const handleGroupNotification = (fullNode, child, msg) => {
779
- // TODO: Support PN/LID (Here is only LID now)
780
- const actingParticipantLid = fullNode.attrs.participant
781
- const actingParticipantPn = fullNode.attrs.participant_pn
782
- const actingParticipantUsername = fullNode.attrs.participant_username
783
- const affectedParticipantLid =
784
- (0, WABinary_1.getBinaryNodeChild)(child, 'participant')?.attrs?.jid || actingParticipantLid
785
- const affectedParticipantPn =
786
- (0, WABinary_1.getBinaryNodeChild)(child, 'participant')?.attrs?.phone_number || actingParticipantPn
787
- switch (child?.tag) {
788
- case 'create':
789
- const metadata = (0, groups_1.extractGroupMetadata)(child)
790
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CREATE
791
- msg.messageStubParameters = [metadata.subject]
792
- msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn }
793
- ev.emit('chats.upsert', [
794
- {
795
- id: metadata.id,
796
- name: metadata.subject,
797
- conversationTimestamp: metadata.creation
798
- }
799
- ])
800
- ev.emit('groups.upsert', [
801
- {
802
- ...metadata,
803
- author: actingParticipantLid,
804
- authorPn: actingParticipantPn,
805
- authorUsername: actingParticipantUsername
806
- }
807
- ])
808
- break
809
- case 'ephemeral':
810
- case 'not_ephemeral':
811
- msg.message = {
812
- protocolMessage: {
813
- type: index_js_1.proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
814
- ephemeralExpiration: +(child.attrs.expiration || 0)
815
- }
816
- }
817
- break
818
- case 'modify':
819
- const oldNumber = (0, WABinary_1.getBinaryNodeChildren)(child, 'participant').map(p => p.attrs.jid)
820
- msg.messageStubParameters = oldNumber || []
821
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER
822
- break
823
- case 'promote':
824
- case 'demote':
825
- case 'remove':
826
- case 'add':
827
- case 'leave':
828
- const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`
829
- msg.messageStubType = Types_1.WAMessageStubType[stubType]
830
- const participants = (0, WABinary_1.getBinaryNodeChildren)(child, 'participant').map(({ attrs }) => {
831
- // TODO: Store LID MAPPINGS
832
- return {
833
- id: attrs.jid,
834
- phoneNumber:
835
- (0, WABinary_1.isLidUser)(attrs.jid) && (0, WABinary_1.isPnUser)(attrs.phone_number)
836
- ? attrs.phone_number
837
- : undefined,
838
- lid: (0, WABinary_1.isPnUser)(attrs.jid) && (0, WABinary_1.isLidUser)(attrs.lid) ? attrs.lid : undefined,
839
- username: attrs.participant_username || attrs.username || undefined,
840
- admin: attrs.type || null
841
- }
842
- })
843
- if (
844
- participants.length === 1 &&
845
- // if recv. "remove" message and sender removed themselves
846
- // mark as left
847
- ((0, WABinary_1.areJidsSameUser)(participants[0].id, actingParticipantLid) ||
848
- (0, WABinary_1.areJidsSameUser)(participants[0].id, actingParticipantPn)) &&
849
- child.tag === 'remove'
850
- ) {
851
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_PARTICIPANT_LEAVE
852
- }
853
- msg.messageStubParameters = participants.map(a => JSON.stringify(a))
854
- break
855
- case 'subject':
856
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_SUBJECT
857
- msg.messageStubParameters = [child.attrs.subject]
858
- break
859
- case 'description':
860
- const description = (0, WABinary_1.getBinaryNodeChild)(child, 'body')?.content?.toString()
861
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_DESCRIPTION
862
- msg.messageStubParameters = description ? [description] : undefined
863
- break
864
- case 'announcement':
865
- case 'not_announcement':
866
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_ANNOUNCE
867
- msg.messageStubParameters = [child.tag === 'announcement' ? 'on' : 'off']
868
- break
869
- case 'locked':
870
- case 'unlocked':
871
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_RESTRICT
872
- msg.messageStubParameters = [child.tag === 'locked' ? 'on' : 'off']
873
- break
874
- case 'invite':
875
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_INVITE_LINK
876
- msg.messageStubParameters = [child.attrs.code]
877
- break
878
- case 'member_add_mode':
879
- const addMode = child.content
880
- if (addMode) {
881
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_MEMBER_ADD_MODE
882
- msg.messageStubParameters = [addMode.toString()]
883
- }
884
- break
885
- case 'membership_approval_mode':
886
- const approvalMode = (0, WABinary_1.getBinaryNodeChild)(child, 'group_join')
887
- if (approvalMode) {
888
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE
889
- msg.messageStubParameters = [approvalMode.attrs.state]
890
- }
891
- break
892
- case 'created_membership_requests':
893
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
894
- msg.messageStubParameters = [
895
- JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
896
- 'created',
897
- child.attrs.request_method
898
- ]
899
- break
900
- case 'revoked_membership_requests':
901
- const isDenied = (0, WABinary_1.areJidsSameUser)(affectedParticipantLid, actingParticipantLid)
902
- // TODO: LIDMAPPING SUPPORT
903
- msg.messageStubType = Types_1.WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD
904
- msg.messageStubParameters = [
905
- JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
906
- isDenied ? 'revoked' : 'rejected'
907
- ]
908
- break
909
- }
910
- }
911
- const normalizeNotificationParticipant = async (jid, groupData) => {
912
- if (!jid || typeof jid !== 'string') {
913
- return jid
914
- }
915
- if (!(0, WABinary_1.isLidUser)(jid) && !(0, WABinary_1.isHostedLidUser)(jid)) {
916
- return jid
917
- }
918
- const normalized = await (0, jid_display_normalization_1.normalizeMentionedJidsForSend)(
919
- [jid],
920
- groupData,
921
- signalRepository,
922
- logger
923
- )
924
- return normalized?.[0] || jid
925
- }
926
- const normalizeNotificationParticipantsArray = async (participants, groupData) => {
927
- if (!Array.isArray(participants)) {
928
- return participants
929
- }
930
- return Promise.all(participants.map(jid => normalizeNotificationParticipant(jid, groupData)))
931
- }
932
- const getNotificationGroupData = async node => {
933
- const groupJid = (0, WABinary_1.jidNormalizedUser)(node?.attrs?.from)
934
- if (!(0, WABinary_1.isJidGroup)(groupJid)) {
935
- return undefined
936
- }
937
- try {
938
- return (
939
- (config.useCachedGroupMetadata && config.cachedGroupMetadata
940
- ? await config.cachedGroupMetadata(groupJid)
941
- : undefined) || (await sock.groupMetadata(groupJid))
942
- )
943
- } catch (error) {
944
- logger.debug({ error, groupJid }, 'failed to fetch group metadata for notification normalization')
945
- return undefined
946
- }
947
- }
948
- const normalizeNotificationStubParameters = async (stubParameters, groupData) => {
949
- if (!Array.isArray(stubParameters)) {
950
- return stubParameters
951
- }
952
- const normalized = []
953
- for (const entry of stubParameters) {
954
- if (typeof entry !== 'string') {
955
- normalized.push(entry)
956
- continue
957
- }
958
- if ((0, WABinary_1.isLidUser)(entry) || (0, WABinary_1.isHostedLidUser)(entry)) {
959
- normalized.push(await normalizeNotificationParticipant(entry, groupData))
960
- continue
961
- }
962
- if (entry.startsWith('{') && entry.includes('"id"')) {
963
- try {
964
- const parsed = JSON.parse(entry)
965
- const explicitPn =
966
- typeof parsed?.phoneNumber === 'string'
967
- ? parsed.phoneNumber
968
- : typeof parsed?.pn === 'string'
969
- ? parsed.pn
970
- : undefined
971
- if ((0, WABinary_1.isPnUser)(explicitPn) || (0, WABinary_1.isHostedPnUser)(explicitPn)) {
972
- parsed.id = explicitPn
973
- parsed.pn = explicitPn
974
- normalized.push(JSON.stringify(parsed))
975
- continue
976
- }
977
- if (parsed?.id) {
978
- parsed.id = await normalizeNotificationParticipant(parsed.id, groupData)
979
- }
980
- if (parsed?.lid && !parsed?.pn) {
981
- parsed.pn = await normalizeNotificationParticipant(parsed.lid, groupData)
982
- }
983
- normalized.push(JSON.stringify(parsed))
984
- continue
985
- } catch (err) {
986
- logger.debug({ err, entry }, 'failed to normalize stub parameter JSON')
987
- }
988
- }
989
- normalized.push(entry)
990
- }
991
- return normalized
992
- }
993
- const normalizeCallEventJids = async (call, infoChild) => {
994
- if (!call) {
995
- return call
996
- }
997
- const callContextGroupJid = call.groupJid || ((0, WABinary_1.isJidGroup)(call.chatId) ? call.chatId : undefined)
998
- let groupData
999
- if (callContextGroupJid) {
1000
- try {
1001
- groupData =
1002
- (config.useCachedGroupMetadata && config.cachedGroupMetadata
1003
- ? await config.cachedGroupMetadata(callContextGroupJid)
1004
- : undefined) || (await sock.groupMetadata(callContextGroupJid))
1005
- } catch (error) {
1006
- logger.debug({ error, groupJid: callContextGroupJid }, 'failed to fetch group metadata for call normalization')
1007
- }
1008
- }
1009
- if (call.chatId && !call.isGroup) {
1010
- call.chatId = await normalizeNotificationParticipant(call.chatId, groupData)
1011
- }
1012
- if (call.from) {
1013
- call.from = await normalizeNotificationParticipant(call.from, groupData)
1014
- }
1015
- if (call.groupJid) {
1016
- call.groupJid = await normalizeNotificationParticipant(call.groupJid, groupData)
1017
- }
1018
- if (!call.callerPn && infoChild?.attrs?.caller_lid) {
1019
- call.callerPn = await normalizeNotificationParticipant(infoChild.attrs.caller_lid, groupData)
1020
- }
1021
- if (!call.callerPn && call.from) {
1022
- call.callerPn = call.from
1023
- }
1024
- return call
1025
- }
1026
- const normalizeNotificationResult = async (node, result, groupData) => {
1027
- const groupJid = (0, WABinary_1.jidNormalizedUser)(node?.attrs?.from)
1028
- if (!(0, WABinary_1.isJidGroup)(groupJid)) {
1029
- return
1030
- }
1031
- if (result?.key?.participant) {
1032
- result.key.participant = await normalizeNotificationParticipant(result.key.participant, groupData)
1033
- }
1034
- if (result?.participant) {
1035
- result.participant = await normalizeNotificationParticipant(result.participant, groupData)
1036
- }
1037
- if (Array.isArray(result?.messageStubParameters)) {
1038
- result.messageStubParameters = await normalizeNotificationStubParameters(result.messageStubParameters, groupData)
1039
- }
1040
- }
1041
-
1042
- /**
1043
- * Handle incoming interop notifications (type="interop").
1044
- *
1045
- * The APK emits these for:
1046
- * - stella_interop_enabled / stella_ios_enabled → feature-flag toggles
1047
- * - ig_professional / ig_handle / followers → Instagram profile data updates
1048
- * - fbid:thread / fbid:devices → Meta thread/device association
1049
- * - peer_device_presence → interop contact online/offline
1050
- * - group membership changes in interop groups → add/remove/promote events
1051
- */
1052
- const handleInteropNotification = (node, child) => {
1053
- const childTag = child?.tag
1054
- const attrs = child?.attrs || {}
1055
-
1056
- // Feature flag: server toggled stella_interop_enabled or stella_ios_enabled
1057
- if (childTag === 'feature') {
1058
- const feature = attrs.name
1059
- const enabled = attrs.value === 'true' || attrs.value === '1'
1060
- logger.info({ feature, enabled }, '[interop] feature flag update')
1061
- ev.emit('interop.feature-update', { feature, enabled })
1062
- return
1063
- }
1064
-
1065
- // Instagram profile data pushed for an interop contact
1066
- if (childTag === 'ig_profile') {
1067
- const contactUpdate = {
1068
- id: (0, WABinary_1.jidNormalizedUser)(node.attrs.from),
1069
- ...(attrs.ig_handle ? { igHandle: attrs.ig_handle } : {}),
1070
- ...(attrs.ig_professional !== undefined ? { igProfessional: attrs.ig_professional === 'true' } : {}),
1071
- ...(attrs.followers !== undefined ? { igFollowers: parseInt(attrs.followers, 10) } : {})
1072
- }
1073
- logger.debug({ contactUpdate }, '[interop] ig_profile update')
1074
- ev.emit('contacts.update', [contactUpdate])
1075
- return
1076
- }
1077
-
1078
- // peer_device_presence — interop contact came online or went offline
1079
- if (childTag === 'peer_device_presence') {
1080
- const jid = attrs.jid || (0, WABinary_1.jidNormalizedUser)(node.attrs.from)
1081
- const presence = attrs.type === 'unavailable' ? 'unavailable' : 'available'
1082
- logger.debug({ jid, presence }, '[interop] peer_device_presence')
1083
- ev.emit('presence.update', { id: jid, presences: { [jid]: { lastKnownPresence: presence } } })
1084
- return
1085
- }
1086
-
1087
- // fbid:thread / fbid:devices — Meta thread or device list association
1088
- if (childTag === 'fbid_thread' || childTag === 'fbid_devices') {
1089
- logger.debug({ childTag, attrs, from: node.attrs.from }, '[interop] fbid association update')
1090
- ev.emit('interop.fbid-update', { type: childTag, jid: node.attrs.from, attrs })
1091
- return
1092
- }
1093
-
1094
- // Interop group membership changes (add / remove / promote / demote)
1095
- if (childTag === 'participants') {
1096
- const groupJid = node.attrs.from
1097
- const action = attrs.type // 'add' | 'remove' | 'promote' | 'demote'
1098
- const participants = (0, WABinary_1.getBinaryNodeChildren)(child, 'participant').map(p => p.attrs.jid)
1099
- logger.info({ groupJid, action, participants }, '[interop] group participants update')
1100
- ev.emit('group-participants.update', { id: groupJid, participants, action })
1101
- return
1102
- }
1103
-
1104
- logger.debug({ childTag, from: node.attrs.from }, '[interop] unhandled interop notification subtype')
1105
- }
1106
-
1107
- const processNotification = async node => {
1108
- const result = {}
1109
- const [child] = (0, WABinary_1.getAllBinaryNodeChildren)(node)
1110
- const nodeType = node.attrs.type
1111
- const from = (0, WABinary_1.jidNormalizedUser)(node.attrs.from)
1112
- switch (nodeType) {
1113
- case 'newsletter':
1114
- await handleNewsletterNotification(node)
1115
- break
1116
- case 'mex':
1117
- await handleMexNewsletterNotification(node)
1118
- break
1119
- case 'w:gp2':
1120
- // TODO: HANDLE PARTICIPANT_PN
1121
- const groupData = await getNotificationGroupData(node)
1122
- handleGroupNotification(node, child, result)
1123
-
1124
- await normalizeNotificationResult(node, result, groupData)
1125
- break
1126
- case 'mediaretry':
1127
- const event = (0, Utils_1.decodeMediaRetryNode)(node)
1128
- ev.emit('messages.media-update', [event])
1129
- break
1130
- case 'encrypt':
1131
- await handleEncryptNotification(node)
1132
- break
1133
- case 'devices':
1134
- const devices = (0, WABinary_1.getBinaryNodeChildren)(child, 'device')
1135
- const deviceOwnerJid = child.attrs.jid || child.attrs.lid
1136
- const deviceData = devices.map(d => ({
1137
- id: d.attrs.jid,
1138
- lid: d.attrs.lid,
1139
- keyIndex: d.attrs.key_index ? +d.attrs.key_index : undefined,
1140
- platform: d.attrs.platform || undefined,
1141
- isCompanion: d.attrs.companion === 'true' || undefined
1142
- }))
1143
- if (
1144
- (0, WABinary_1.areJidsSameUser)(child.attrs.jid, authState.creds.me.id) ||
1145
- (0, WABinary_1.areJidsSameUser)(child.attrs.lid, authState.creds.me.lid)
1146
- ) {
1147
- logger.info({ deviceData }, 'my own devices changed')
1148
- ev.emit('devices.update', { id: deviceOwnerJid, devices: deviceData, isSelf: true })
1149
- } else if (deviceOwnerJid) {
1150
- ev.emit('devices.update', { id: deviceOwnerJid, devices: deviceData, isSelf: false })
1151
- }
1152
- break
1153
- case 'server_sync':
1154
- const update = (0, WABinary_1.getBinaryNodeChild)(node, 'collection')
1155
- if (update) {
1156
- const name = update.attrs.name
1157
- await resyncAppState([name], false)
1158
- }
1159
- break
1160
- case 'picture':
1161
- const setPicture = (0, WABinary_1.getBinaryNodeChild)(node, 'set')
1162
- const delPicture = (0, WABinary_1.getBinaryNodeChild)(node, 'delete')
1163
- // TODO: WAJIDHASH stuff proper support inhouse
1164
- ev.emit('contacts.update', [
1165
- {
1166
- id: (0, WABinary_1.jidNormalizedUser)(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '',
1167
- imgUrl: setPicture ? 'changed' : 'removed'
1168
- }
1169
- ])
1170
- if ((0, WABinary_1.isJidGroup)(from)) {
1171
- const node = setPicture || delPicture
1172
- result.messageStubType = Types_1.WAMessageStubType.GROUP_CHANGE_ICON
1173
- if (setPicture) {
1174
- result.messageStubParameters = [setPicture.attrs.id]
1175
- }
1176
- result.participant = node?.attrs.author
1177
- result.key = {
1178
- ...(result.key || {}),
1179
- participant: setPicture?.attrs.author
1180
- }
1181
- }
1182
- break
1183
- case 'account_sync':
1184
- if (child.tag === 'disappearing_mode') {
1185
- const newDuration = +child.attrs.duration
1186
- const timestamp = +child.attrs.t
1187
- logger.info({ newDuration }, 'updated account disappearing mode')
1188
- ev.emit('creds.update', {
1189
- accountSettings: {
1190
- ...authState.creds.accountSettings,
1191
- defaultDisappearingMode: {
1192
- ephemeralExpiration: newDuration,
1193
- ephemeralSettingTimestamp: timestamp
1194
- }
1195
- }
1196
- })
1197
- } else if (child.tag === 'blocklist') {
1198
- const blocklists = (0, WABinary_1.getBinaryNodeChildren)(child, 'item')
1199
- for (const { attrs } of blocklists) {
1200
- const blocklist = [attrs.jid]
1201
- const type = attrs.action === 'block' ? 'add' : 'remove'
1202
- ev.emit('blocklist.update', { blocklist, type })
1203
- }
1204
- }
1205
- break
1206
- case 'business':
1207
- // SMB privacy / data-sharing settings sync push
1208
- // (WhatsApp Web: WASmaxInBizSettingsSyncPrivacySettingRequest)
1209
- if (child?.tag === 'privacy') {
1210
- ev.emit('business.privacy-settings-sync', {
1211
- jid: from,
1212
- categories: (0, WABinary_1.getBinaryNodeChildren)(child, 'category').map(c => ({
1213
- name: c.attrs.name,
1214
- value: c.attrs.value
1215
- })),
1216
- attrs: child.attrs
1217
- })
1218
- }
1219
- break
1220
- case 'hosted':
1221
- // Coexistence (WhatsApp <-> Messenger/Instagram) onboarding/offboarding push
1222
- // (WhatsApp Web: WASmaxInCoexistenceOnboarding/OffboardingNotification)
1223
- if (child?.tag === 'onboarding_status') {
1224
- ev.emit('coexistence.update', {
1225
- jid: from,
1226
- kind: 'onboarding',
1227
- status: child.attrs.status,
1228
- productSurface: child.attrs['product_surface']
1229
- })
1230
- } else if (child?.tag === 'offboarding') {
1231
- ev.emit('coexistence.update', {
1232
- jid: from,
1233
- kind: 'offboarding',
1234
- productSurface: child.attrs['product_surface']
1235
- })
1236
- }
1237
- break
1238
- case 'link_code_companion_reg':
1239
- const linkCodeCompanionReg = (0, WABinary_1.getBinaryNodeChild)(node, 'link_code_companion_reg')
1240
- const ref = toRequiredBuffer(
1241
- (0, WABinary_1.getBinaryNodeChildBuffer)(linkCodeCompanionReg, 'link_code_pairing_ref')
1242
- )
1243
- const primaryIdentityPublicKey = toRequiredBuffer(
1244
- (0, WABinary_1.getBinaryNodeChildBuffer)(linkCodeCompanionReg, 'primary_identity_pub')
1245
- )
1246
- const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(
1247
- (0, WABinary_1.getBinaryNodeChildBuffer)(
1248
- linkCodeCompanionReg,
1249
- 'link_code_pairing_wrapped_primary_ephemeral_pub'
1250
- )
1251
- )
1252
- const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped)
1253
- const companionSharedKey = Utils_1.Curve.sharedKey(
1254
- authState.creds.pairingEphemeralKeyPair.private,
1255
- codePairingPublicKey
1256
- )
1257
- const random = (0, crypto_1.randomBytes)(32)
1258
- const linkCodeSalt = (0, crypto_1.randomBytes)(32)
1259
- const linkCodePairingExpanded = (0, Utils_1.hkdf)(companionSharedKey, 32, {
1260
- salt: linkCodeSalt,
1261
- info: 'link_code_pairing_key_bundle_encryption_key'
1262
- })
1263
- const encryptPayload = Buffer.concat([
1264
- Buffer.from(authState.creds.signedIdentityKey.public),
1265
- primaryIdentityPublicKey,
1266
- random
1267
- ])
1268
- const encryptIv = (0, crypto_1.randomBytes)(12)
1269
- const encrypted = (0, Utils_1.aesEncryptGCM)(
1270
- encryptPayload,
1271
- linkCodePairingExpanded,
1272
- encryptIv,
1273
- Buffer.alloc(0)
1274
- )
1275
- const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted])
1276
- const identitySharedKey = Utils_1.Curve.sharedKey(
1277
- authState.creds.signedIdentityKey.private,
1278
- primaryIdentityPublicKey
1279
- )
1280
- const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random])
1281
- authState.creds.advSecretKey = Buffer.from(
1282
- (0, Utils_1.hkdf)(identityPayload, 32, { info: 'adv_secret' })
1283
- ).toString('base64')
1284
- await query({
1285
- tag: 'iq',
1286
- attrs: {
1287
- to: WABinary_1.S_WHATSAPP_NET,
1288
- type: 'set',
1289
- id: sock.generateMessageTag(),
1290
- xmlns: 'md'
1291
- },
1292
- content: [
1293
- {
1294
- tag: 'link_code_companion_reg',
1295
- attrs: {
1296
- jid: authState.creds.me.id,
1297
- stage: 'companion_finish'
1298
- },
1299
- content: [
1300
- {
1301
- tag: 'link_code_pairing_wrapped_key_bundle',
1302
- attrs: {},
1303
- content: encryptedPayload
1304
- },
1305
- {
1306
- tag: 'companion_identity_public',
1307
- attrs: {},
1308
- content: authState.creds.signedIdentityKey.public
1309
- },
1310
- {
1311
- tag: 'link_code_pairing_ref',
1312
- attrs: {},
1313
- content: ref
1314
- }
1315
- ]
1316
- }
1317
- ]
1318
- })
1319
- authState.creds.registered = true
1320
- ev.emit('creds.update', authState.creds)
1321
- break
1322
- case 'privacy_token':
1323
- await handlePrivacyTokenNotification(node)
1324
- break
1325
- case 'security':
1326
- // Security notifications (compromised session, location change alerts)
1327
- const securityType = child?.tag || node.attrs.type
1328
- const securityData = {
1329
- type: securityType,
1330
- jid: from,
1331
- timestamp: node.attrs.t ? +node.attrs.t : Math.floor(Date.now() / 1000),
1332
- details: child?.attrs || {}
1333
- }
1334
- logger.warn({ securityData }, 'received security notification')
1335
- ev.emit('security.alert', securityData)
1336
- break
1337
- case 'identity':
1338
- // Identity change notifications — peer changed their identity key
1339
- const identityJid = node.attrs.from
1340
- const identityNewKey = child?.content ? Buffer.from(child.content) : undefined
1341
- ev.emit('identity.update', {
1342
- jid: (0, WABinary_1.jidNormalizedUser)(identityJid),
1343
- newIdentityKey: identityNewKey,
1344
- timestamp: node.attrs.t ? +node.attrs.t : Math.floor(Date.now() / 1000)
1345
- })
1346
- break
1347
- case 'server':
1348
- // Server-issued notifications (config changes, client config refresh)
1349
- const serverTag = child?.tag
1350
- if (serverTag === 'config') {
1351
- const configData = {}
1352
- for (const attr of Object.keys(child?.attrs || {})) {
1353
- configData[attr] = child.attrs[attr]
1354
- }
1355
- ev.emit('server.config', configData)
1356
- } else if (serverTag === 'app_state_key') {
1357
- // Server pushed a new app-state key — trigger resync
1358
- logger.info('server pushed app state key update')
1359
- await resyncAppState(['critical_block', 'regular_high', 'regular_low'], false)
1360
- } else {
1361
- logger.debug({ node }, 'unhandled server notification')
1362
- }
1363
- break
1364
- case 'status':
1365
- // Contact status (about) change notification
1366
- const statusOwner = node.attrs.from
1367
- const statusText = child?.content ? child.content.toString() : undefined
1368
- if (statusOwner && statusText !== undefined) {
1369
- ev.emit('contacts.update', [
1370
- {
1371
- id: (0, WABinary_1.jidNormalizedUser)(statusOwner),
1372
- status: statusText
1373
- }
1374
- ])
1375
- }
1376
- break
1377
- case 'usync':
1378
- // USync result push from server
1379
- const usyncResults = (0, WABinary_1.getBinaryNodeChildren)(child || node, 'user')
1380
- if (usyncResults.length) {
1381
- const updates = usyncResults
1382
- .map(u => ({
1383
- id: (0, WABinary_1.jidNormalizedUser)(u.attrs.jid),
1384
- ...(u.attrs.lid ? { lid: u.attrs.lid } : {}),
1385
- ...(u.attrs.username ? { username: u.attrs.username } : {}),
1386
- ...(u.attrs.status ? { status: u.attrs.status } : {})
1387
- }))
1388
- .filter(u => u.id)
1389
- if (updates.length) {
1390
- ev.emit('contacts.update', updates)
1391
- }
1392
- }
1393
- break
1394
- case 'interop':
1395
- // Interop-related server notifications — covers:
1396
- // stella_interop_enabled / stella_ios_enabled feature flags
1397
- // ig_professional / ig_handle / followers (Instagram account data)
1398
- // fbid:thread / fbid:devices (Meta thread/device references)
1399
- // peer_device_presence updates
1400
- // group membership changes in interop groups
1401
- handleInteropNotification(node, child)
1402
- break
1403
- }
1404
- if (Object.keys(result).length) {
1405
- return result
1406
- }
1407
- }
1408
- /**
1409
- * In-memory cache of storage JIDs with stored tctokens, seeded from the persisted index.
1410
- * Used to coalesce writes during a session; pruning always re-reads the persisted index.
1411
- */
1412
- const tcTokenKnownJids = new Set()
1413
- const tcTokenIndexLoaded = (async () => {
1414
- try {
1415
- const jids = await (0, tc_token_utils_1.readTcTokenIndex)(authState.keys)
1416
- for (const jid of jids) tcTokenKnownJids.add(jid)
1417
- logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index')
1418
- } catch (err) {
1419
- logger.warn({ err: err?.message }, 'failed to load tctoken index')
1420
- }
1421
- })()
1422
- let tcTokenIndexTimer
1423
- async function flushTcTokenIndex() {
1424
- if (tcTokenIndexTimer) {
1425
- clearTimeout(tcTokenIndexTimer)
1426
- tcTokenIndexTimer = undefined
1427
- }
1428
- const write = await (0, tc_token_utils_1.buildMergedTcTokenIndexWrite)(authState.keys, tcTokenKnownJids)
1429
- return authState.keys.set({ tctoken: write })
1430
- }
1431
- function scheduleTcTokenIndexSave() {
1432
- if (tcTokenIndexTimer) {
1433
- clearTimeout(tcTokenIndexTimer)
1434
- }
1435
- tcTokenIndexTimer = setTimeout(() => {
1436
- tcTokenIndexTimer = undefined
1437
- flushTcTokenIndex().catch(err => {
1438
- logger.warn({ err: err?.message }, 'failed to save tctoken index')
1439
- })
1440
- }, 5000)
1441
- }
1442
- function trackTcTokenJid(jid) {
1443
- if (jid && jid !== tc_token_utils_1.TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) {
1444
- tcTokenKnownJids.add(jid)
1445
- scheduleTcTokenIndexSave()
1446
- }
1447
- }
1448
- const handlePrivacyTokenNotification = async node => {
1449
- const tokensNode = (0, WABinary_1.getBinaryNodeChild)(node, 'tokens')
1450
- if (!tokensNode) return
1451
- const from = (0, WABinary_1.jidNormalizedUser)(node.attrs.from)
1452
- // WA Web uses: senderLid ?? toLid(from) for the storage key
1453
- const senderLid =
1454
- node.attrs.sender_lid && (0, WABinary_1.isLidUser)((0, WABinary_1.jidNormalizedUser)(node.attrs.sender_lid))
1455
- ? (0, WABinary_1.jidNormalizedUser)(node.attrs.sender_lid)
1456
- : undefined
1457
- const fallbackJid = senderLid ?? (await (0, tc_token_utils_1.resolveTcTokenJid)(from, getLIDForPN))
1458
- logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification')
1459
- await (0, tc_token_utils_1.storeTcTokensFromIqResult)({
1460
- result: node,
1461
- fallbackJid,
1462
- keys: authState.keys,
1463
- getLIDForPN,
1464
- onNewJidStored: trackTcTokenJid
1465
- })
1466
- }
1467
- async function decipherLinkPublicKey(data) {
1468
- const buffer = toRequiredBuffer(data)
1469
- const salt = buffer.slice(0, 32)
1470
- const secretKey = await (0, Utils_1.derivePairingCodeKey)(authState.creds.pairingCode, salt)
1471
- const iv = buffer.slice(32, 48)
1472
- const payload = buffer.slice(48, 80)
1473
- return (0, Utils_1.aesDecryptCTR)(payload, secretKey, iv)
1474
- }
1475
- function toRequiredBuffer(data) {
1476
- if (data === undefined) {
1477
- throw new boom_1.Boom('Invalid buffer', { statusCode: 400 })
1478
- }
1479
- return data instanceof Buffer ? data : Buffer.from(data)
1480
- }
1481
- const willSendMessageAgain = async (id, participant) => {
1482
- const key = `${id}:${participant}`
1483
- const retryCount = (await msgRetryCache.get(key)) || 0
1484
- return retryCount < maxMsgRetryCount
1485
- }
1486
- const updateSendMessageAgainCount = async (id, participant) => {
1487
- const key = `${id}:${participant}`
1488
- const newValue = ((await msgRetryCache.get(key)) || 0) + 1
1489
- await msgRetryCache.set(key, newValue)
1490
- }
1491
- const sendMessagesAgain = async (key, ids, retryNode, receiptNode) => {
1492
- const remoteJid = key.remoteJid
1493
- const participant = key.participant || remoteJid
1494
- const retryCount = +retryNode.attrs.count || 1
1495
- const msgId = ids[0]
1496
- // Try to get messages from cache first, then fallback to getMessage
1497
- const msgs = []
1498
- for (const id of ids) {
1499
- let msg
1500
- // Try to get from retry cache first if enabled
1501
- if (messageRetryManager) {
1502
- const cachedMsg = messageRetryManager.getRecentMessage(remoteJid, id)
1503
- if (cachedMsg) {
1504
- msg = cachedMsg.message
1505
- logger.debug({ jid: remoteJid, id }, 'found message in retry cache')
1506
- // Mark retry as successful since we found the message
1507
- messageRetryManager.markRetrySuccess(id)
1508
- }
1509
- }
1510
- // Fallback to getMessage if not found in cache
1511
- if (!msg) {
1512
- msg = await getMessage({ ...key, id })
1513
- if (msg) {
1514
- logger.debug({ jid: remoteJid, id }, 'found message via getMessage')
1515
- // Also mark as successful if found via getMessage
1516
- if (messageRetryManager) {
1517
- messageRetryManager.markRetrySuccess(id)
1518
- }
1519
- }
1520
- }
1521
- msgs.push(msg)
1522
- }
1523
- // if it's the primary jid sending the request
1524
- // just re-send the message to everyone
1525
- // prevents the first message decryption failure
1526
- const sendToAll = !(0, WABinary_1.jidDecode)(participant)?.device
1527
- const sessionId = signalRepository.jidToSignalProtocolAddress(participant)
1528
- let injectedFromBundle = false
1529
- if (typeof signalRepository.injectE2ESession === 'function' && typeof Utils_1.extractE2ESessionFromRetryReceipt === 'function') {
1530
- const bundle = Utils_1.extractE2ESessionFromRetryReceipt(receiptNode)
1531
- if (bundle) {
1532
- try {
1533
- await signalRepository.injectE2ESession({ jid: participant, session: bundle })
1534
- injectedFromBundle = true
1535
- logger.debug({ participant, retryCount }, 'injected session from retry receipt key bundle')
1536
- } catch (error) {
1537
- logger.warn({ error, participant }, 'failed to inject session from retry receipt')
1538
- }
1539
- }
1540
- }
1541
- if (!injectedFromBundle && signalRepository.getSessionInfo) {
1542
- const receivedRegId = (0, WABinary_1.getBinaryNodeChildUInt)(receiptNode, 'registration', 4)
1543
- if (typeof receivedRegId === 'number' && Number.isInteger(receivedRegId)) {
1544
- const info = await signalRepository.getSessionInfo(participant)
1545
- if (info && info.registrationId !== 0 && info.registrationId !== receivedRegId) {
1546
- logger.info(
1547
- { participant, stored: info.registrationId, received: receivedRegId },
1548
- 'reg id mismatch on retry without bundle, deleting session'
1549
- )
1550
- await authState.keys.set({ session: { [sessionId]: null } })
1551
- }
1552
- }
1553
- }
1554
- const BASE_KEY_CHECK_RETRY = 2
1555
- if (msgId && messageRetryManager && signalRepository.getSessionInfo) {
1556
- const info = await signalRepository.getSessionInfo(participant)
1557
- if (info) {
1558
- if (retryCount === BASE_KEY_CHECK_RETRY) {
1559
- messageRetryManager.saveBaseKey(sessionId, msgId, info.baseKey)
1560
- } else if (retryCount > BASE_KEY_CHECK_RETRY) {
1561
- if (messageRetryManager.hasSameBaseKey(sessionId, msgId, info.baseKey)) {
1562
- logger.warn({ participant, retryCount }, 'base key collision on retry, forcing fresh session')
1563
- await authState.keys.set({ session: { [sessionId]: null } })
1564
- }
1565
- messageRetryManager.deleteBaseKey(sessionId, msgId)
1566
- }
1567
- }
1568
- }
1569
- // Check if we should recreate session for this retry
1570
- let shouldRecreateSession = false
1571
- let recreateReason = ''
1572
- if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1 && !injectedFromBundle) {
1573
- try {
1574
- const hasSession = await signalRepository.validateSession(participant)
1575
- const result = messageRetryManager.shouldRecreateSession(participant, hasSession.exists)
1576
- shouldRecreateSession = result.recreate
1577
- recreateReason = result.reason
1578
- if (shouldRecreateSession) {
1579
- logger.debug({ participant, retryCount, reason: recreateReason }, 'recreating session for outgoing retry')
1580
- await authState.keys.set({ session: { [sessionId]: null } })
1581
- }
1582
- } catch (error) {
1583
- logger.warn({ error, participant }, 'failed to check session recreation for outgoing retry')
1584
- }
1585
- }
1586
- if (!injectedFromBundle) {
1587
- await assertSessions([participant], true)
1588
- }
1589
- if ((0, WABinary_1.isJidGroup)(remoteJid)) {
1590
- await authState.keys.set({ 'sender-key-memory': { [remoteJid]: null } })
1591
- }
1592
- logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason }, 'forced new session for retry recp')
1593
- for (const [i, msg] of msgs.entries()) {
1594
- if (!ids[i]) continue
1595
- if (msg && (await willSendMessageAgain(ids[i], participant))) {
1596
- await updateSendMessageAgainCount(ids[i], participant)
1597
- const msgRelayOpts = { messageId: ids[i] }
1598
- if (sendToAll) {
1599
- msgRelayOpts.useUserDevicesCache = false
1600
- } else {
1601
- msgRelayOpts.participant = {
1602
- jid: participant,
1603
- count: +retryNode.attrs.count
1604
- }
1605
- }
1606
- await relayMessage(key.remoteJid, msg, msgRelayOpts)
1607
- } else {
1608
- logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available')
1609
- }
1610
- }
1611
- }
1612
- const handleReceipt = async node => {
1613
- const { attrs, content } = node
1614
- const isLid = attrs.from.includes('lid')
1615
- const isNodeFromMe = (0, WABinary_1.areJidsSameUser)(
1616
- attrs.participant || attrs.from,
1617
- isLid ? authState.creds.me?.lid : authState.creds.me?.id
1618
- )
1619
- const remoteJid = !isNodeFromMe || (0, WABinary_1.isJidGroup)(attrs.from) ? attrs.from : attrs.recipient
1620
- const fromMe = !attrs.recipient || ((attrs.type === 'retry' || attrs.type === 'sender') && isNodeFromMe)
1621
- const key = {
1622
- remoteJid,
1623
- id: '',
1624
- fromMe,
1625
- participant: attrs.participant
1626
- }
1627
- if (shouldIgnoreJid(remoteJid) && remoteJid !== WABinary_1.S_WHATSAPP_NET) {
1628
- logger.debug({ remoteJid }, 'ignoring receipt from jid')
1629
- await sendMessageAck(node)
1630
- return
1631
- }
1632
- const ids = [attrs.id]
1633
- if (Array.isArray(content)) {
1634
- const items = (0, WABinary_1.getBinaryNodeChildren)(content[0], 'item')
1635
- ids.push(...items.map(i => i.attrs.id))
1636
- }
1637
- try {
1638
- await Promise.all([
1639
- receiptMutex.mutex(async () => {
1640
- const status = (0, Utils_1.getStatusFromReceiptType)(attrs.type)
1641
- if (
1642
- typeof status !== 'undefined' &&
1643
- // basically, we only want to know when a message from us has been delivered to/read by the other person
1644
- // or another device of ours has read some messages
1645
- (status >= index_js_1.proto.WebMessageInfo.Status.SERVER_ACK || !isNodeFromMe)
1646
- ) {
1647
- if ((0, WABinary_1.isJidGroup)(remoteJid) || (0, WABinary_1.isJidStatusBroadcast)(remoteJid)) {
1648
- if (attrs.participant) {
1649
- const updateKey =
1650
- status === index_js_1.proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp'
1651
- ev.emit(
1652
- 'message-receipt.update',
1653
- ids.map(id => ({
1654
- key: { ...key, id },
1655
- receipt: {
1656
- userJid: (0, WABinary_1.jidNormalizedUser)(attrs.participant),
1657
- [updateKey]: +attrs.t
1658
- }
1659
- }))
1660
- )
1661
- }
1662
- } else {
1663
- ev.emit(
1664
- 'messages.update',
1665
- ids.map(id => ({
1666
- key: { ...key, id },
1667
- update: { status, messageTimestamp: (0, Utils_1.toNumber)(+(attrs.t ?? 0)) }
1668
- }))
1669
- )
1670
- }
1671
- }
1672
- if (attrs.type === 'retry') {
1673
- // correctly set who is asking for the retry
1674
- key.participant = key.participant || attrs.from
1675
- const retryNode = (0, WABinary_1.getBinaryNodeChild)(node, 'retry')
1676
- if (ids[0] && key.participant && (await willSendMessageAgain(ids[0], key.participant))) {
1677
- if (key.fromMe) {
1678
- try {
1679
- await updateSendMessageAgainCount(ids[0], key.participant)
1680
- logger.debug({ attrs, key }, 'recv retry request')
1681
- await sendMessagesAgain(key, ids, retryNode, node)
1682
- } catch (error) {
1683
- logger.error(
1684
- { key, ids, trace: error instanceof Error ? error.stack : 'Unknown error' },
1685
- 'error in sending message again'
1686
- )
1687
- }
1688
- } else {
1689
- logger.info({ attrs, key }, 'recv retry for not fromMe message')
1690
- }
1691
- } else {
1692
- logger.info({ attrs, key }, 'will not send message again, as sent too many times')
1693
- }
1694
- }
1695
- })
1696
- ])
1697
- } finally {
1698
- await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack receipt'))
1699
- }
1700
- }
1701
- const handleNotification = async node => {
1702
- const remoteJid = node.attrs.from
1703
- if (shouldIgnoreJid(remoteJid) && remoteJid !== WABinary_1.S_WHATSAPP_NET) {
1704
- logger.debug({ remoteJid, id: node.attrs.id }, 'ignored notification')
1705
- await sendMessageAck(node)
1706
- return
1707
- }
1708
- try {
1709
- await Promise.all([
1710
- notificationMutex.mutex(async () => {
1711
- const msg = await processNotification(node)
1712
- if (msg) {
1713
- const fromMe = (0, WABinary_1.areJidsSameUser)(node.attrs.participant || remoteJid, authState.creds.me.id)
1714
- const { senderAlt: participantAlt, addressingMode } = (0, Utils_1.extractAddressingContext)(node)
1715
- msg.key = {
1716
- remoteJid,
1717
- fromMe,
1718
- participant: node.attrs.participant,
1719
- participantAlt,
1720
- participantUsername: node.attrs.participant_username,
1721
- addressingMode,
1722
- id: node.attrs.id,
1723
- ...(msg.key || {})
1724
- }
1725
- msg.participant ?? (msg.participant = node.attrs.participant)
1726
- msg.messageTimestamp = +node.attrs.t
1727
- let groupDataForNormalization
1728
- if ((0, WABinary_1.isJidGroup)(msg?.key?.remoteJid)) {
1729
- try {
1730
- groupDataForNormalization =
1731
- (config.useCachedGroupMetadata && config.cachedGroupMetadata
1732
- ? await config.cachedGroupMetadata(msg.key.remoteJid)
1733
- : undefined) || (await sock.groupMetadata(msg.key.remoteJid))
1734
- } catch (error) {
1735
- logger.debug(
1736
- { error, jid: msg.key.remoteJid },
1737
- 'failed to fetch group metadata for recv jid normalization'
1738
- )
1739
- }
1740
- }
1741
- await (0, jid_display_normalization_1.normalizeMessageForDisplayJids)(
1742
- msg,
1743
- signalRepository,
1744
- logger,
1745
- groupDataForNormalization
1746
- )
1747
- const fullMsg = index_js_1.proto.WebMessageInfo.fromObject(msg)
1748
- await upsertMessage(fullMsg, 'append')
1749
- }
1750
- })
1751
- ])
1752
- } finally {
1753
- await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack notification'))
1754
- }
1755
- }
1756
- const handleMessage = async node => {
1757
- const isInteropNode = (0, WABinary_1.isInteropUser)(node.attrs.from)
1758
- if (isInteropNode) {
1759
- logger.info(
1760
- {
1761
- from: node.attrs.from,
1762
- id: node.attrs.id,
1763
- type: node.attrs.type,
1764
- sts: node.attrs.sts,
1765
- display_name: node.attrs.display_name,
1766
- encType: (0, WABinary_1.getBinaryNodeChild)(node, 'enc')?.attrs?.type
1767
- },
1768
- '[interop] node arrived'
1769
- )
1770
- }
1771
- if (shouldIgnoreJid(node.attrs.from) && node.attrs.from !== WABinary_1.S_WHATSAPP_NET) {
1772
- if (isInteropNode) logger.warn({ from: node.attrs.from }, '[interop] node dropped by shouldIgnoreJid')
1773
- logger.debug({ key: node.attrs.key }, 'ignored message')
1774
- await sendMessageAck(node, Utils_1.NACK_REASONS.UnhandledError)
1775
- return
1776
- }
1777
- const groupJid = node.attrs.from
1778
- const communityJid = linkedParentMap[groupJid]
1779
- const encNode = (0, WABinary_1.getBinaryNodeChild)(node, 'enc')
1780
- // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
1781
- if (encNode?.attrs.type === 'msmsg') {
1782
- // await sendMessageAck(node, Utils_1.NACK_REASONS.MissingMessageSecret)
1783
- // return
1784
- // Pre-populate botMessageSecrets from store so msmsg can be decrypted after restart
1785
- if (getMessage) {
1786
- const metaNode = (0, WABinary_1.getBinaryNodeChild)(node, 'meta')
1787
- const targetId = metaNode?.attrs?.target_id
1788
- if (targetId) {
1789
- try {
1790
- const targetMsg = await getMessage({ remoteJid: node.attrs.from, id: targetId, fromMe: true })
1791
- const secret = targetMsg?.messageContextInfo?.messageSecret
1792
- if (secret) {
1793
- ;(0, Utils_1.setBotMessageSecret)(targetId, secret)
1794
- }
1795
- } catch (err) {
1796
- logger.debug({ err, targetId }, 'failed to retrieve message secret for msmsg')
1797
- }
1798
- }
1799
- }
1800
- }
1801
- let acked = false
1802
- try {
1803
- const {
1804
- fullMessage: msg,
1805
- category,
1806
- author,
1807
- decrypt
1808
- } = (0, Utils_1.decryptMessageNode)(
1809
- node,
1810
- authState.creds.me.id,
1811
- authState.creds.me.lid || '',
1812
- signalRepository,
1813
- logger
1814
- )
1815
- if (isInteropNode) {
1816
- logger.info(
1817
- { remoteJid: msg.key.remoteJid, id: msg.key.id, fromMe: msg.key.fromMe, pushName: msg.pushName },
1818
- '[interop] decodeMessageNode OK'
1819
- )
1820
- }
1821
- const alt = msg.key.participantAlt || msg.key.remoteJidAlt
1822
- // store new mappings we didn't have before
1823
- if (!!alt) {
1824
- const altServer = (0, WABinary_1.jidDecode)(alt)?.server
1825
- const primaryJid = msg.key.participant || msg.key.remoteJid
1826
- if (altServer === 'lid') {
1827
- if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
1828
- await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }])
1829
- await signalRepository.migrateSession(primaryJid, alt)
1830
- }
1831
- } else {
1832
- await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }])
1833
- await signalRepository.migrateSession(alt, primaryJid)
1834
- }
1835
- }
1836
- await messageMutex.mutex(async () => {
1837
- await decrypt()
1838
- if (isInteropNode) {
1839
- const stubType = msg.messageStubType
1840
- const stubText = msg.messageStubParameters?.[0]
1841
- logger.info(
1842
- {
1843
- id: msg.key.id,
1844
- hasMessage: !!msg.message,
1845
- messageKeys: msg.message ? Object.keys(msg.message) : [],
1846
- stubType,
1847
- stubText
1848
- },
1849
- '[interop] decrypt() done'
1850
- )
1851
- }
1852
- if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) {
1853
- messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message)
1854
- }
1855
- // message failed to decrypt
1856
- if (msg.messageStubType === index_js_1.proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
1857
- if (msg?.messageStubParameters?.[0] === Utils_1.MISSING_KEYS_ERROR_TEXT) {
1858
- if (isInteropNode) logger.warn({ id: msg.key.id }, '[interop] decrypt failed: MISSING_KEYS')
1859
- acked = true
1860
- return sendMessageAck(node, Utils_1.NACK_REASONS.ParsingError)
1861
- }
1862
- if (msg.messageStubParameters?.[0] === Utils_1.NO_MESSAGE_FOUND_ERROR_TEXT) {
1863
- // Message arrived without encryption (e.g. CTWA ads messages).
1864
- // Check if this is eligible for placeholder resend (matching WA Web filters).
1865
- const unavailableNode = (0, WABinary_1.getBinaryNodeChild)(node, 'unavailable')
1866
- const unavailableType = unavailableNode?.attrs?.type
1867
- if (
1868
- unavailableType === 'bot_unavailable_fanout' ||
1869
- unavailableType === 'hosted_unavailable_fanout' ||
1870
- unavailableType === 'view_once_unavailable_fanout'
1871
- ) {
1872
- logger.debug(
1873
- { msgId: msg.key.id, unavailableType },
1874
- 'skipping placeholder resend for excluded unavailable type'
1875
- )
1876
- acked = true
1877
- return sendMessageAck(node)
1878
- }
1879
- const messageAge = (0, Utils_1.unixTimestampSeconds)() - (0, Utils_1.toNumber)(msg.messageTimestamp)
1880
- if (messageAge > Defaults_1.PLACEHOLDER_MAX_AGE_SECONDS) {
1881
- logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message')
1882
- acked = true
1883
- return sendMessageAck(node)
1884
- }
1885
- // Request the real content from the phone via placeholder resend PDO.
1886
- // Upsert the CIPHERTEXT stub as a placeholder (like WA Web's processPlaceholderMsg),
1887
- // and store the requestId in stubParameters[1] so users can correlate
1888
- // with the incoming PDO response event.
1889
- const cleanKey = {
1890
- remoteJid: msg.key.remoteJid,
1891
- fromMe: msg.key.fromMe,
1892
- id: msg.key.id,
1893
- participant: msg.key.participant
1894
- }
1895
- // Cache the original message metadata so the PDO response handler
1896
- // can preserve key fields (LID details etc.) that the phone may omit
1897
- const msgData = {
1898
- key: msg.key,
1899
- messageTimestamp: msg.messageTimestamp,
1900
- pushName: msg.pushName,
1901
- participant: msg.participant,
1902
- verifiedBizName: msg.verifiedBizName
1903
- }
1904
- requestPlaceholderResend(cleanKey, msgData)
1905
- .then(requestId => {
1906
- if (requestId && requestId !== 'RESOLVED') {
1907
- logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message')
1908
- ev.emit('messages.update', [
1909
- {
1910
- key: msg.key,
1911
- update: { messageStubParameters: [Utils_1.NO_MESSAGE_FOUND_ERROR_TEXT, requestId] }
1912
- }
1913
- ])
1914
- }
1915
- })
1916
- .catch(err => {
1917
- logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message')
1918
- })
1919
- acked = true
1920
- await sendMessageAck(node)
1921
- // Don't return — fall through to upsertMessage so the stub is emitted
1922
- } else {
1923
- // Skip retry for expired status messages (>24h old)
1924
- if ((0, WABinary_1.isJidStatusBroadcast)(msg.key.remoteJid)) {
1925
- const messageAge = (0, Utils_1.unixTimestampSeconds)() - (0, Utils_1.toNumber)(msg.messageTimestamp)
1926
- if (messageAge > Defaults_1.STATUS_EXPIRY_SECONDS) {
1927
- logger.debug(
1928
- { msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid },
1929
- 'skipping retry for expired status message'
1930
- )
1931
- acked = true
1932
- return sendMessageAck(node)
1933
- }
1934
- }
1935
- const errorMessage = msg?.messageStubParameters?.[0] || ''
1936
- const isPreKeyError = errorMessage.includes('PreKey')
1937
- logger.debug(`[handleMessage] Attempting retry request for failed decryption`)
1938
- // Handle both pre-key and normal retries in single mutex
1939
- await retryMutex.mutex(async () => {
1940
- try {
1941
- if (!ws.isOpen) {
1942
- logger.debug({ node }, 'Connection closed, skipping retry')
1943
- return
1944
- }
1945
- // Handle pre-key errors with upload and delay
1946
- if (isPreKeyError) {
1947
- logger.info({ error: errorMessage }, 'PreKey error detected, uploading and retrying')
1948
- try {
1949
- logger.debug('Uploading pre-keys for error recovery')
1950
- await uploadPreKeys(5)
1951
- logger.debug('Waiting for server to process new pre-keys')
1952
- await (0, Utils_1.delay)(1000)
1953
- } catch (uploadErr) {
1954
- logger.error({ uploadErr }, 'Pre-key upload failed, proceeding with retry anyway')
1955
- }
1956
- }
1957
- const encNode = (0, WABinary_1.getBinaryNodeChild)(node, 'enc')
1958
- await sendRetryRequest(node, !encNode)
1959
- if (retryRequestDelayMs) {
1960
- await (0, Utils_1.delay)(retryRequestDelayMs)
1961
- }
1962
- } catch (err) {
1963
- logger.error({ err, isPreKeyError }, 'Failed to handle retry, attempting basic retry')
1964
- // Still attempt retry even if pre-key upload failed
1965
- try {
1966
- const encNode = (0, WABinary_1.getBinaryNodeChild)(node, 'enc')
1967
- await sendRetryRequest(node, !encNode)
1968
- } catch (retryErr) {
1969
- logger.error({ retryErr }, 'Failed to send retry after error handling')
1970
- }
1971
- }
1972
- acked = true
1973
- await sendMessageAck(node, Utils_1.NACK_REASONS.UnhandledError)
1974
- })
1975
- }
1976
- } else {
1977
- if (messageRetryManager && msg.key.id) {
1978
- messageRetryManager.cancelPendingPhoneRequest(msg.key.id)
1979
- }
1980
- const isNewsletter = (0, WABinary_1.isJidNewsletter)(msg.key.remoteJid)
1981
- if (!isNewsletter) {
1982
- // no type in the receipt => message delivered
1983
- let type = undefined
1984
- let participant = msg.key.participant
1985
- if (communityJid) {
1986
- msg.communityJid = communityJid
1987
- }
1988
- if (category === 'peer') {
1989
- // special peer message
1990
- type = 'peer_msg'
1991
- } else if (msg.key.fromMe) {
1992
- // message was sent by us from a different device
1993
- type = 'sender'
1994
- // need to specially handle this case
1995
- if ((0, WABinary_1.isLidUser)(msg.key.remoteJid) || (0, WABinary_1.isLidUser)(msg.key.remoteJidAlt)) {
1996
- participant = author // TODO: investigate sending receipts to LIDs and not PNs
1997
- }
1998
- } else if (!sendActiveReceipts) {
1999
- type = 'inactive'
2000
- }
2001
- acked = true
2002
- // Pass sts from the original stanza for interop contacts (BirdyChat/Haiket)
2003
- const interopSts = (0, WABinary_1.isInteropUser)(msg.key.remoteJid) ? node.attrs.sts : undefined
2004
- await sendReceipt(msg.key.remoteJid, participant, [msg.key.id], type, interopSts)
2005
- // send ack for history message
2006
- const isAnyHistoryMsg = (0, Utils_1.getHistoryMsg)(msg.message)
2007
- if (isAnyHistoryMsg) {
2008
- const jid = (0, WABinary_1.jidNormalizedUser)(msg.key.remoteJid)
2009
- await sendReceipt(jid, undefined, [msg.key.id], 'hist_sync') // TODO: investigate
2010
- }
2011
- } else {
2012
- acked = true
2013
- await sendMessageAck(node)
2014
- logger.debug({ key: msg.key }, 'processed newsletter message without receipts')
2015
- }
2016
- }
2017
- ;(0, Utils_1.cleanMessage)(msg, authState.creds.me.id, authState.creds.me.lid)
2018
- const msgTs = (0, Utils_1.toNumber)(msg.messageTimestamp)
2019
- const isPending = !isConnected || node.attrs.offline || msgTs < socketCreatedAt
2020
- if (isInteropNode) {
2021
- logger.info(
2022
- {
2023
- id: msg.key.id,
2024
- isPending,
2025
- isConnected,
2026
- offline: node.attrs.offline,
2027
- msgTs,
2028
- socketCreatedAt
2029
- },
2030
- isPending ? '[interop] upsert as append (pending/offline)' : '[interop] upsert as notify'
2031
- )
2032
- }
2033
- if (isPending) {
2034
- await upsertMessage(msg, 'append')
2035
- return
2036
- }
2037
- let groupDataForNormalization
2038
- if ((0, WABinary_1.isJidGroup)(msg?.key?.remoteJid)) {
2039
- try {
2040
- groupDataForNormalization =
2041
- (config.useCachedGroupMetadata && config.cachedGroupMetadata
2042
- ? await config.cachedGroupMetadata(msg.key.remoteJid)
2043
- : undefined) || (await sock.groupMetadata(msg.key.remoteJid))
2044
- } catch (error) {
2045
- logger.debug({ error, jid: msg.key.remoteJid }, 'failed to fetch group metadata for recv jid normalization')
2046
- }
2047
- }
2048
- await (0, jid_display_normalization_1.normalizeMessageForDisplayJids)(
2049
- msg,
2050
- signalRepository,
2051
- logger,
2052
- groupDataForNormalization
2053
- )
2054
- await upsertMessage(msg, 'notify')
2055
- })
2056
- } catch (error) {
2057
- if (isInteropNode) {
2058
- logger.error(
2059
- { error: error?.message, stack: error?.stack, from: node.attrs.from, id: node.attrs.id },
2060
- '[interop] unhandled error in handleMessage'
2061
- )
2062
- }
2063
- logger.error({ error, node: (0, WABinary_1.binaryNodeToString)(node) }, 'error in handling message')
2064
- if (!acked) {
2065
- await sendMessageAck(node, Utils_1.NACK_REASONS.UnhandledError).catch(ackErr =>
2066
- logger.error({ ackErr }, 'failed to ack message after error')
2067
- )
2068
- }
2069
- }
2070
- }
2071
- const handleCall = async node => {
2072
- try {
2073
- const { attrs } = node
2074
- const [infoChild] = (0, WABinary_1.getAllBinaryNodeChildren)(node)
2075
- if (!infoChild) {
2076
- throw new boom_1.Boom('Missing call info in call node')
2077
- }
2078
- const status = (0, Utils_1.getCallStatusFromNode)(infoChild)
2079
- const callId = infoChild.attrs['call-id']
2080
- const from = infoChild.attrs.from || infoChild.attrs['call-creator']
2081
- const call = {
2082
- chatId: attrs.from,
2083
- from,
2084
- callerPn: infoChild.attrs['caller_pn'],
2085
- id: callId,
2086
- date: new Date(+attrs.t * 1000),
2087
- offline: !!attrs.offline,
2088
- status
2089
- }
2090
- if (status === 'relaylatency') {
2091
- const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms']
2092
- const latencyMs = latencyValue ? Number(latencyValue) : undefined
2093
- if (Number.isFinite(latencyMs)) {
2094
- call.latencyMs = latencyMs
2095
- }
2096
- }
2097
- if (status === 'offer') {
2098
- call.isVideo = !!(0, WABinary_1.getBinaryNodeChild)(infoChild, 'video')
2099
- call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid']
2100
- call.groupJid = infoChild.attrs['group-jid']
2101
- await callOfferCache.set(call.id, call)
2102
- }
2103
- const existingCall = await callOfferCache.get(call.id)
2104
- // use existing call info to populate this event
2105
- if (existingCall) {
2106
- call.isVideo = existingCall.isVideo
2107
- call.isGroup = existingCall.isGroup
2108
- call.callerPn = call.callerPn || existingCall.callerPn
2109
- }
2110
- // delete data once call has ended
2111
- if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
2112
- await callOfferCache.del(call.id)
2113
- }
2114
- await normalizeCallEventJids(call, infoChild)
2115
- ev.emit('call', [call])
2116
- } catch (error) {
2117
- logger.error({ error, node: (0, WABinary_1.binaryNodeToString)(node) }, 'error in handling call')
2118
- } finally {
2119
- await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack call'))
2120
- }
2121
- }
2122
- // Some accounts receive call signalling as TOP-LEVEL stanzas (<offer>, <terminate>,
2123
- // <mute_v2>, <transport>, … each with a call-id) instead of wrapped in <call>.
2124
- // This additively handles those: emit a 'call' event for state stanzas and ack ALL
2125
- // of them (otherwise WhatsApp keeps redelivering). The <call> path above is untouched.
2126
- const CALL_STATE_TAGS = new Set(['offer', 'offer_notice', 'terminate', 'accept', 'reject', 'preaccept'])
2127
- const handleStandaloneCallStanza = async node => {
2128
- try {
2129
- if (!CALL_STATE_TAGS.has(node.tag)) {
2130
- return // media/relay signalling (transport, video, duration, mute_v2, lobby, …): ack only
2131
- }
2132
- const { attrs } = node
2133
- const status = (0, Utils_1.getCallStatusFromNode)(node)
2134
- const callId = attrs['call-id']
2135
- const from = attrs.from || attrs['call-creator']
2136
- const call = {
2137
- chatId: attrs.from || from,
2138
- from,
2139
- callerPn: attrs['caller_pn'],
2140
- id: callId,
2141
- date: attrs.t ? new Date(+attrs.t * 1000) : new Date(),
2142
- offline: !!attrs.offline,
2143
- status
2144
- }
2145
- if (status === 'offer') {
2146
- call.isVideo = !!(0, WABinary_1.getBinaryNodeChild)(node, 'video')
2147
- call.isGroup = attrs.type === 'group' || !!attrs['group-jid']
2148
- call.groupJid = attrs['group-jid']
2149
- if (callId) {
2150
- await callOfferCache.set(callId, call)
2151
- }
2152
- }
2153
- const existingCall = callId ? await callOfferCache.get(callId) : undefined
2154
- if (existingCall) {
2155
- call.isVideo = existingCall.isVideo
2156
- call.isGroup = existingCall.isGroup
2157
- call.callerPn = call.callerPn || existingCall.callerPn
2158
- }
2159
- if (callId && (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate')) {
2160
- await callOfferCache.del(callId)
2161
- }
2162
- await normalizeCallEventJids(call, node)
2163
- ev.emit('call', [call])
2164
- } catch (error) {
2165
- logger.error({ error, node: (0, WABinary_1.binaryNodeToString)(node) }, 'error handling standalone call stanza')
2166
- } finally {
2167
- await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack standalone call'))
2168
- }
2169
- }
2170
- const handleBadAck = async ({ attrs }) => {
2171
- const key = { remoteJid: attrs.from, fromMe: true, id: attrs.id }
2172
- // WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP
2173
- // // current hypothesis is that if pash is sent in the ack
2174
- // // it means -- the message hasn't reached all devices yet
2175
- // // we'll retry sending the message here
2176
- // if(attrs.phash) {
2177
- // logger.info({ attrs }, 'received phash in ack, resending message...')
2178
- // const msg = await getMessage(key)
2179
- // if(msg) {
2180
- // await relayMessage(key.remoteJid!, msg, { messageId: key.id!, useUserDevicesCache: false })
2181
- // } else {
2182
- // logger.warn({ attrs }, 'could not send message again, as it was not found')
2183
- // }
2184
- // }
2185
- // error in acknowledgement,
2186
- // device could not display the message
2187
- if (attrs.error) {
2188
- if (attrs.error === Utils_1.SERVER_ERROR_CODES.MissingTcToken) {
2189
- // 463 = account restricted + no tctoken for this contact.
2190
- // WA Web prevents this client-side (disables compose bar).
2191
- // No retry — retrying worsens the restriction by counting as another "reach out" to an unknown contact.
2192
- logger.warn(
2193
- { msgId: attrs.id, from: attrs.from },
2194
- 'error 463: account restricted or missing tctoken for contact'
2195
- )
2196
- } else if (attrs.error === Utils_1.SERVER_ERROR_CODES.SmaxInvalid) {
2197
- logger.warn(
2198
- { msgId: attrs.id, from: attrs.from },
2199
- 'smax-invalid (479): stanza rejected by server — likely stale device session or malformed addressing'
2200
- )
2201
- } else {
2202
- logger.warn({ attrs }, 'received error in ack')
2203
- }
2204
- ev.emit('messages.update', [
2205
- {
2206
- key,
2207
- update: {
2208
- status: Types_1.WAMessageStatus.ERROR,
2209
- messageStubParameters: [attrs.error]
2210
- }
2211
- }
2212
- ])
2213
- // resend the message with device_fanout=false, use at your own risk
2214
- // if (attrs.error === '475') {
2215
- // const msg = await getMessage(key)
2216
- // if (msg) {
2217
- // await relayMessage(key.remoteJid!, msg, {
2218
- // messageId: key.id!,
2219
- // useUserDevicesCache: false,
2220
- // additionalAttributes: {
2221
- // device_fanout: 'false'
2222
- // }
2223
- // })
2224
- // }
2225
- // }
2226
- }
2227
- }
2228
- /// processes a node with the given function
2229
- /// and adds the task to the existing buffer if we're buffering events
2230
- const processNodeWithBuffer = async (node, identifier, exec) => {
2231
- ev.buffer()
2232
- await execTask()
2233
- ev.flush()
2234
- function execTask() {
2235
- return exec(node, false).catch(err => onUnexpectedError(err, identifier))
2236
- }
2237
- }
2238
- const offlineNodeProcessor = (0, offline_node_processor_1.makeOfflineNodeProcessor)(
2239
- new Map([
2240
- ['message', handleMessage],
2241
- ['call', handleCall],
2242
- ['receipt', handleReceipt],
2243
- ['notification', handleNotification]
2244
- ]),
2245
- {
2246
- isWsOpen: () => ws.isOpen,
2247
- onUnexpectedError,
2248
- yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
2249
- }
2250
- )
2251
- const processNode = async (type, node, identifier, exec) => {
2252
- const isOffline = !!node.attrs.offline
2253
- if (isOffline) {
2254
- offlineNodeProcessor.enqueue(type, node)
2255
- } else {
2256
- await processNodeWithBuffer(node, identifier, exec)
2257
- }
2258
- }
2259
- let latestNodeInMemory = null
2260
- const nodelogger = node => {
2261
- if (!node) return null
2262
- latestNodeInMemory = node
2263
- return latestNodeInMemory
2264
- }
2265
- const setNodeLoggerListener = () => {
2266
- return latestNodeInMemory
2267
- }
2268
- // recv a message
2269
- ws.on('CB:message', async node => {
2270
- nodelogger(node)
2271
- await processNode('message', node, 'processing message', handleMessage)
2272
- })
2273
- ws.on('CB:call', async node => {
2274
- nodelogger(node)
2275
- await processNode('call', node, 'handling call', handleCall)
2276
- })
2277
- // additive: top-level call-signalling stanzas (some accounts send these instead of <call>)
2278
- for (const callTag of [
2279
- 'offer', 'offer_notice', 'terminate', 'accept', 'reject', 'preaccept',
2280
- 'transport', 'video', 'duration', 'mute_v2', 'lobby', 'heartbeat', 'relaylatency', 'link_query'
2281
- ]) {
2282
- ws.on('CB:' + callTag, node => {
2283
- nodelogger(node)
2284
- handleStandaloneCallStanza(node).catch(error => onUnexpectedError(error, 'handling standalone call stanza'))
2285
- })
2286
- }
2287
- ws.on('CB:receipt', async node => {
2288
- nodelogger(node)
2289
- await processNode('receipt', node, 'handling receipt', handleReceipt)
2290
- })
2291
- ws.on('CB:notification', async node => {
2292
- nodelogger(node)
2293
- await processNode('notification', node, 'handling notification', handleNotification)
2294
- })
2295
- ws.on('CB:ack,class:message', node => {
2296
- nodelogger(node)
2297
- handleBadAck(node).catch(error => onUnexpectedError(error, 'handling bad ack'))
2298
- })
2299
- const linkedParentMap = {}
2300
- ws.on('CB:iq', node => {
2301
- if (node && node.tag === 'iq' && node.attrs.type === 'result') {
2302
- const groups = node.content
2303
-
2304
- if (Array.isArray(groups)) {
2305
- for (const group of groups) {
2306
- const groupId = group.attrs.id + '@g.us'
2307
-
2308
- if (group && Array.isArray(group.content)) {
2309
- for (const item of group.content) {
2310
- if (item.tag === 'linked_parent' && item.attrs && item.attrs.jid) {
2311
- linkedParentMap[groupId] = item.attrs.jid
2312
- }
2313
- }
2314
- }
2315
- }
2316
- }
2317
- }
2318
- })
2319
- ev.on('call', async ([call]) => {
2320
- if (!call) {
2321
- return
2322
- }
2323
- nodelogger(call)
2324
- // missed call + group call notification message generation
2325
- if (call.status === 'timeout' || (call.status === 'offer' && call.isGroup)) {
2326
- const msg = {
2327
- key: {
2328
- remoteJid: call.chatId,
2329
- id: call.id,
2330
- fromMe: false
2331
- },
2332
- messageTimestamp: (0, Utils_1.unixTimestampSeconds)(call.date)
2333
- }
2334
- if (call.status === 'timeout') {
2335
- if (call.isGroup) {
2336
- msg.messageStubType = call.isVideo
2337
- ? Types_1.WAMessageStubType.CALL_MISSED_GROUP_VIDEO
2338
- : Types_1.WAMessageStubType.CALL_MISSED_GROUP_VOICE
2339
- } else {
2340
- msg.messageStubType = call.isVideo
2341
- ? Types_1.WAMessageStubType.CALL_MISSED_VIDEO
2342
- : Types_1.WAMessageStubType.CALL_MISSED_VOICE
2343
- }
2344
- } else {
2345
- msg.message = { call: { callKey: Buffer.from(call.id) } }
2346
- }
2347
- const protoMsg = index_js_1.proto.WebMessageInfo.fromObject(msg)
2348
- await upsertMessage(protoMsg, call.offline ? 'append' : 'notify')
2349
- }
2350
- })
2351
- let lastTcTokenPruneTs = 0
2352
- ev.on('connection.update', ({ isOnline, connection }) => {
2353
- if (connection === 'open') {
2354
- isConnected = true
2355
- }
2356
- if (typeof isOnline !== 'undefined') {
2357
- sendActiveReceipts = isOnline
2358
- logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`)
2359
- }
2360
- // Daily cleanup of expired tctokens (mirrors WA Web's CLEAN_TC_TOKENS task)
2361
- if (isOnline) {
2362
- const now = Date.now()
2363
- const DAY_MS = 24 * 60 * 60 * 1000
2364
- if (now - lastTcTokenPruneTs >= DAY_MS) {
2365
- lastTcTokenPruneTs = now
2366
- void pruneExpiredTcTokens()
2367
- }
2368
- }
2369
- })
2370
- async function pruneExpiredTcTokens() {
2371
- try {
2372
- await tcTokenIndexLoaded
2373
- const persisted = await (0, tc_token_utils_1.readTcTokenIndex)(authState.keys)
2374
- const allJids = new Set(tcTokenKnownJids)
2375
- for (const jid of persisted) allJids.add(jid)
2376
- if (!allJids.size) return
2377
- const jids = [...allJids]
2378
- const allTokens = await authState.keys.get('tctoken', jids)
2379
- const writes = {}
2380
- const survivors = new Set()
2381
- let mutated = 0
2382
- for (const jid of jids) {
2383
- const entry = allTokens[jid]
2384
- if (!entry) {
2385
- mutated++
2386
- continue
2387
- }
2388
- const hasPeerToken = !!entry.token?.length
2389
- const peerTokenExpired = hasPeerToken && (0, tc_token_utils_1.isTcTokenExpired)(entry.timestamp)
2390
- const hasSenderTs = entry.senderTimestamp !== undefined
2391
- const senderTsExpired = hasSenderTs && (0, tc_token_utils_1.isTcTokenExpired)(entry.senderTimestamp)
2392
- const keepPeerToken = hasPeerToken && !peerTokenExpired
2393
- const keepSenderTs = hasSenderTs && !senderTsExpired
2394
- if (!keepPeerToken && !keepSenderTs) {
2395
- writes[jid] = null
2396
- mutated++
2397
- } else if (peerTokenExpired && keepSenderTs) {
2398
- writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp }
2399
- survivors.add(jid)
2400
- mutated++
2401
- } else {
2402
- survivors.add(jid)
2403
- }
2404
- }
2405
- if (mutated === 0) return
2406
- await authState.keys.set({
2407
- tctoken: {
2408
- ...writes,
2409
- [tc_token_utils_1.TC_TOKEN_INDEX_KEY]: {
2410
- token: Buffer.from(JSON.stringify([...survivors]))
2411
- }
2412
- }
2413
- })
2414
- tcTokenKnownJids.clear()
2415
- for (const jid of survivors) tcTokenKnownJids.add(jid)
2416
- logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens')
2417
- } catch (err) {
2418
- logger.warn({ err: err?.message }, 'failed to prune expired tctokens')
2419
- }
2420
- }
2421
- return {
2422
- ...sock,
2423
- sendMessageAck,
2424
- sendRetryRequest,
2425
- offerCall,
2426
- rejectCall,
2427
- acceptCall,
2428
- terminateCall,
2429
- rekeyCall,
2430
- joinCallLink,
2431
- queryCallLink,
2432
- nodelogger,
2433
- setNodeLoggerListener,
2434
- fetchMessageHistory,
2435
- requestPlaceholderResend,
2436
- requestWaffleNonce,
2437
- requestCompanionCanonicalNonce,
2438
- requestCompanionMetaNonce,
2439
- messageRetryManager
2440
- }
1
+ import NodeCache from '@cacheable/node-cache';
2
+ import { Boom } from '@hapi/boom';
3
+ import { randomBytes } from 'crypto';
4
+ import Long from 'long';
5
+ import { proto } from '../../WAProto/index.js';
6
+ import { DEFAULT_CACHE_TTLS, KEY_BUNDLE_TYPE, MIN_PREKEY_COUNT, PLACEHOLDER_MAX_AGE_SECONDS, STATUS_EXPIRY_SECONDS } from '../Defaults/index.js';
7
+ import { ReachoutTimelockEnforcementType, WAMessageStatus, WAMessageStubType } from '../Types/index.js';
8
+ import { ACCOUNT_RESTRICTED_TEXT, aesDecryptCTR, aesEncryptGCM, cleanMessage, Curve, decodeMediaRetryNode, decodeMessageNode, decryptMessageNode, delay, derivePairingCodeKey, encodeBigEndian, encodeSignedDeviceIdentity, extractAddressingContext, extractE2ESessionFromRetryReceipt, getCallStatusFromNode, getHistoryMsg, getNextPreKeys, getStatusFromReceiptType, handleIdentityChange, hkdf, MISSING_KEYS_ERROR_TEXT, NACK_REASONS, NO_MESSAGE_FOUND_ERROR_TEXT, SERVER_ERROR_CODES, toNumber, unixTimestampSeconds, xmppPreKey, xmppSignedPreKey, generateWAMessageFromContent } from '../Utils/index.js';
9
+ import { makeMutex } from '../Utils/make-mutex.js';
10
+ import { makeOfflineNodeProcessor } from '../Utils/offline-node-processor.js';
11
+ import { buildAckStanza } from '../Utils/stanza-ack.js';
12
+ import { buildMergedTcTokenIndexWrite, isTcTokenExpired, readTcTokenIndex, resolveIssuanceJid, resolveTcTokenJid, storeTcTokensFromIqResult, TC_TOKEN_INDEX_KEY } from '../Utils/tc-token-utils.js';
13
+ import { areJidsSameUser, binaryNodeToString, getAllBinaryNodeChildren, getBinaryNodeChild, getBinaryNodeChildBuffer, getBinaryNodeChildren, getBinaryNodeChildString, getBinaryNodeChildUInt, isJidGroup, isJidNewsletter, isJidStatusBroadcast, isLidUser, isPnUser, jidDecode, jidNormalizedUser, S_WHATSAPP_NET } from '../WABinary/index.js';
14
+ import { extractGroupMetadata } from './groups.js';
15
+ import { makeMessagesSocket } from './messages-send.js';
16
+ const ENFORCEMENT_TYPE_VALUES = new Set(Object.values(ReachoutTimelockEnforcementType));
17
+ function isValidEnforcementType(value) {
18
+ return typeof value === 'string' && ENFORCEMENT_TYPE_VALUES.has(value);
2441
19
  }
2442
- exports.makeMessagesRecvSocket = makeMessagesRecvSocket
20
+ export const makeMessagesRecvSocket = (config) => {
21
+ const { logger, retryRequestDelayMs, maxMsgRetryCount, getMessage, shouldIgnoreJid, enableAutoSessionRecreation } = config;
22
+ const sock = makeMessagesSocket(config);
23
+ const { userDevicesCache, devicesMutex, ev, authState, ws, messageMutex, notificationMutex, receiptMutex, signalRepository, query, upsertMessage, resyncAppState, onUnexpectedError, assertSessions, sendNode, sendMessage, relayMessage, sendReceipt, uploadPreKeys, sendPeerDataOperationMessage, messageRetryManager, registerSocketEndHandler, issuePrivacyTokens, fetchAccountReachoutTimelock, placeholderResendCache } = sock;
24
+ const getLIDForPN = signalRepository.lidMapping.getLIDForPN.bind(signalRepository.lidMapping);
25
+ /** this mutex ensures that each retryRequest will wait for the previous one to finish */
26
+ const retryMutex = makeMutex();
27
+ const msgRetryCache = config.msgRetryCounterCache ||
28
+ new NodeCache({
29
+ stdTTL: DEFAULT_CACHE_TTLS.MSG_RETRY, // 1 hour
30
+ useClones: false
31
+ });
32
+ const callOfferCache = config.callOfferCache ||
33
+ new NodeCache({
34
+ stdTTL: DEFAULT_CACHE_TTLS.CALL_OFFER, // 5 mins
35
+ useClones: false
36
+ });
37
+ // Debounce identity-change session refreshes per JID to avoid bursts
38
+ const identityAssertDebounce = new NodeCache({ stdTTL: 5, useClones: false });
39
+ let sendActiveReceipts = false;
40
+ const fetchMessageHistory = async (count, oldestMsgKey, oldestMsgTimestamp) => {
41
+ if (!authState.creds.me?.id) {
42
+ throw new Boom('Not authenticated');
43
+ }
44
+ const pdoMessage = {
45
+ historySyncOnDemandRequest: {
46
+ chatJid: oldestMsgKey.remoteJid,
47
+ oldestMsgFromMe: oldestMsgKey.fromMe,
48
+ oldestMsgId: oldestMsgKey.id,
49
+ oldestMsgTimestampMs: oldestMsgTimestamp,
50
+ onDemandMsgCount: count
51
+ },
52
+ peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.HISTORY_SYNC_ON_DEMAND
53
+ };
54
+ return sendPeerDataOperationMessage(pdoMessage);
55
+ };
56
+ const requestPlaceholderResend = async (messageKey, msgData) => {
57
+ if (!authState.creds.me?.id) {
58
+ throw new Boom('Not authenticated');
59
+ }
60
+ if (await placeholderResendCache.get(messageKey?.id)) {
61
+ logger.debug({ messageKey }, 'already requested resend');
62
+ return;
63
+ }
64
+ else {
65
+ // Store original message data so PDO response handler can preserve
66
+ // metadata (LID details, timestamps, etc.) that the phone may omit
67
+ await placeholderResendCache.set(messageKey?.id, msgData || true);
68
+ }
69
+ await delay(2000);
70
+ if (!(await placeholderResendCache.get(messageKey?.id))) {
71
+ logger.debug({ messageKey }, 'message received while resend requested');
72
+ return 'RESOLVED';
73
+ }
74
+ const pdoMessage = {
75
+ placeholderMessageResendRequest: [
76
+ {
77
+ messageKey
78
+ }
79
+ ],
80
+ peerDataOperationRequestType: proto.Message.PeerDataOperationRequestType.PLACEHOLDER_MESSAGE_RESEND
81
+ };
82
+ setTimeout(async () => {
83
+ if (await placeholderResendCache.get(messageKey?.id)) {
84
+ logger.debug({ messageKey }, 'PDO message without response after 8 seconds. Phone possibly offline');
85
+ await placeholderResendCache.del(messageKey?.id);
86
+ }
87
+ }, 8000);
88
+ return sendPeerDataOperationMessage(pdoMessage);
89
+ };
90
+ const handleMexNotification = async (node) => {
91
+ const updateNode = getBinaryNodeChild(node, 'update');
92
+ if (updateNode) {
93
+ const opName = updateNode.attrs?.op_name;
94
+ if (!opName) {
95
+ logger.warn({ node: binaryNodeToString(node) }, 'mex notification missing op_name, fallback to legacy');
96
+ await handleLegacyMexNewsletterNotification(node);
97
+ return;
98
+ }
99
+ let mexResponse;
100
+ try {
101
+ mexResponse = JSON.parse(updateNode.content.toString());
102
+ }
103
+ catch (error) {
104
+ logger.error({ err: error, opName }, 'failed to parse mex notification JSON');
105
+ return;
106
+ }
107
+ if (mexResponse.errors?.length) {
108
+ logger.warn({ errors: mexResponse.errors, opName }, 'mex notification has GQL errors');
109
+ return;
110
+ }
111
+ const data = mexResponse.data;
112
+ if (!data) {
113
+ logger.warn({ opName }, 'mex notification has null data');
114
+ return;
115
+ }
116
+ logger.debug({ opName }, 'processing mex notification');
117
+ switch (opName) {
118
+ case 'NotificationUserReachoutTimelockUpdate':
119
+ handleReachoutTimelockNotification(data);
120
+ break;
121
+ case 'MessageCappingInfoNotification':
122
+ handleMessageCappingNotification(data);
123
+ break;
124
+ // newsletter ops still use the legacy <mex> child structure
125
+ case 'NotificationNewsletterUpdate':
126
+ case 'NotificationLinkedProfilesUpdates':
127
+ case 'NotificationNewsletterAdminPromote':
128
+ case 'NotificationNewsletterAdminDemote':
129
+ case 'NotificationNewsletterUserSettingChange':
130
+ case 'NotificationNewsletterJoin':
131
+ case 'NotificationNewsletterLeave':
132
+ case 'NotificationNewsletterStateChange':
133
+ case 'NotificationNewsletterAdminMetadataUpdate':
134
+ case 'NotificationNewsletterOwnerUpdate':
135
+ case 'NotificationNewsletterAdminInviteRevoke':
136
+ case 'NotificationNewsletterWamoSubStatusChange':
137
+ case 'NotificationNewsletterBlockUser':
138
+ case 'NotificationNewsletterPaidPartnership':
139
+ case 'NotificationNewsletterMilestone':
140
+ case 'NewsletterResponseStateUpdate':
141
+ await handleLegacyMexNewsletterNotification(node);
142
+ break;
143
+ default:
144
+ logger.debug({ opName }, 'unhandled mex notification');
145
+ break;
146
+ }
147
+ return;
148
+ }
149
+ await handleLegacyMexNewsletterNotification(node);
150
+ };
151
+ const handleReachoutTimelockNotification = (data) => {
152
+ const payload = data.xwa2_notify_account_reachout_timelock;
153
+ if (!payload) {
154
+ logger.warn('reachout timelock notification missing payload');
155
+ return;
156
+ }
157
+ if (!payload.is_active) {
158
+ logger.info('reachout timelock restriction lifted');
159
+ ev.emit('connection.update', {
160
+ reachoutTimeLock: {
161
+ isActive: false,
162
+ enforcementType: ReachoutTimelockEnforcementType.DEFAULT
163
+ }
164
+ });
165
+ return;
166
+ }
167
+ // WA Web defaults to now+60s when the server omits the expiry
168
+ const timeEnforcementEnds = payload.time_enforcement_ends
169
+ ? new Date(parseInt(payload.time_enforcement_ends, 10) * 1000)
170
+ : new Date(Date.now() + 60000);
171
+ const enforcementType = isValidEnforcementType(payload.enforcement_type)
172
+ ? payload.enforcement_type
173
+ : ReachoutTimelockEnforcementType.DEFAULT;
174
+ logger.info({ enforcementType, timeEnforcementEnds }, 'reachout timelock restriction set');
175
+ ev.emit('connection.update', {
176
+ reachoutTimeLock: {
177
+ isActive: true,
178
+ timeEnforcementEnds,
179
+ enforcementType
180
+ }
181
+ });
182
+ };
183
+ const handleMessageCappingNotification = (data) => {
184
+ const payload = data.xwa2_notify_new_chat_messages_capping_info_update;
185
+ if (!payload) {
186
+ logger.warn('message capping notification missing payload');
187
+ return;
188
+ }
189
+ logger.info({ payload }, 'received message capping update');
190
+ ev.emit('message-capping.update', payload);
191
+ };
192
+ const handleLegacyMexNewsletterNotification = async (node) => {
193
+ const mexNode = getBinaryNodeChild(node, 'mex');
194
+ const updateNode = mexNode?.content ? null : getBinaryNodeChild(node, 'update') || getAllBinaryNodeChildren(node)[0];
195
+ const payloadNode = mexNode?.content ? mexNode : updateNode;
196
+ if (!payloadNode?.content) {
197
+ logger.warn({ node: binaryNodeToString(node) }, 'invalid mex newsletter notification');
198
+ return;
199
+ }
200
+ let data;
201
+ try {
202
+ const payloadContent = payloadNode.content;
203
+ if (Array.isArray(payloadContent)) {
204
+ logger.warn({ payloadNode }, 'invalid mex newsletter notification payload format');
205
+ return;
206
+ }
207
+ const contentBuf = typeof payloadContent === 'string' ? Buffer.from(payloadContent, 'binary') : Buffer.from(payloadContent);
208
+ data = JSON.parse(contentBuf.toString());
209
+ }
210
+ catch (error) {
211
+ logger.error({ err: error, node: binaryNodeToString(node) }, 'failed to parse mex newsletter notification');
212
+ return;
213
+ }
214
+ const operation = data?.operation ?? payloadNode?.attrs?.op_name;
215
+ let updates = data?.updates;
216
+ if (!updates) {
217
+ const linkedProfiles = data?.data?.xwa2_notify_linked_profiles;
218
+ if (linkedProfiles) {
219
+ updates = [linkedProfiles];
220
+ }
221
+ }
222
+ if (!updates || !operation) {
223
+ logger.warn({ data }, 'invalid mex newsletter notification content');
224
+ return;
225
+ }
226
+ logger.info({ operation, updates }, 'got mex newsletter notification');
227
+ switch (operation) {
228
+ case 'NotificationNewsletterUpdate':
229
+ for (const update of updates) {
230
+ if (update.jid && update.settings && Object.keys(update.settings).length > 0) {
231
+ ev.emit('newsletter-settings.update', {
232
+ id: update.jid,
233
+ update: update.settings
234
+ });
235
+ }
236
+ }
237
+ break;
238
+ case 'NotificationNewsletterAdminPromote':
239
+ for (const update of updates) {
240
+ if (update.jid && update.user) {
241
+ ev.emit('newsletter-participants.update', {
242
+ id: update.jid,
243
+ author: node.attrs.from,
244
+ user: update.user,
245
+ new_role: 'ADMIN',
246
+ action: 'promote'
247
+ });
248
+ }
249
+ }
250
+ break;
251
+ case 'NotificationLinkedProfilesUpdates':
252
+ for (const update of updates) {
253
+ const lid = update?.jid;
254
+ const addedProfiles = Array.isArray(update?.added_profiles) ? update.added_profiles : [];
255
+ const mappings = [];
256
+ for (const profile of addedProfiles) {
257
+ const pn = typeof profile === 'string' ? profile : (profile?.pn ?? profile?.jid ?? null);
258
+ if (lid && pn) {
259
+ const mapping = { lid, pn };
260
+ ev.emit('lid-mapping.update', mapping);
261
+ mappings.push(mapping);
262
+ }
263
+ }
264
+ await signalRepository.lidMapping.storeLIDPNMappings(mappings);
265
+ }
266
+ break;
267
+ default:
268
+ logger.info({ operation, data }, 'unhandled mex newsletter notification');
269
+ break;
270
+ }
271
+ };
272
+ // Handles newsletter notifications
273
+ const handleNewsletterNotification = async (node) => {
274
+ const from = node.attrs.from;
275
+ const children = getAllBinaryNodeChildren(node);
276
+ const author = node.attrs.participant;
277
+ for (const child of children) {
278
+ logger.debug({ from, child }, 'got newsletter notification');
279
+ switch (child.tag) {
280
+ case 'reaction': {
281
+ const reactionUpdate = {
282
+ id: from,
283
+ server_id: child.attrs.message_id,
284
+ reaction: {
285
+ code: getBinaryNodeChildString(child, 'reaction'),
286
+ count: 1
287
+ }
288
+ };
289
+ ev.emit('newsletter.reaction', reactionUpdate);
290
+ break;
291
+ }
292
+ case 'view': {
293
+ const viewUpdate = {
294
+ id: from,
295
+ server_id: child.attrs.message_id,
296
+ count: parseInt(child.content?.toString() || '0', 10)
297
+ };
298
+ ev.emit('newsletter.view', viewUpdate);
299
+ break;
300
+ }
301
+ case 'participant': {
302
+ const participantUpdate = {
303
+ id: from,
304
+ author,
305
+ user: child.attrs.jid,
306
+ action: child.attrs.action,
307
+ new_role: child.attrs.role
308
+ };
309
+ ev.emit('newsletter-participants.update', participantUpdate);
310
+ break;
311
+ }
312
+ case 'update': {
313
+ const settingsNode = getBinaryNodeChild(child, 'settings');
314
+ if (settingsNode) {
315
+ const update = {};
316
+ const nameNode = getBinaryNodeChild(settingsNode, 'name');
317
+ if (nameNode?.content)
318
+ update.name = nameNode.content.toString();
319
+ const descriptionNode = getBinaryNodeChild(settingsNode, 'description');
320
+ if (descriptionNode?.content)
321
+ update.description = descriptionNode.content.toString();
322
+ ev.emit('newsletter-settings.update', {
323
+ id: from,
324
+ update
325
+ });
326
+ }
327
+ break;
328
+ }
329
+ case 'message': {
330
+ const plaintextNode = getBinaryNodeChild(child, 'plaintext');
331
+ if (plaintextNode?.content) {
332
+ try {
333
+ const contentBuf = typeof plaintextNode.content === 'string'
334
+ ? Buffer.from(plaintextNode.content, 'binary')
335
+ : Buffer.from(plaintextNode.content);
336
+ const messageProto = proto.Message.decode(contentBuf).toJSON();
337
+ const fullMessage = proto.WebMessageInfo.fromObject({
338
+ key: {
339
+ remoteJid: from,
340
+ id: child.attrs.message_id || child.attrs.server_id,
341
+ fromMe: false // TODO: is this really true though
342
+ },
343
+ message: messageProto,
344
+ messageTimestamp: +child.attrs.t
345
+ }).toJSON();
346
+ await upsertMessage(fullMessage, 'append');
347
+ logger.debug('Processed plaintext newsletter message');
348
+ }
349
+ catch (error) {
350
+ logger.error({ error }, 'Failed to decode plaintext newsletter message');
351
+ }
352
+ }
353
+ break;
354
+ }
355
+ default:
356
+ logger.warn({ node, child }, 'Unknown newsletter notification child');
357
+ break;
358
+ }
359
+ }
360
+ };
361
+ const sendMessageAck = async (node, errorCode) => {
362
+ const stanza = buildAckStanza(node, errorCode, authState.creds.me.id);
363
+ logger.debug({ recv: { tag: node.tag, attrs: node.attrs }, sent: stanza.attrs }, 'sent ack');
364
+ await sendNode(stanza);
365
+ };
366
+ const rejectCall = async (callId, callFrom) => {
367
+ const stanza = {
368
+ tag: 'call',
369
+ attrs: {
370
+ from: authState.creds.me.id,
371
+ to: callFrom
372
+ },
373
+ content: [
374
+ {
375
+ tag: 'reject',
376
+ attrs: {
377
+ 'call-id': callId,
378
+ 'call-creator': callFrom,
379
+ count: '0'
380
+ },
381
+ content: undefined
382
+ }
383
+ ]
384
+ };
385
+ await query(stanza);
386
+ };
387
+ const sendText = async (jid, text, options, quoted = null) => {
388
+ return sendMessage(jid, {
389
+ text,
390
+ ...options
391
+ }, { quoted })
392
+ }
393
+ const sendImage = async (jid, image, caption, options, quoted = null) => {
394
+ return sendMessage(jid, {
395
+ image,
396
+ caption,
397
+ ...options
398
+ }, { quoted })
399
+ }
400
+ const sendVideo = async (jid, video, caption, options, quoted = null) => {
401
+ return sendMessage(jid, {
402
+ video,
403
+ caption,
404
+ ...options
405
+ }, { quoted })
406
+ }
407
+ const sendDocument = async (jid, document, fileName, caption, options, quoted = null) => {
408
+ return sendMessage(jid, {
409
+ document,
410
+ fileName,
411
+ caption,
412
+ ...options
413
+ }, { quoted })
414
+ }
415
+ const sendAudio = async (jid, audio, options, quoted = null) => {
416
+ return sendMessage(jid, {
417
+ audio,
418
+ ...options
419
+ }, { quoted })
420
+ }
421
+ const sendLocation = async (jid, name, degreesLongitude, degreesLatitude, url, address, options, quoted = null) => {
422
+ return sendMessage(jid, {
423
+ location: {
424
+ degreesLongitude,
425
+ degreesLatitude,
426
+ name,
427
+ url,
428
+ address
429
+ },
430
+ ...options
431
+ }, { quoted })
432
+ }
433
+ const sendPoll = async (jid, name, pollVote = [], multiSelect = false, options, quoted = null) => {
434
+ const selectableCount = multiSelect ? pollVote.length : 1;
435
+
436
+ return sendMessage(jid, {
437
+ poll: {
438
+ name,
439
+ values: pollVote,
440
+ selectableCount
441
+ },
442
+ ...options
443
+ }, { quoted });
444
+ }
445
+ const sendQuiz = async (
446
+ jid,
447
+ name,
448
+ pollVote = [],
449
+ answer,
450
+ options,
451
+ quoted
452
+ ) => {
453
+ const poll = {
454
+ name,
455
+ values: pollVote,
456
+ selectableCount: 1,
457
+ type: "QUIZ",
458
+ answer: { optionName: answer }
459
+ }
460
+ return sendMessage(jid, {
461
+ poll,
462
+ ...options
463
+ }, { quoted })
464
+ }
465
+ const sendPtv = (jid, ptv, options, quoted = null) => {
466
+ return sendMessage(jid, {
467
+ ptv,
468
+ ...options
469
+ }, { quoted })
470
+ }
471
+ const statusMention = async (jid, content) => {
472
+ const msg = await generateWAMessageFromContent(jid, content, {
473
+ userJid: authState.creds.me.id
474
+ })
475
+ await relayMessage("status@broadcast", msg.message, {
476
+ statusJidList: [jid, authState.creds.me.id],
477
+ additionalNodes: [
478
+ {
479
+ tag: "meta",
480
+ attrs: {},
481
+ content: [
482
+ {
483
+ tag: "mentioned_users",
484
+ attrs: {},
485
+ content: [
486
+ {
487
+ tag: "to",
488
+ attrs: { jid },
489
+ content: undefined
490
+ }
491
+ ]
492
+ }
493
+ ]
494
+ }
495
+ ]
496
+ })
497
+
498
+ const mentionMsg = {
499
+ statusMentionMessage: {
500
+ message: {
501
+ protocolMessage: {
502
+ key: msg.key,
503
+ type: 25,
504
+ timestamp: Math.floor(Date.now() / 1000)
505
+ }
506
+ }
507
+ }
508
+ }
509
+
510
+ const x = generateWAMessageFromContent(jid, mentionMsg, {})
511
+ return relayMessage(jid, x.message, {
512
+ messageId: x.key.id,
513
+ additionalNodes: [
514
+ {
515
+ tag: "meta",
516
+ attrs: { is_status_mention: "true" }
517
+ }
518
+ ]
519
+ })
520
+ };
521
+ const sendRetryRequest = async (node, forceIncludeKeys = false) => {
522
+ const { fullMessage } = decodeMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '');
523
+ const { key: msgKey } = fullMessage;
524
+ const msgId = msgKey.id;
525
+ if (messageRetryManager) {
526
+ // Check if we've exceeded max retries using the new system
527
+ if (messageRetryManager.hasExceededMaxRetries(msgId)) {
528
+ logger.debug({ msgId }, 'reached retry limit with new retry manager, clearing');
529
+ messageRetryManager.markRetryFailed(msgId);
530
+ return;
531
+ }
532
+ // Increment retry count using new system
533
+ const retryCount = messageRetryManager.incrementRetryCount(msgId);
534
+ // Use the new retry count for the rest of the logic
535
+ const key = `${msgId}:${msgKey?.participant}`;
536
+ await msgRetryCache.set(key, retryCount);
537
+ }
538
+ else {
539
+ // Fallback to old system
540
+ const key = `${msgId}:${msgKey?.participant}`;
541
+ let retryCount = (await msgRetryCache.get(key)) || 0;
542
+ if (retryCount >= maxMsgRetryCount) {
543
+ logger.debug({ retryCount, msgId }, 'reached retry limit, clearing');
544
+ await msgRetryCache.del(key);
545
+ return;
546
+ }
547
+ retryCount += 1;
548
+ await msgRetryCache.set(key, retryCount);
549
+ }
550
+ const key = `${msgId}:${msgKey?.participant}`;
551
+ const retryCount = (await msgRetryCache.get(key)) || 1;
552
+ const { account, signedPreKey, signedIdentityKey: identityKey } = authState.creds;
553
+ const fromJid = node.attrs.from;
554
+ // Check if we should recreate the session
555
+ let shouldRecreateSession = false;
556
+ let recreateReason = '';
557
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1) {
558
+ try {
559
+ // Check if we have a session with this JID
560
+ const sessionId = signalRepository.jidToSignalProtocolAddress(fromJid);
561
+ const hasSession = await signalRepository.validateSession(fromJid);
562
+ const result = messageRetryManager.shouldRecreateSession(fromJid, hasSession.exists);
563
+ shouldRecreateSession = result.recreate;
564
+ recreateReason = result.reason;
565
+ if (shouldRecreateSession) {
566
+ logger.debug({ fromJid, retryCount, reason: recreateReason }, 'recreating session for retry');
567
+ // Delete existing session to force recreation
568
+ await authState.keys.set({ session: { [sessionId]: null } });
569
+ forceIncludeKeys = true;
570
+ }
571
+ }
572
+ catch (error) {
573
+ logger.warn({ error, fromJid }, 'failed to check session recreation');
574
+ }
575
+ }
576
+ if (retryCount <= 2) {
577
+ // Use new retry manager for phone requests if available
578
+ if (messageRetryManager) {
579
+ // Schedule phone request with delay (like whatsmeow)
580
+ messageRetryManager.schedulePhoneRequest(msgId, async () => {
581
+ try {
582
+ const requestId = await requestPlaceholderResend(msgKey);
583
+ logger.debug(`sendRetryRequest: requested placeholder resend (${requestId}) for message ${msgId} (scheduled)`);
584
+ }
585
+ catch (error) {
586
+ logger.warn({ error, msgId }, 'failed to send scheduled phone request');
587
+ }
588
+ });
589
+ }
590
+ else {
591
+ // Fallback to immediate request
592
+ const msgId = await requestPlaceholderResend(msgKey);
593
+ logger.debug(`sendRetryRequest: requested placeholder resend for message ${msgId}`);
594
+ }
595
+ }
596
+ const deviceIdentity = encodeSignedDeviceIdentity(account, true);
597
+ await authState.keys.transaction(async () => {
598
+ const receipt = {
599
+ tag: 'receipt',
600
+ attrs: {
601
+ id: msgId,
602
+ type: 'retry',
603
+ to: node.attrs.from
604
+ },
605
+ content: [
606
+ {
607
+ tag: 'retry',
608
+ attrs: {
609
+ count: retryCount.toString(),
610
+ id: node.attrs.id,
611
+ t: node.attrs.t,
612
+ v: '1',
613
+ // ADD ERROR FIELD
614
+ error: '0'
615
+ }
616
+ },
617
+ {
618
+ tag: 'registration',
619
+ attrs: {},
620
+ content: encodeBigEndian(authState.creds.registrationId)
621
+ }
622
+ ]
623
+ };
624
+ if (node.attrs.recipient) {
625
+ receipt.attrs.recipient = node.attrs.recipient;
626
+ }
627
+ if (node.attrs.participant) {
628
+ receipt.attrs.participant = node.attrs.participant;
629
+ }
630
+ if (retryCount > 1 || forceIncludeKeys || shouldRecreateSession) {
631
+ const { update, preKeys } = await getNextPreKeys(authState, 1);
632
+ const [keyId] = Object.keys(preKeys);
633
+ const key = preKeys[+keyId];
634
+ const content = receipt.content;
635
+ content.push({
636
+ tag: 'keys',
637
+ attrs: {},
638
+ content: [
639
+ { tag: 'type', attrs: {}, content: Buffer.from(KEY_BUNDLE_TYPE) },
640
+ { tag: 'identity', attrs: {}, content: identityKey.public },
641
+ xmppPreKey(key, +keyId),
642
+ xmppSignedPreKey(signedPreKey),
643
+ { tag: 'device-identity', attrs: {}, content: deviceIdentity }
644
+ ]
645
+ });
646
+ ev.emit('creds.update', update);
647
+ }
648
+ await sendNode(receipt);
649
+ logger.info({ msgAttrs: node.attrs, retryCount }, 'sent retry receipt');
650
+ }, authState?.creds?.me?.id || 'sendRetryRequest');
651
+ };
652
+ // Mirrors WAWeb/Handle/PreKeyLow.js: skip a re-issued notification with the same stanza id.
653
+ const inFlightPreKeyLow = new Set();
654
+ /**
655
+ * Fire-and-forget tctoken re-issuance after a peer's device identity changed.
656
+ * Mirrors WAWebSendTcTokenWhenDeviceIdentityChange — runs in parallel with
657
+ * the session refresh (not after it).
658
+ */
659
+ const reissueTcTokenAfterIdentityChange = (from) => {
660
+ void (async () => {
661
+ const normalizedJid = jidNormalizedUser(from);
662
+ const tcJid = await resolveTcTokenJid(normalizedJid, getLIDForPN);
663
+ const tcTokenData = await authState.keys.get('tctoken', [tcJid]);
664
+ const senderTs = tcTokenData?.[tcJid]?.senderTimestamp;
665
+ if (senderTs === null || senderTs === undefined || isTcTokenExpired(senderTs)) {
666
+ return;
667
+ }
668
+ logger.debug({ jid: normalizedJid, senderTimestamp: senderTs }, 'identity changed, re-issuing tctoken');
669
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
670
+ const issueJid = await resolveIssuanceJid(normalizedJid, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID);
671
+ const result = await issuePrivacyTokens([issueJid], senderTs);
672
+ await storeTcTokensFromIqResult({
673
+ result,
674
+ fallbackJid: tcJid,
675
+ keys: authState.keys,
676
+ getLIDForPN,
677
+ onNewJidStored: trackTcTokenJid
678
+ });
679
+ })().catch(err => {
680
+ logger.debug({ jid: from, err: err?.message }, 'failed to re-issue tctoken after identity change');
681
+ });
682
+ };
683
+ const handleEncryptNotification = async (node) => {
684
+ const from = node.attrs.from;
685
+ if (from === S_WHATSAPP_NET) {
686
+ const stanzaId = node.attrs.id;
687
+ if (stanzaId && inFlightPreKeyLow.has(stanzaId)) {
688
+ return;
689
+ }
690
+ const countChild = getBinaryNodeChild(node, 'count');
691
+ const count = +countChild.attrs.value;
692
+ const shouldUploadMorePreKeys = count < MIN_PREKEY_COUNT;
693
+ logger.debug({ count, shouldUploadMorePreKeys }, 'recv pre-key count');
694
+ if (shouldUploadMorePreKeys) {
695
+ if (stanzaId)
696
+ inFlightPreKeyLow.add(stanzaId);
697
+ try {
698
+ await uploadPreKeys();
699
+ }
700
+ finally {
701
+ if (stanzaId)
702
+ inFlightPreKeyLow.delete(stanzaId);
703
+ }
704
+ }
705
+ }
706
+ else {
707
+ const result = await handleIdentityChange(node, {
708
+ meId: authState.creds.me?.id,
709
+ meLid: authState.creds.me?.lid,
710
+ validateSession: signalRepository.validateSession,
711
+ assertSessions,
712
+ debounceCache: identityAssertDebounce,
713
+ logger,
714
+ onBeforeSessionRefresh: reissueTcTokenAfterIdentityChange
715
+ });
716
+ if (result.action === 'no_identity_node') {
717
+ logger.info({ node }, 'unknown encrypt notification');
718
+ }
719
+ }
720
+ };
721
+ const handleGroupNotification = (fullNode, child, msg) => {
722
+ // TODO: Support PN/LID (Here is only LID now)
723
+ const actingParticipantLid = fullNode.attrs.participant;
724
+ const actingParticipantPn = fullNode.attrs.participant_pn;
725
+ const actingParticipantUsername = fullNode.attrs.participant_username;
726
+ const affectedParticipantLid = getBinaryNodeChild(child, 'participant')?.attrs?.jid || actingParticipantLid;
727
+ const affectedParticipantPn = getBinaryNodeChild(child, 'participant')?.attrs?.phone_number || actingParticipantPn;
728
+ switch (child?.tag) {
729
+ case 'create':
730
+ const metadata = extractGroupMetadata(child);
731
+ msg.messageStubType = WAMessageStubType.GROUP_CREATE;
732
+ msg.messageStubParameters = [metadata.subject];
733
+ msg.key = { participant: metadata.owner, participantAlt: metadata.ownerPn };
734
+ ev.emit('chats.upsert', [
735
+ {
736
+ id: metadata.id,
737
+ name: metadata.subject,
738
+ conversationTimestamp: metadata.creation
739
+ }
740
+ ]);
741
+ ev.emit('groups.upsert', [
742
+ {
743
+ ...metadata,
744
+ author: actingParticipantLid,
745
+ authorPn: actingParticipantPn,
746
+ authorUsername: actingParticipantUsername
747
+ }
748
+ ]);
749
+ break;
750
+ case 'ephemeral':
751
+ case 'not_ephemeral':
752
+ msg.message = {
753
+ protocolMessage: {
754
+ type: proto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING,
755
+ ephemeralExpiration: +(child.attrs.expiration || 0)
756
+ }
757
+ };
758
+ break;
759
+ case 'modify':
760
+ const oldNumber = getBinaryNodeChildren(child, 'participant').map(p => p.attrs.jid);
761
+ msg.messageStubParameters = oldNumber || [];
762
+ msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_CHANGE_NUMBER;
763
+ break;
764
+ case 'promote':
765
+ case 'demote':
766
+ case 'remove':
767
+ case 'add':
768
+ case 'leave':
769
+ const stubType = `GROUP_PARTICIPANT_${child.tag.toUpperCase()}`;
770
+ msg.messageStubType = WAMessageStubType[stubType];
771
+ const participants = getBinaryNodeChildren(child, 'participant').map(({ attrs }) => {
772
+ // TODO: Store LID MAPPINGS
773
+ return {
774
+ id: attrs.jid,
775
+ phoneNumber: isLidUser(attrs.jid) && isPnUser(attrs.phone_number) ? attrs.phone_number : undefined,
776
+ lid: isPnUser(attrs.jid) && isLidUser(attrs.lid) ? attrs.lid : undefined,
777
+ username: attrs.participant_username || attrs.username || undefined,
778
+ admin: (attrs.type || null)
779
+ };
780
+ });
781
+ if (participants.length === 1 &&
782
+ // if recv. "remove" message and sender removed themselves
783
+ // mark as left
784
+ (areJidsSameUser(participants[0].id, actingParticipantLid) ||
785
+ areJidsSameUser(participants[0].id, actingParticipantPn)) &&
786
+ child.tag === 'remove') {
787
+ msg.messageStubType = WAMessageStubType.GROUP_PARTICIPANT_LEAVE;
788
+ }
789
+ msg.messageStubParameters = participants.map(a => JSON.stringify(a));
790
+ break;
791
+ case 'subject':
792
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_SUBJECT;
793
+ msg.messageStubParameters = [child.attrs.subject];
794
+ break;
795
+ case 'description':
796
+ const description = getBinaryNodeChild(child, 'body')?.content?.toString();
797
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_DESCRIPTION;
798
+ msg.messageStubParameters = description ? [description] : undefined;
799
+ break;
800
+ case 'announcement':
801
+ case 'not_announcement':
802
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_ANNOUNCE;
803
+ msg.messageStubParameters = [child.tag === 'announcement' ? 'on' : 'off'];
804
+ break;
805
+ case 'locked':
806
+ case 'unlocked':
807
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_RESTRICT;
808
+ msg.messageStubParameters = [child.tag === 'locked' ? 'on' : 'off'];
809
+ break;
810
+ case 'invite':
811
+ msg.messageStubType = WAMessageStubType.GROUP_CHANGE_INVITE_LINK;
812
+ msg.messageStubParameters = [child.attrs.code];
813
+ break;
814
+ case 'member_add_mode':
815
+ const addMode = child.content;
816
+ if (addMode) {
817
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBER_ADD_MODE;
818
+ msg.messageStubParameters = [addMode.toString()];
819
+ }
820
+ break;
821
+ case 'membership_approval_mode':
822
+ const approvalMode = getBinaryNodeChild(child, 'group_join');
823
+ if (approvalMode) {
824
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_MODE;
825
+ msg.messageStubParameters = [approvalMode.attrs.state];
826
+ }
827
+ break;
828
+ case 'created_membership_requests':
829
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD;
830
+ msg.messageStubParameters = [
831
+ JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
832
+ 'created',
833
+ child.attrs.request_method
834
+ ];
835
+ break;
836
+ case 'revoked_membership_requests':
837
+ const isDenied = areJidsSameUser(affectedParticipantLid, actingParticipantLid);
838
+ // TODO: LIDMAPPING SUPPORT
839
+ msg.messageStubType = WAMessageStubType.GROUP_MEMBERSHIP_JOIN_APPROVAL_REQUEST_NON_ADMIN_ADD;
840
+ msg.messageStubParameters = [
841
+ JSON.stringify({ lid: affectedParticipantLid, pn: affectedParticipantPn }),
842
+ isDenied ? 'revoked' : 'rejected'
843
+ ];
844
+ break;
845
+ }
846
+ };
847
+ const handleDevicesNotification = async (node) => {
848
+ const [child] = getAllBinaryNodeChildren(node);
849
+ const from = jidNormalizedUser(node.attrs.from);
850
+ if (!child) {
851
+ logger.debug({ from }, 'devices notification missing child, skipping');
852
+ return;
853
+ }
854
+ const tag = child.tag;
855
+ const deviceHash = child.attrs.device_hash;
856
+ const devices = getBinaryNodeChildren(child, 'device');
857
+ if (areJidsSameUser(from, authState.creds.me.id) || areJidsSameUser(from, authState.creds.me.lid)) {
858
+ const deviceJids = devices.map(d => d.attrs.jid);
859
+ logger.info({ deviceJids }, 'got my own devices');
860
+ }
861
+ if (!devices.length) {
862
+ logger.debug({ from, tag }, 'no devices in notification, skipping');
863
+ return;
864
+ }
865
+ const decoded = [];
866
+ for (const d of devices) {
867
+ const jid = d.attrs.jid;
868
+ if (!jid)
869
+ continue;
870
+ const parts = jidDecode(jid);
871
+ if (!parts) {
872
+ logger.debug({ jid }, 'failed to decode device jid, skipping');
873
+ continue;
874
+ }
875
+ decoded.push({ jid, user: parts.user, server: parts.server, device: parts.device });
876
+ }
877
+ if (!decoded.length)
878
+ return;
879
+ await devicesMutex.mutex(async () => {
880
+ const byUser = new Map();
881
+ for (const d of decoded) {
882
+ const list = byUser.get(d.user) || [];
883
+ list.push(d);
884
+ byUser.set(d.user, list);
885
+ }
886
+ for (const [user, entries] of byUser) {
887
+ if (tag === 'update') {
888
+ logger.debug({ user }, `${user}'s device list updated, dropping cached devices`);
889
+ await userDevicesCache?.del(user);
890
+ continue;
891
+ }
892
+ if (tag === 'remove') {
893
+ await signalRepository.deleteSession(entries.map(e => e.jid));
894
+ }
895
+ const existingCache = (await userDevicesCache?.get(user)) || [];
896
+ if (!existingCache.length) {
897
+ // No baseline yet; skip applying the delta so getUSyncDevices can
898
+ // later fetch the full device list. Caching just the notification
899
+ // entries would make a partial list look authoritative.
900
+ logger.debug({ user, tag }, 'device list not cached, deferring to USync refresh');
901
+ continue;
902
+ }
903
+ const affected = new Set(entries.map(e => e.device));
904
+ let updatedDevices;
905
+ switch (tag) {
906
+ case 'add':
907
+ logger.info({ deviceHash, count: entries.length }, 'devices added');
908
+ updatedDevices = [
909
+ ...existingCache.filter(d => !affected.has(d.device)),
910
+ ...entries.map(e => ({ user: e.user, server: e.server, device: e.device }))
911
+ ];
912
+ break;
913
+ case 'remove':
914
+ logger.info({ deviceHash, count: entries.length }, 'devices removed');
915
+ updatedDevices = existingCache.filter(d => !affected.has(d.device));
916
+ break;
917
+ default:
918
+ logger.debug({ tag }, 'Unknown device list change tag');
919
+ continue;
920
+ }
921
+ if (updatedDevices.length === 0) {
922
+ await userDevicesCache?.del(user);
923
+ }
924
+ else {
925
+ await userDevicesCache?.set(user, updatedDevices);
926
+ }
927
+ }
928
+ });
929
+ };
930
+ const processNotification = async (node) => {
931
+ const result = {};
932
+ const [child] = getAllBinaryNodeChildren(node);
933
+ const nodeType = node.attrs.type;
934
+ const from = jidNormalizedUser(node.attrs.from);
935
+ switch (nodeType) {
936
+ case 'newsletter':
937
+ await handleNewsletterNotification(node);
938
+ break;
939
+ case 'mex':
940
+ await handleMexNotification(node);
941
+ break;
942
+ case 'w:gp2':
943
+ // TODO: HANDLE PARTICIPANT_PN
944
+ handleGroupNotification(node, child, result);
945
+ break;
946
+ case 'mediaretry':
947
+ const event = decodeMediaRetryNode(node);
948
+ ev.emit('messages.media-update', [event]);
949
+ break;
950
+ case 'encrypt':
951
+ await handleEncryptNotification(node);
952
+ break;
953
+ case 'devices':
954
+ try {
955
+ await handleDevicesNotification(node);
956
+ }
957
+ catch (error) {
958
+ logger.error({ error, node }, 'failed to handle devices notification');
959
+ }
960
+ break;
961
+ case 'server_sync':
962
+ const update = getBinaryNodeChild(node, 'collection');
963
+ if (update) {
964
+ const name = update.attrs.name;
965
+ await resyncAppState([name], false);
966
+ }
967
+ break;
968
+ case 'picture':
969
+ const setPicture = getBinaryNodeChild(node, 'set');
970
+ const delPicture = getBinaryNodeChild(node, 'delete');
971
+ // TODO: WAJIDHASH stuff proper support inhouse
972
+ ev.emit('contacts.update', [
973
+ {
974
+ id: jidNormalizedUser(node?.attrs?.from) || (setPicture || delPicture)?.attrs?.hash || '',
975
+ imgUrl: setPicture ? 'changed' : 'removed'
976
+ }
977
+ ]);
978
+ if (isJidGroup(from)) {
979
+ const node = setPicture || delPicture;
980
+ result.messageStubType = WAMessageStubType.GROUP_CHANGE_ICON;
981
+ if (setPicture) {
982
+ result.messageStubParameters = [setPicture.attrs.id];
983
+ }
984
+ result.participant = node?.attrs.author;
985
+ result.key = {
986
+ ...(result.key || {}),
987
+ participant: setPicture?.attrs.author
988
+ };
989
+ }
990
+ break;
991
+ case 'account_sync':
992
+ if (child.tag === 'disappearing_mode') {
993
+ const newDuration = +child.attrs.duration;
994
+ const timestamp = +child.attrs.t;
995
+ logger.info({ newDuration }, 'updated account disappearing mode');
996
+ ev.emit('creds.update', {
997
+ accountSettings: {
998
+ ...authState.creds.accountSettings,
999
+ defaultDisappearingMode: {
1000
+ ephemeralExpiration: newDuration,
1001
+ ephemeralSettingTimestamp: timestamp
1002
+ }
1003
+ }
1004
+ });
1005
+ }
1006
+ else if (child.tag === 'blocklist') {
1007
+ const blocklists = getBinaryNodeChildren(child, 'item');
1008
+ for (const { attrs } of blocklists) {
1009
+ const blocklist = [attrs.jid];
1010
+ const type = attrs.action === 'block' ? 'add' : 'remove';
1011
+ ev.emit('blocklist.update', { blocklist, type });
1012
+ }
1013
+ }
1014
+ break;
1015
+ case 'link_code_companion_reg':
1016
+ const linkCodeCompanionReg = getBinaryNodeChild(node, 'link_code_companion_reg');
1017
+ const ref = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_ref'));
1018
+ const primaryIdentityPublicKey = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'primary_identity_pub'));
1019
+ const primaryEphemeralPublicKeyWrapped = toRequiredBuffer(getBinaryNodeChildBuffer(linkCodeCompanionReg, 'link_code_pairing_wrapped_primary_ephemeral_pub'));
1020
+ const codePairingPublicKey = await decipherLinkPublicKey(primaryEphemeralPublicKeyWrapped);
1021
+ const companionSharedKey = Curve.sharedKey(authState.creds.pairingEphemeralKeyPair.private, codePairingPublicKey);
1022
+ const random = randomBytes(32);
1023
+ const linkCodeSalt = randomBytes(32);
1024
+ const linkCodePairingExpanded = hkdf(companionSharedKey, 32, {
1025
+ salt: linkCodeSalt,
1026
+ info: 'link_code_pairing_key_bundle_encryption_key'
1027
+ });
1028
+ const encryptPayload = Buffer.concat([
1029
+ Buffer.from(authState.creds.signedIdentityKey.public),
1030
+ primaryIdentityPublicKey,
1031
+ random
1032
+ ]);
1033
+ const encryptIv = randomBytes(12);
1034
+ const encrypted = aesEncryptGCM(encryptPayload, linkCodePairingExpanded, encryptIv, Buffer.alloc(0));
1035
+ const encryptedPayload = Buffer.concat([linkCodeSalt, encryptIv, encrypted]);
1036
+ const identitySharedKey = Curve.sharedKey(authState.creds.signedIdentityKey.private, primaryIdentityPublicKey);
1037
+ const identityPayload = Buffer.concat([companionSharedKey, identitySharedKey, random]);
1038
+ authState.creds.advSecretKey = Buffer.from(hkdf(identityPayload, 32, { info: 'adv_secret' })).toString('base64');
1039
+ await query({
1040
+ tag: 'iq',
1041
+ attrs: {
1042
+ to: S_WHATSAPP_NET,
1043
+ type: 'set',
1044
+ id: sock.generateMessageTag(),
1045
+ xmlns: 'md'
1046
+ },
1047
+ content: [
1048
+ {
1049
+ tag: 'link_code_companion_reg',
1050
+ attrs: {
1051
+ jid: authState.creds.me.id,
1052
+ stage: 'companion_finish'
1053
+ },
1054
+ content: [
1055
+ {
1056
+ tag: 'link_code_pairing_wrapped_key_bundle',
1057
+ attrs: {},
1058
+ content: encryptedPayload
1059
+ },
1060
+ {
1061
+ tag: 'companion_identity_public',
1062
+ attrs: {},
1063
+ content: authState.creds.signedIdentityKey.public
1064
+ },
1065
+ {
1066
+ tag: 'link_code_pairing_ref',
1067
+ attrs: {},
1068
+ content: ref
1069
+ }
1070
+ ]
1071
+ }
1072
+ ]
1073
+ });
1074
+ authState.creds.registered = true;
1075
+ ev.emit('creds.update', authState.creds);
1076
+ break;
1077
+ case 'privacy_token':
1078
+ await handlePrivacyTokenNotification(node);
1079
+ break;
1080
+ }
1081
+ if (Object.keys(result).length) {
1082
+ return result;
1083
+ }
1084
+ };
1085
+ /**
1086
+ * In-memory cache of storage JIDs with stored tctokens, seeded from the persisted index.
1087
+ * Used to coalesce writes during a session; pruning always re-reads the persisted index
1088
+ * to cover writes made by other layers (e.g. history sync).
1089
+ */
1090
+ const tcTokenKnownJids = new Set();
1091
+ const tcTokenIndexLoaded = (async () => {
1092
+ try {
1093
+ const jids = await readTcTokenIndex(authState.keys);
1094
+ for (const jid of jids)
1095
+ tcTokenKnownJids.add(jid);
1096
+ logger.debug({ count: tcTokenKnownJids.size }, 'loaded tctoken index');
1097
+ }
1098
+ catch (err) {
1099
+ logger.warn({ err: err?.message }, 'failed to load tctoken index');
1100
+ }
1101
+ })();
1102
+ let tcTokenIndexTimer;
1103
+ async function flushTcTokenIndex() {
1104
+ if (tcTokenIndexTimer) {
1105
+ clearTimeout(tcTokenIndexTimer);
1106
+ tcTokenIndexTimer = undefined;
1107
+ }
1108
+ // Merge with whatever is already persisted so we don't clobber writes from other
1109
+ // paths (history sync, concurrent sessions on the same store).
1110
+ const write = await buildMergedTcTokenIndexWrite(authState.keys, tcTokenKnownJids);
1111
+ return authState.keys.set({ tctoken: write });
1112
+ }
1113
+ function scheduleTcTokenIndexSave() {
1114
+ if (tcTokenIndexTimer) {
1115
+ clearTimeout(tcTokenIndexTimer);
1116
+ }
1117
+ tcTokenIndexTimer = setTimeout(() => {
1118
+ tcTokenIndexTimer = undefined;
1119
+ flushTcTokenIndex().catch(err => {
1120
+ logger.warn({ err: err?.message }, 'failed to save tctoken index');
1121
+ });
1122
+ }, 5000);
1123
+ }
1124
+ function trackTcTokenJid(jid) {
1125
+ if (jid && jid !== TC_TOKEN_INDEX_KEY && !tcTokenKnownJids.has(jid)) {
1126
+ tcTokenKnownJids.add(jid);
1127
+ scheduleTcTokenIndexSave();
1128
+ }
1129
+ }
1130
+ const handlePrivacyTokenNotification = async (node) => {
1131
+ const tokensNode = getBinaryNodeChild(node, 'tokens');
1132
+ if (!tokensNode)
1133
+ return;
1134
+ const from = jidNormalizedUser(node.attrs.from);
1135
+ // WA Web uses: senderLid ?? toLid(from) for the storage key
1136
+ // The sender_lid attribute provides the LID directly when available
1137
+ const senderLid = node.attrs.sender_lid && isLidUser(jidNormalizedUser(node.attrs.sender_lid))
1138
+ ? jidNormalizedUser(node.attrs.sender_lid)
1139
+ : undefined;
1140
+ const fallbackJid = senderLid ?? (await resolveTcTokenJid(from, getLIDForPN));
1141
+ logger.debug({ from, storageJid: fallbackJid }, 'processing privacy token notification');
1142
+ await storeTcTokensFromIqResult({
1143
+ result: node,
1144
+ fallbackJid,
1145
+ keys: authState.keys,
1146
+ getLIDForPN,
1147
+ onNewJidStored: trackTcTokenJid
1148
+ });
1149
+ };
1150
+ async function decipherLinkPublicKey(data) {
1151
+ const buffer = toRequiredBuffer(data);
1152
+ const salt = buffer.slice(0, 32);
1153
+ const secretKey = await derivePairingCodeKey(authState.creds.pairingCode, salt);
1154
+ const iv = buffer.slice(32, 48);
1155
+ const payload = buffer.slice(48, 80);
1156
+ return aesDecryptCTR(payload, secretKey, iv);
1157
+ }
1158
+ function toRequiredBuffer(data) {
1159
+ if (data === undefined) {
1160
+ throw new Boom('Invalid buffer', { statusCode: 400 });
1161
+ }
1162
+ return data instanceof Buffer ? data : Buffer.from(data);
1163
+ }
1164
+ const willSendMessageAgain = async (id, participant) => {
1165
+ const key = `${id}:${participant}`;
1166
+ const retryCount = (await msgRetryCache.get(key)) || 0;
1167
+ return retryCount < maxMsgRetryCount;
1168
+ };
1169
+ const updateSendMessageAgainCount = async (id, participant) => {
1170
+ const key = `${id}:${participant}`;
1171
+ const newValue = ((await msgRetryCache.get(key)) || 0) + 1;
1172
+ await msgRetryCache.set(key, newValue);
1173
+ };
1174
+ const sendMessagesAgain = async (key, ids, retryNode, receiptNode) => {
1175
+ const remoteJid = key.remoteJid;
1176
+ const participant = key.participant || remoteJid;
1177
+ const retryCount = +retryNode.attrs.count || 1;
1178
+ const msgId = ids[0];
1179
+ // Try to get messages from cache first, then fallback to getMessage
1180
+ const msgs = [];
1181
+ for (const id of ids) {
1182
+ let msg;
1183
+ // Try to get from retry cache first if enabled
1184
+ if (messageRetryManager) {
1185
+ const cachedMsg = messageRetryManager.getRecentMessage(remoteJid, id);
1186
+ if (cachedMsg) {
1187
+ msg = cachedMsg.message;
1188
+ logger.debug({ jid: remoteJid, id }, 'found message in retry cache');
1189
+ // Mark retry as successful since we found the message
1190
+ messageRetryManager.markRetrySuccess(id);
1191
+ }
1192
+ }
1193
+ // Fallback to getMessage if not found in cache
1194
+ if (!msg) {
1195
+ msg = await getMessage({ ...key, id });
1196
+ if (msg) {
1197
+ logger.debug({ jid: remoteJid, id }, 'found message via getMessage');
1198
+ // Also mark as successful if found via getMessage
1199
+ if (messageRetryManager) {
1200
+ messageRetryManager.markRetrySuccess(id);
1201
+ }
1202
+ }
1203
+ }
1204
+ msgs.push(msg);
1205
+ }
1206
+ // if it's the primary jid sending the request
1207
+ // just re-send the message to everyone
1208
+ // prevents the first message decryption failure
1209
+ const sendToAll = !jidDecode(participant)?.device;
1210
+ const sessionId = signalRepository.jidToSignalProtocolAddress(participant);
1211
+ let injectedFromBundle = false;
1212
+ const bundle = extractE2ESessionFromRetryReceipt(receiptNode);
1213
+ if (bundle) {
1214
+ try {
1215
+ await signalRepository.injectE2ESession({ jid: participant, session: bundle });
1216
+ injectedFromBundle = true;
1217
+ logger.debug({ participant, retryCount }, 'injected session from retry receipt key bundle');
1218
+ }
1219
+ catch (error) {
1220
+ logger.warn({ error, participant }, 'failed to inject session from retry receipt');
1221
+ }
1222
+ }
1223
+ if (!injectedFromBundle) {
1224
+ const receivedRegId = getBinaryNodeChildUInt(receiptNode, 'registration', 4);
1225
+ if (typeof receivedRegId === 'number' && Number.isInteger(receivedRegId)) {
1226
+ const info = await signalRepository.getSessionInfo(participant);
1227
+ if (info && info.registrationId !== 0 && info.registrationId !== receivedRegId) {
1228
+ logger.info({ participant, stored: info.registrationId, received: receivedRegId }, 'reg id mismatch on retry without bundle, deleting session');
1229
+ await authState.keys.set({ session: { [sessionId]: null } });
1230
+ }
1231
+ }
1232
+ }
1233
+ const BASE_KEY_CHECK_RETRY = 2;
1234
+ if (msgId && messageRetryManager) {
1235
+ const info = await signalRepository.getSessionInfo(participant);
1236
+ if (info) {
1237
+ if (retryCount === BASE_KEY_CHECK_RETRY) {
1238
+ messageRetryManager.saveBaseKey(sessionId, msgId, info.baseKey);
1239
+ }
1240
+ else if (retryCount > BASE_KEY_CHECK_RETRY) {
1241
+ if (messageRetryManager.hasSameBaseKey(sessionId, msgId, info.baseKey)) {
1242
+ logger.warn({ participant, retryCount }, 'base key collision on retry, forcing fresh session');
1243
+ await authState.keys.set({ session: { [sessionId]: null } });
1244
+ }
1245
+ messageRetryManager.deleteBaseKey(sessionId, msgId);
1246
+ }
1247
+ }
1248
+ }
1249
+ let shouldRecreateSession = false;
1250
+ let recreateReason = '';
1251
+ if (enableAutoSessionRecreation && messageRetryManager && retryCount > 1 && !injectedFromBundle) {
1252
+ try {
1253
+ const hasSession = await signalRepository.validateSession(participant);
1254
+ const result = messageRetryManager.shouldRecreateSession(participant, hasSession.exists);
1255
+ shouldRecreateSession = result.recreate;
1256
+ recreateReason = result.reason;
1257
+ if (shouldRecreateSession) {
1258
+ logger.debug({ participant, retryCount, reason: recreateReason }, 'recreating session for outgoing retry');
1259
+ await authState.keys.set({ session: { [sessionId]: null } });
1260
+ }
1261
+ }
1262
+ catch (error) {
1263
+ logger.warn({ error, participant }, 'failed to check session recreation for outgoing retry');
1264
+ }
1265
+ }
1266
+ if (!injectedFromBundle) {
1267
+ await assertSessions([participant], true);
1268
+ }
1269
+ if (isJidGroup(remoteJid)) {
1270
+ await authState.keys.set({ 'sender-key-memory': { [remoteJid]: null } });
1271
+ }
1272
+ logger.debug({ participant, sendToAll, shouldRecreateSession, recreateReason, injectedFromBundle }, 'prepared session for retry resend');
1273
+ for (const [i, msg] of msgs.entries()) {
1274
+ if (!ids[i])
1275
+ continue;
1276
+ if (msg && (await willSendMessageAgain(ids[i], participant))) {
1277
+ await updateSendMessageAgainCount(ids[i], participant);
1278
+ const msgRelayOpts = { messageId: ids[i] };
1279
+ if (sendToAll) {
1280
+ msgRelayOpts.useUserDevicesCache = false;
1281
+ }
1282
+ else {
1283
+ msgRelayOpts.participant = {
1284
+ jid: participant,
1285
+ count: +retryNode.attrs.count
1286
+ };
1287
+ }
1288
+ await relayMessage(key.remoteJid, msg, msgRelayOpts);
1289
+ }
1290
+ else {
1291
+ logger.debug({ jid: key.remoteJid, id: ids[i] }, 'recv retry request, but message not available');
1292
+ }
1293
+ }
1294
+ };
1295
+ const handleReceipt = async (node) => {
1296
+ const { attrs, content } = node;
1297
+ const isLid = attrs.from.includes('lid');
1298
+ const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, isLid ? authState.creds.me?.lid : authState.creds.me?.id);
1299
+ const remoteJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient;
1300
+ const fromMe = !attrs.recipient || ((attrs.type === 'retry' || attrs.type === 'sender') && isNodeFromMe);
1301
+ const key = {
1302
+ remoteJid,
1303
+ id: '',
1304
+ fromMe,
1305
+ participant: attrs.participant
1306
+ };
1307
+ const ids = [attrs.id];
1308
+ if (Array.isArray(content)) {
1309
+ const items = getBinaryNodeChildren(content[0], 'item');
1310
+ ids.push(...items.map(i => i.attrs.id));
1311
+ }
1312
+ try {
1313
+ await Promise.all([
1314
+ receiptMutex.mutex(async () => {
1315
+ const status = getStatusFromReceiptType(attrs.type);
1316
+ if (typeof status !== 'undefined' &&
1317
+ // basically, we only want to know when a message from us has been delivered to/read by the other person
1318
+ // or another device of ours has read some messages
1319
+ (status >= proto.WebMessageInfo.Status.SERVER_ACK || !isNodeFromMe)) {
1320
+ if (isJidGroup(remoteJid) || isJidStatusBroadcast(remoteJid)) {
1321
+ if (attrs.participant) {
1322
+ const updateKey = status === proto.WebMessageInfo.Status.DELIVERY_ACK ? 'receiptTimestamp' : 'readTimestamp';
1323
+ ev.emit('message-receipt.update', ids.map(id => ({
1324
+ key: { ...key, id },
1325
+ receipt: {
1326
+ userJid: jidNormalizedUser(attrs.participant),
1327
+ [updateKey]: +attrs.t
1328
+ }
1329
+ })));
1330
+ }
1331
+ }
1332
+ else {
1333
+ ev.emit('messages.update', ids.map(id => ({
1334
+ key: { ...key, id },
1335
+ update: { status, messageTimestamp: toNumber(+(attrs.t ?? 0)) }
1336
+ })));
1337
+ }
1338
+ }
1339
+ if (attrs.type === 'retry') {
1340
+ // correctly set who is asking for the retry
1341
+ key.participant = key.participant || attrs.from;
1342
+ const retryNode = getBinaryNodeChild(node, 'retry');
1343
+ if (ids[0] && key.participant && (await willSendMessageAgain(ids[0], key.participant))) {
1344
+ if (key.fromMe) {
1345
+ try {
1346
+ await updateSendMessageAgainCount(ids[0], key.participant);
1347
+ logger.debug({ attrs, key }, 'recv retry request');
1348
+ await sendMessagesAgain(key, ids, retryNode, node);
1349
+ }
1350
+ catch (error) {
1351
+ logger.error({ key, ids, trace: error instanceof Error ? error.stack : 'Unknown error' }, 'error in sending message again');
1352
+ }
1353
+ }
1354
+ else {
1355
+ logger.info({ attrs, key }, 'recv retry for not fromMe message');
1356
+ }
1357
+ }
1358
+ else {
1359
+ logger.info({ attrs, key }, 'will not send message again, as sent too many times');
1360
+ }
1361
+ }
1362
+ })
1363
+ ]);
1364
+ }
1365
+ finally {
1366
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack receipt'));
1367
+ }
1368
+ };
1369
+ const handleNotification = async (node) => {
1370
+ const remoteJid = node.attrs.from;
1371
+ try {
1372
+ await Promise.all([
1373
+ notificationMutex.mutex(async () => {
1374
+ const msg = await processNotification(node);
1375
+ if (msg) {
1376
+ const fromMe = areJidsSameUser(node.attrs.participant || remoteJid, authState.creds.me.id);
1377
+ const { senderAlt: participantAlt, addressingMode } = extractAddressingContext(node);
1378
+ msg.key = {
1379
+ remoteJid,
1380
+ fromMe,
1381
+ participant: node.attrs.participant,
1382
+ participantAlt,
1383
+ participantUsername: node.attrs.participant_username,
1384
+ addressingMode,
1385
+ id: node.attrs.id,
1386
+ ...(msg.key || {})
1387
+ };
1388
+ msg.participant ?? (msg.participant = node.attrs.participant);
1389
+ msg.messageTimestamp = +node.attrs.t;
1390
+ const fullMsg = proto.WebMessageInfo.fromObject(msg);
1391
+ await upsertMessage(fullMsg, 'append');
1392
+ }
1393
+ })
1394
+ ]);
1395
+ }
1396
+ finally {
1397
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack notification'));
1398
+ }
1399
+ };
1400
+ const handleMessage = async (node) => {
1401
+ const encNode = getBinaryNodeChild(node, 'enc');
1402
+ // TODO: temporary fix for crashes and issues resulting of failed msmsg decryption
1403
+ if (encNode?.attrs.type === 'msmsg') {
1404
+ logger.debug({ key: node.attrs.key }, 'ignored msmsg');
1405
+ await sendMessageAck(node, NACK_REASONS.MissingMessageSecret);
1406
+ return;
1407
+ }
1408
+ let acked = false;
1409
+ try {
1410
+ const { fullMessage: msg, category, author, decrypt } = decryptMessageNode(node, authState.creds.me.id, authState.creds.me.lid || '', signalRepository, logger);
1411
+ const alt = msg.key.participantAlt || msg.key.remoteJidAlt;
1412
+ // store new mappings we didn't have before
1413
+ if (!!alt) {
1414
+ const altServer = jidDecode(alt)?.server;
1415
+ const primaryJid = msg.key.participant || msg.key.remoteJid;
1416
+ if (altServer === 'lid') {
1417
+ if (!(await signalRepository.lidMapping.getPNForLID(alt))) {
1418
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: alt, pn: primaryJid }]);
1419
+ await signalRepository.migrateSession(primaryJid, alt);
1420
+ }
1421
+ }
1422
+ else {
1423
+ await signalRepository.lidMapping.storeLIDPNMappings([{ lid: primaryJid, pn: alt }]);
1424
+ await signalRepository.migrateSession(alt, primaryJid);
1425
+ }
1426
+ }
1427
+ await messageMutex.mutex(async () => {
1428
+ await decrypt();
1429
+ if (msg.key?.remoteJid && msg.key?.id && msg.message && messageRetryManager) {
1430
+ messageRetryManager.addRecentMessage(msg.key.remoteJid, msg.key.id, msg.message);
1431
+ }
1432
+ // message failed to decrypt
1433
+ if (msg.messageStubType === proto.WebMessageInfo.StubType.CIPHERTEXT && msg.category !== 'peer') {
1434
+ if (msg?.messageStubParameters?.[0] === MISSING_KEYS_ERROR_TEXT) {
1435
+ acked = true;
1436
+ return sendMessageAck(node, NACK_REASONS.ParsingError);
1437
+ }
1438
+ if (msg.messageStubParameters?.[0] === NO_MESSAGE_FOUND_ERROR_TEXT) {
1439
+ // Message arrived without encryption (e.g. CTWA ads messages).
1440
+ // Check if this is eligible for placeholder resend (matching WA Web filters).
1441
+ const unavailableNode = getBinaryNodeChild(node, 'unavailable');
1442
+ const unavailableType = unavailableNode?.attrs?.type;
1443
+ if (unavailableType === 'bot_unavailable_fanout' ||
1444
+ unavailableType === 'hosted_unavailable_fanout' ||
1445
+ unavailableType === 'view_once_unavailable_fanout') {
1446
+ logger.debug({ msgId: msg.key.id, unavailableType }, 'skipping placeholder resend for excluded unavailable type');
1447
+ acked = true;
1448
+ return sendMessageAck(node);
1449
+ }
1450
+ const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp);
1451
+ if (messageAge > PLACEHOLDER_MAX_AGE_SECONDS) {
1452
+ logger.debug({ msgId: msg.key.id, messageAge }, 'skipping placeholder resend for old message');
1453
+ acked = true;
1454
+ return sendMessageAck(node);
1455
+ }
1456
+ // Request the real content from the phone via placeholder resend PDO.
1457
+ // Upsert the CIPHERTEXT stub as a placeholder (like WA Web's processPlaceholderMsg),
1458
+ // and store the requestId in stubParameters[1] so users can correlate
1459
+ // with the incoming PDO response event.
1460
+ const cleanKey = {
1461
+ remoteJid: msg.key.remoteJid,
1462
+ fromMe: msg.key.fromMe,
1463
+ id: msg.key.id,
1464
+ participant: msg.key.participant
1465
+ };
1466
+ // Cache the original message metadata so the PDO response handler
1467
+ // can preserve key fields (LID details etc.) that the phone may omit
1468
+ const msgData = {
1469
+ key: msg.key,
1470
+ messageTimestamp: msg.messageTimestamp,
1471
+ pushName: msg.pushName,
1472
+ participant: msg.participant,
1473
+ verifiedBizName: msg.verifiedBizName
1474
+ };
1475
+ requestPlaceholderResend(cleanKey, msgData)
1476
+ .then(requestId => {
1477
+ if (requestId && requestId !== 'RESOLVED') {
1478
+ logger.debug({ msgId: msg.key.id, requestId }, 'requested placeholder resend for unavailable message');
1479
+ ev.emit('messages.update', [
1480
+ {
1481
+ key: msg.key,
1482
+ update: { messageStubParameters: [NO_MESSAGE_FOUND_ERROR_TEXT, requestId] }
1483
+ }
1484
+ ]);
1485
+ }
1486
+ })
1487
+ .catch(err => {
1488
+ logger.warn({ err, msgId: msg.key.id }, 'failed to request placeholder resend for unavailable message');
1489
+ });
1490
+ acked = true;
1491
+ await sendMessageAck(node);
1492
+ // Don't return — fall through to upsertMessage so the stub is emitted
1493
+ }
1494
+ else {
1495
+ // Skip retry for expired status messages (>24h old)
1496
+ if (isJidStatusBroadcast(msg.key.remoteJid)) {
1497
+ const messageAge = unixTimestampSeconds() - toNumber(msg.messageTimestamp);
1498
+ if (messageAge > STATUS_EXPIRY_SECONDS) {
1499
+ logger.debug({ msgId: msg.key.id, messageAge, remoteJid: msg.key.remoteJid }, 'skipping retry for expired status message');
1500
+ acked = true;
1501
+ return sendMessageAck(node);
1502
+ }
1503
+ }
1504
+ logger.debug('[handleMessage] Attempting retry request for failed decryption');
1505
+ // WAWeb only retry-receipts here; server emits PreKeyLow if prekeys run low.
1506
+ await retryMutex.mutex(async () => {
1507
+ try {
1508
+ if (!ws.isOpen) {
1509
+ logger.debug({ node }, 'Connection closed, skipping retry');
1510
+ return;
1511
+ }
1512
+ const encNode = getBinaryNodeChild(node, 'enc');
1513
+ await sendRetryRequest(node, !encNode);
1514
+ if (retryRequestDelayMs) {
1515
+ await delay(retryRequestDelayMs);
1516
+ }
1517
+ }
1518
+ catch (err) {
1519
+ logger.error({ err }, 'Failed to send retry');
1520
+ }
1521
+ acked = true;
1522
+ await sendMessageAck(node, NACK_REASONS.UnhandledError);
1523
+ });
1524
+ }
1525
+ }
1526
+ else {
1527
+ if (messageRetryManager && msg.key.id) {
1528
+ messageRetryManager.cancelPendingPhoneRequest(msg.key.id);
1529
+ }
1530
+ const isNewsletter = isJidNewsletter(msg.key.remoteJid);
1531
+ if (!isNewsletter) {
1532
+ // no type in the receipt => message delivered
1533
+ let type = undefined;
1534
+ let participant = msg.key.participant;
1535
+ if (category === 'peer') {
1536
+ // special peer message
1537
+ type = 'peer_msg';
1538
+ }
1539
+ else if (msg.key.fromMe) {
1540
+ // message was sent by us from a different device
1541
+ type = 'sender';
1542
+ // need to specially handle this case
1543
+ if (isLidUser(msg.key.remoteJid) || isLidUser(msg.key.remoteJidAlt)) {
1544
+ participant = author; // TODO: investigate sending receipts to LIDs and not PNs
1545
+ }
1546
+ }
1547
+ else if (!sendActiveReceipts) {
1548
+ type = 'inactive';
1549
+ }
1550
+ acked = true;
1551
+ await sendReceipt(msg.key.remoteJid, participant, [msg.key.id], type);
1552
+ // send ack for history message
1553
+ const isAnyHistoryMsg = getHistoryMsg(msg.message);
1554
+ if (isAnyHistoryMsg) {
1555
+ const jid = jidNormalizedUser(msg.key.remoteJid);
1556
+ await sendReceipt(jid, undefined, [msg.key.id], 'hist_sync'); // TODO: investigate
1557
+ }
1558
+ }
1559
+ else {
1560
+ acked = true;
1561
+ await sendMessageAck(node);
1562
+ logger.debug({ key: msg.key }, 'processed newsletter message without receipts');
1563
+ }
1564
+ }
1565
+ cleanMessage(msg, authState.creds.me.id, authState.creds.me.lid);
1566
+ await upsertMessage(msg, node.attrs.offline ? 'append' : 'notify');
1567
+ });
1568
+ }
1569
+ catch (error) {
1570
+ logger.error({ error, node: binaryNodeToString(node) }, 'error in handling message');
1571
+ if (!acked) {
1572
+ await sendMessageAck(node, NACK_REASONS.UnhandledError).catch(ackErr => logger.error({ ackErr }, 'failed to ack message after error'));
1573
+ }
1574
+ }
1575
+ };
1576
+ const handleCall = async (node) => {
1577
+ try {
1578
+ const { attrs } = node;
1579
+ const [infoChild] = getAllBinaryNodeChildren(node);
1580
+ if (!infoChild) {
1581
+ throw new Boom('Missing call info in call node');
1582
+ }
1583
+ const status = getCallStatusFromNode(infoChild);
1584
+ const callId = infoChild.attrs['call-id'];
1585
+ const from = infoChild.attrs.from || infoChild.attrs['call-creator'];
1586
+ const call = {
1587
+ chatId: attrs.from,
1588
+ from,
1589
+ callerPn: infoChild.attrs['caller_pn'],
1590
+ id: callId,
1591
+ date: new Date(+attrs.t * 1000),
1592
+ offline: !!attrs.offline,
1593
+ status
1594
+ };
1595
+ if (status === 'relaylatency') {
1596
+ const latencyValue = infoChild.attrs.latency || infoChild.attrs['latency_ms'] || infoChild.attrs['latency-ms'];
1597
+ const latencyMs = latencyValue ? Number(latencyValue) : undefined;
1598
+ if (Number.isFinite(latencyMs)) {
1599
+ call.latencyMs = latencyMs;
1600
+ }
1601
+ }
1602
+ if (status === 'offer') {
1603
+ call.isVideo = !!getBinaryNodeChild(infoChild, 'video');
1604
+ call.isGroup = infoChild.attrs.type === 'group' || !!infoChild.attrs['group-jid'];
1605
+ call.groupJid = infoChild.attrs['group-jid'];
1606
+ await callOfferCache.set(call.id, call);
1607
+ }
1608
+ const existingCall = await callOfferCache.get(call.id);
1609
+ // use existing call info to populate this event
1610
+ if (existingCall) {
1611
+ call.isVideo = existingCall.isVideo;
1612
+ call.isGroup = existingCall.isGroup;
1613
+ call.callerPn = call.callerPn || existingCall.callerPn;
1614
+ }
1615
+ // delete data once call has ended
1616
+ if (status === 'reject' || status === 'accept' || status === 'timeout' || status === 'terminate') {
1617
+ await callOfferCache.del(call.id);
1618
+ }
1619
+ ev.emit('call', [call]);
1620
+ }
1621
+ catch (error) {
1622
+ logger.error({ error, node: binaryNodeToString(node) }, 'error in handling call');
1623
+ }
1624
+ finally {
1625
+ await sendMessageAck(node).catch(ackErr => logger.error({ ackErr }, 'failed to ack call'));
1626
+ }
1627
+ };
1628
+ const handleBadAck = async ({ attrs }) => {
1629
+ const key = { remoteJid: attrs.from, fromMe: true, id: attrs.id };
1630
+ // WARNING: REFRAIN FROM ENABLING THIS FOR NOW. IT WILL CAUSE A LOOP
1631
+ // // current hypothesis is that if pash is sent in the ack
1632
+ // // it means -- the message hasn't reached all devices yet
1633
+ // // we'll retry sending the message here
1634
+ // if(attrs.phash) {
1635
+ // logger.info({ attrs }, 'received phash in ack, resending message...')
1636
+ // const msg = await getMessage(key)
1637
+ // if(msg) {
1638
+ // await relayMessage(key.remoteJid!, msg, { messageId: key.id!, useUserDevicesCache: false })
1639
+ // } else {
1640
+ // logger.warn({ attrs }, 'could not send message again, as it was not found')
1641
+ // }
1642
+ // }
1643
+ // error in acknowledgement,
1644
+ // device could not display the message
1645
+ if (attrs.error) {
1646
+ const isReachoutTimelocked = attrs.error === String(NACK_REASONS.SenderReachoutTimelocked);
1647
+ if (attrs.error === SERVER_ERROR_CODES.MessageAccountRestriction) {
1648
+ // 463 = 1:1 message missing privacy token (tctoken). Usually means the
1649
+ // account is restricted: WhatsApp blocks starting new chats but preserves
1650
+ // existing ones, since established chats already carry a tctoken.
1651
+ // WA Web prevents this client-side (disables the compose bar).
1652
+ // No retry — retrying counts as another "reach out" and worsens the restriction.
1653
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'error 463: account restricted or missing tctoken for contact');
1654
+ const ackFrom = attrs.from;
1655
+ if (ackFrom && !inFlight463Recoveries.has(ackFrom)) {
1656
+ inFlight463Recoveries.add(ackFrom);
1657
+ void (async () => {
1658
+ try {
1659
+ const getPNForLID = signalRepository.lidMapping.getPNForLID.bind(signalRepository.lidMapping);
1660
+ const tcStorageJid = await resolveTcTokenJid(ackFrom, getLIDForPN);
1661
+ const issueJid = await resolveIssuanceJid(ackFrom, sock.serverProps.lidTrustedTokenIssueToLid, getLIDForPN, getPNForLID);
1662
+ const result = await issuePrivacyTokens([issueJid], unixTimestampSeconds());
1663
+ await storeTcTokensFromIqResult({
1664
+ result,
1665
+ fallbackJid: tcStorageJid,
1666
+ keys: authState.keys,
1667
+ getLIDForPN,
1668
+ onNewJidStored: trackTcTokenJid
1669
+ });
1670
+ logger.debug({ from: ackFrom }, 'completed 463 token recovery issuance');
1671
+ }
1672
+ catch (err) {
1673
+ logger.debug({ from: ackFrom, err: err?.message }, 'failed 463 token recovery issuance');
1674
+ }
1675
+ finally {
1676
+ inFlight463Recoveries.delete(ackFrom);
1677
+ }
1678
+ })();
1679
+ }
1680
+ }
1681
+ else if (attrs.error === SERVER_ERROR_CODES.SmaxInvalid) {
1682
+ logger.warn({ msgId: attrs.id, from: attrs.from }, 'smax-invalid (479): stanza rejected by server — likely stale device session or malformed addressing');
1683
+ }
1684
+ else if (isReachoutTimelocked) {
1685
+ // user is temporarily restricted, fetch current restriction details
1686
+ await fetchAccountReachoutTimelock().catch(err => logger.warn({ err }, 'failed to fetch reachout timelock'));
1687
+ logger.warn({ attrs }, 'received error in ack');
1688
+ }
1689
+ else {
1690
+ logger.warn({ attrs }, 'received error in ack');
1691
+ }
1692
+ ev.emit('messages.update', [
1693
+ {
1694
+ key,
1695
+ update: {
1696
+ status: WAMessageStatus.ERROR,
1697
+ messageStubParameters: isReachoutTimelocked ? [attrs.error, ACCOUNT_RESTRICTED_TEXT] : [attrs.error]
1698
+ }
1699
+ }
1700
+ ]);
1701
+ }
1702
+ };
1703
+ /// processes a node with the given function
1704
+ /// and adds the task to the existing buffer if we're buffering events
1705
+ const processNodeWithBuffer = async (node, identifier, exec) => {
1706
+ ev.buffer();
1707
+ await execTask();
1708
+ ev.flush();
1709
+ function execTask() {
1710
+ return exec(node, false).catch(err => onUnexpectedError(err, identifier));
1711
+ }
1712
+ };
1713
+ const offlineNodeProcessor = makeOfflineNodeProcessor(new Map([
1714
+ ['message', handleMessage],
1715
+ ['call', handleCall],
1716
+ ['receipt', handleReceipt],
1717
+ ['notification', handleNotification]
1718
+ ]), {
1719
+ isWsOpen: () => ws.isOpen,
1720
+ onUnexpectedError,
1721
+ yieldToEventLoop: () => new Promise(resolve => setImmediate(resolve))
1722
+ });
1723
+ const processNode = async (type, node, identifier, exec) => {
1724
+ // Fast path: ack and drop ignored JIDs before entering the buffer/queue
1725
+ const from = node.attrs.from;
1726
+ let ignoreJid = from;
1727
+ if (type === 'receipt' && from) {
1728
+ const attrs = node.attrs;
1729
+ const isLid = attrs.from.includes('lid');
1730
+ const isNodeFromMe = areJidsSameUser(attrs.participant || attrs.from, isLid ? authState.creds.me?.lid : authState.creds.me?.id);
1731
+ ignoreJid = !isNodeFromMe || isJidGroup(attrs.from) ? attrs.from : attrs.recipient;
1732
+ }
1733
+ if (ignoreJid && ignoreJid !== S_WHATSAPP_NET && shouldIgnoreJid(ignoreJid)) {
1734
+ await sendMessageAck(node, type === 'message' ? NACK_REASONS.UnhandledError : undefined);
1735
+ return;
1736
+ }
1737
+ const isOffline = !!node.attrs.offline;
1738
+ if (isOffline) {
1739
+ offlineNodeProcessor.enqueue(type, node);
1740
+ }
1741
+ else {
1742
+ await processNodeWithBuffer(node, identifier, exec);
1743
+ }
1744
+ };
1745
+ // recv a message
1746
+ ws.on('CB:message', async (node) => {
1747
+ await processNode('message', node, 'processing message', handleMessage);
1748
+ });
1749
+ ws.on('CB:call', async (node) => {
1750
+ await processNode('call', node, 'handling call', handleCall);
1751
+ });
1752
+ ws.on('CB:receipt', async (node) => {
1753
+ await processNode('receipt', node, 'handling receipt', handleReceipt);
1754
+ });
1755
+ ws.on('CB:notification', async (node) => {
1756
+ await processNode('notification', node, 'handling notification', handleNotification);
1757
+ });
1758
+ ws.on('CB:ack,class:message', (node) => {
1759
+ handleBadAck(node).catch(error => onUnexpectedError(error, 'handling bad ack'));
1760
+ });
1761
+ ev.on('call', async ([call]) => {
1762
+ if (!call) {
1763
+ return;
1764
+ }
1765
+ // missed call + group call notification message generation
1766
+ if (call.status === 'timeout' || (call.status === 'offer' && call.isGroup)) {
1767
+ const msg = {
1768
+ key: {
1769
+ remoteJid: call.chatId,
1770
+ id: call.id,
1771
+ fromMe: false
1772
+ },
1773
+ messageTimestamp: unixTimestampSeconds(call.date)
1774
+ };
1775
+ if (call.status === 'timeout') {
1776
+ if (call.isGroup) {
1777
+ msg.messageStubType = call.isVideo
1778
+ ? WAMessageStubType.CALL_MISSED_GROUP_VIDEO
1779
+ : WAMessageStubType.CALL_MISSED_GROUP_VOICE;
1780
+ }
1781
+ else {
1782
+ msg.messageStubType = call.isVideo ? WAMessageStubType.CALL_MISSED_VIDEO : WAMessageStubType.CALL_MISSED_VOICE;
1783
+ }
1784
+ }
1785
+ else {
1786
+ msg.message = { call: { callKey: Buffer.from(call.id) } };
1787
+ }
1788
+ const protoMsg = proto.WebMessageInfo.fromObject(msg);
1789
+ await upsertMessage(protoMsg, call.offline ? 'append' : 'notify');
1790
+ }
1791
+ });
1792
+ /** timestamp of last tctoken prune run — throttles to once per 24h */
1793
+ let lastTcTokenPruneTs = 0;
1794
+ /** dedupe in-flight 463 recovery token issuance by target JID */
1795
+ const inFlight463Recoveries = new Set();
1796
+ ev.on('connection.update', ({ isOnline, connection }) => {
1797
+ if (typeof isOnline !== 'undefined') {
1798
+ sendActiveReceipts = isOnline;
1799
+ logger.trace(`sendActiveReceipts set to "${sendActiveReceipts}"`);
1800
+ }
1801
+ // Flush pending tctoken index save on disconnect to avoid writing after close
1802
+ if (connection === 'close' && tcTokenIndexTimer) {
1803
+ clearTimeout(tcTokenIndexTimer);
1804
+ tcTokenIndexTimer = undefined;
1805
+ // Best-effort flush — may fail if store is already closed
1806
+ try {
1807
+ void Promise.resolve(flushTcTokenIndex()).catch(() => { });
1808
+ }
1809
+ catch {
1810
+ /* ignore sync errors */
1811
+ }
1812
+ }
1813
+ // Prune expired tctokens when coming online, at most once per 24 hours
1814
+ // Matches WA Web's CLEAN_TC_TOKENS task
1815
+ // Note: don't gate on tcTokenKnownJids.size — the index may still be loading
1816
+ if (isOnline) {
1817
+ const now = Date.now();
1818
+ const DAY_MS = 24 * 60 * 60 * 1000;
1819
+ if (now - lastTcTokenPruneTs >= DAY_MS) {
1820
+ lastTcTokenPruneTs = now;
1821
+ void pruneExpiredTcTokens();
1822
+ }
1823
+ }
1824
+ });
1825
+ registerSocketEndHandler(() => {
1826
+ if (!config.msgRetryCounterCache && msgRetryCache.close) {
1827
+ msgRetryCache.close();
1828
+ }
1829
+ if (!config.callOfferCache && callOfferCache.close) {
1830
+ callOfferCache.close();
1831
+ }
1832
+ identityAssertDebounce.close();
1833
+ sendActiveReceipts = false;
1834
+ });
1835
+ async function pruneExpiredTcTokens() {
1836
+ try {
1837
+ await tcTokenIndexLoaded;
1838
+ // Union with the persisted index picks up JIDs added by other layers
1839
+ // (history sync) without needing inter-module wiring.
1840
+ const persisted = await readTcTokenIndex(authState.keys);
1841
+ const allJids = new Set(tcTokenKnownJids);
1842
+ for (const jid of persisted)
1843
+ allJids.add(jid);
1844
+ if (!allJids.size)
1845
+ return;
1846
+ const jids = [...allJids];
1847
+ const allTokens = await authState.keys.get('tctoken', jids);
1848
+ const writes = {};
1849
+ const survivors = new Set();
1850
+ let mutated = 0;
1851
+ for (const jid of jids) {
1852
+ const entry = allTokens[jid];
1853
+ if (!entry) {
1854
+ // Tracked but nothing in store — drop from index.
1855
+ mutated++;
1856
+ continue;
1857
+ }
1858
+ const hasPeerToken = !!entry.token?.length;
1859
+ const peerTokenExpired = hasPeerToken && isTcTokenExpired(entry.timestamp);
1860
+ const hasSenderTs = entry.senderTimestamp !== undefined;
1861
+ const senderTsExpired = hasSenderTs && isTcTokenExpired(entry.senderTimestamp);
1862
+ const keepPeerToken = hasPeerToken && !peerTokenExpired;
1863
+ const keepSenderTs = hasSenderTs && !senderTsExpired;
1864
+ if (!keepPeerToken && !keepSenderTs) {
1865
+ writes[jid] = null;
1866
+ mutated++;
1867
+ }
1868
+ else if (peerTokenExpired && keepSenderTs) {
1869
+ writes[jid] = { token: Buffer.alloc(0), senderTimestamp: entry.senderTimestamp };
1870
+ survivors.add(jid);
1871
+ mutated++;
1872
+ }
1873
+ else {
1874
+ survivors.add(jid);
1875
+ }
1876
+ }
1877
+ if (mutated === 0)
1878
+ return;
1879
+ await authState.keys.set({
1880
+ tctoken: {
1881
+ ...writes,
1882
+ [TC_TOKEN_INDEX_KEY]: {
1883
+ token: Buffer.from(JSON.stringify([...survivors]))
1884
+ }
1885
+ }
1886
+ });
1887
+ tcTokenKnownJids.clear();
1888
+ for (const jid of survivors)
1889
+ tcTokenKnownJids.add(jid);
1890
+ logger.debug({ mutated, remaining: survivors.size }, 'pruned expired tctokens');
1891
+ }
1892
+ catch (err) {
1893
+ logger.warn({ err: err?.message }, 'failed to prune expired tctokens');
1894
+ }
1895
+ }
1896
+ return {
1897
+ ...sock,
1898
+ sendMessageAck,
1899
+ sendRetryRequest,
1900
+ rejectCall,
1901
+ fetchMessageHistory,
1902
+ requestPlaceholderResend,
1903
+ messageRetryManager,
1904
+ sendText,
1905
+ sendImage,
1906
+ sendVideo,
1907
+ sendAudio,
1908
+ sendDocument,
1909
+ sendLocation,
1910
+ sendPoll,
1911
+ sendQuiz,
1912
+ sendPtv,
1913
+ statusMention
1914
+ };
1915
+ };
1916
+ //# sourceMappingURL=messages-recv.js.map