@nexustechpro/baileys 2.0.6 → 2.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,821 +1,724 @@
1
- import { Boom } from '@hapi/boom'
2
- import { randomBytes } from 'crypto'
3
- import { promises as fs } from 'fs'
4
- import { zip } from 'fflate'
5
- import { proto } from '../../WAProto/index.js'
6
- import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX, MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults/index.js'
7
- import { WAMessageStatus, WAProto } from '../Types/index.js'
8
- import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js'
9
- import { sha256 } from './crypto.js'
10
- import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics.js'
11
- import { downloadContentFromMessage, encryptedStream, prepareStream, generateThumbnail, getAudioDuration, getAudioWaveform, getRawMediaUploadData, getStream, toBuffer, getImageProcessingLibrary } from './messages-media.js'
12
- import { shouldIncludeReportingToken } from './reporting-utils.js'
13
-
14
- // ─── CONSTANTS ────────────────────────────────────────────────────────────────
15
- const MIMETYPE_MAP = {
16
- image: 'image/jpeg', video: 'video/mp4', document: 'application/pdf',
17
- audio: 'audio/ogg; codecs=opus', sticker: 'image/webp', 'product-catalog-image': 'image/jpeg'
18
- }
19
-
20
- const MessageTypeProto = {
21
- image: WAProto.Message.ImageMessage, video: WAProto.Message.VideoMessage,
22
- audio: WAProto.Message.AudioMessage, sticker: WAProto.Message.StickerMessage,
23
- document: WAProto.Message.DocumentMessage
24
- }
25
-
26
- // High-level keys that require processing — NOT raw WAProto passthrough
27
- const HIGH_LEVEL_KEYS = [
28
- 'text', 'image', 'video', 'audio', 'document', 'sticker', 'contacts', 'location',
29
- 'react', 'delete', 'forward', 'disappearingMessagesInChat', 'groupInvite', 'stickerPack',
30
- 'pin', 'buttonReply', 'ptv', 'product', 'listReply', 'event', 'poll', 'inviteAdmin',
31
- 'requestPayment', 'sharePhoneNumber', 'requestPhoneNumber', 'limitSharing', 'viewOnce',
32
- 'mentions', 'edit', 'buttons', 'templateButtons', 'sections', 'interactiveButtons',
33
- 'album', 'call', 'paymentInvite', 'order', 'keep', 'shop', 'payment'
34
- ]
35
-
36
- // ─── UTILITIES ────────────────────────────────────────────────────────────────
37
- export const extractUrlFromText = (text) => text.match(URL_REGEX)?.[0]
38
-
39
- export const generateLinkPreviewIfRequired = async (text, getUrlInfo, logger) => {
40
- const url = extractUrlFromText(text)
41
- if (!getUrlInfo || !url) return
42
- try { return await getUrlInfo(url) }
43
- catch (e) { logger?.warn({ trace: e.stack }, 'url generation failed') }
44
- }
45
-
46
- const assertColor = (color) => {
47
- if (typeof color === 'number') return color > 0 ? color : 0xffffffff + Number(color) + 1
48
- const hex = color.trim().replace('#', '')
49
- return parseInt(hex.length <= 6 ? 'FF' + hex.padStart(6, '0') : hex, 16)
50
- }
51
-
52
- export const getContentType = (content) => {
53
- if (!content) return
54
- return Object.keys(content).find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage')
55
- }
56
-
57
- export const normalizeMessageContent = (content) => {
58
- if (!content) return
59
- for (let i = 0; i < 5; i++) {
60
- const inner = (
61
- content?.ephemeralMessage || content?.viewOnceMessage ||
62
- content?.documentWithCaptionMessage || content?.viewOnceMessageV2 ||
63
- content?.viewOnceMessageV2Extension || content?.editedMessage ||
64
- content?.groupMentionedMessage || content?.botInvokeMessage ||
65
- content?.lottieStickerMessage || content?.eventCoverImage ||
66
- content?.statusMentionMessage || content?.pollCreationOptionImageMessage ||
67
- content?.associatedChildMessage || content?.groupStatusMentionMessage ||
68
- content?.pollCreationMessageV4 || content?.pollCreationMessageV5 ||
69
- content?.statusAddYours || content?.groupStatusMessage ||
70
- content?.limitSharingMessage || content?.botTaskMessage ||
71
- content?.questionMessage || content?.groupStatusMessageV2 ||
72
- content?.botForwardedMessage
73
- )
74
- if (!inner) break
75
- content = inner.message
76
- }
77
- return content
78
- }
79
-
80
- export const extractMessageContent = (content) => {
81
- content = normalizeMessageContent(content)
82
- const extractFromButtons = (msg) => {
83
- const header = typeof msg.header === 'object' && msg.header !== null
84
- if ((header ? msg.header?.imageMessage : msg.imageMessage)) return { imageMessage: header ? msg.header.imageMessage : msg.imageMessage }
85
- if ((header ? msg.header?.documentMessage : msg.documentMessage)) return { documentMessage: header ? msg.header.documentMessage : msg.documentMessage }
86
- if ((header ? msg.header?.videoMessage : msg.videoMessage)) return { videoMessage: header ? msg.header.videoMessage : msg.videoMessage }
87
- if ((header ? msg.header?.locationMessage : msg.locationMessage)) return { locationMessage: header ? msg.header.locationMessage : msg.locationMessage }
88
- if ((header ? msg.header?.productMessage : msg.productMessage)) return { productMessage: header ? msg.header.productMessage : msg.productMessage }
89
- return { conversation: 'contentText' in msg ? msg.contentText : ('hydratedContentText' in msg ? msg.hydratedContentText : 'body' in msg ? msg.body?.text : '') || '' }
90
- }
91
- if (content?.buttonsMessage) return extractFromButtons(content.buttonsMessage)
92
- if (content?.interactiveMessage) return extractFromButtons(content.interactiveMessage)
93
- if (content?.templateMessage?.interactiveMessageTemplate) return extractFromButtons(content.templateMessage.interactiveMessageTemplate)
94
- if (content?.templateMessage?.hydratedFourRowTemplate) return extractFromButtons(content.templateMessage.hydratedFourRowTemplate)
95
- if (content?.templateMessage?.hydratedTemplate) return extractFromButtons(content.templateMessage.hydratedTemplate)
96
- if (content?.templateMessage?.fourRowTemplate) return extractFromButtons(content.templateMessage.fourRowTemplate)
97
- return content
98
- }
99
-
100
- // ─── MEDIA PREPARATION ────────────────────────────────────────────────────────
101
- export const prepareWAMessageMedia = async (message, options) => {
102
- const mediaType = MEDIA_KEYS.find(k => k in message)
103
- if (!mediaType) throw new Boom('Invalid media type', { statusCode: 400 })
104
- const uploadData = { ...message, media: message[mediaType] }
105
- delete uploadData[mediaType]
106
- if (mediaType === 'document' && !uploadData.fileName) uploadData.fileName = 'file'
107
- if (!uploadData.mimetype) uploadData.mimetype = MIMETYPE_MAP[mediaType]
108
- const cacheableKey = typeof uploadData.media === 'object' && 'url' in uploadData.media && uploadData.media.url && options.mediaCache
109
- ? `${mediaType}:${uploadData.media.url.toString()}` : null
110
- if (cacheableKey) {
111
- const cached = await options.mediaCache?.get(cacheableKey)
112
- if (cached) {
113
- options.logger?.debug({ cacheableKey }, 'got media cache hit')
114
- const obj = WAProto.Message.decode(cached)
115
- Object.assign(obj[`${mediaType}Message`], { ...uploadData, media: undefined })
116
- return obj
117
- }
118
- }
119
- const isNewsletter = !!options.jid && isJidNewsletter(options.jid)
120
- const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
121
- const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData.jpegThumbnail === 'undefined'
122
- const requiresWaveformProcessing = mediaType === 'audio' && (uploadData.ptt === true || !!options.backgroundColor)
123
- const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation || requiresWaveformProcessing
124
- const encryptionResult = await (isNewsletter ? prepareStream : encryptedStream)(uploadData.media, options.mediaTypeOverride || mediaType, {
125
- logger: options.logger,
126
- saveOriginalFileIfRequired: requiresOriginalForSomeProcessing,
127
- opts: options.options,
128
- isPtt: uploadData.ptt,
129
- forceOpus: mediaType === 'audio' && uploadData.mimetype?.includes('opus'),
130
- convertVideo: mediaType === 'video'
131
- })
132
- const { mediaKey, encWriteStream, bodyPath, fileEncSha256, fileSha256, fileLength, didSaveToTmpPath, opusConverted, encFilePath } = encryptionResult
133
- if (mediaType === 'audio' && opusConverted) uploadData.mimetype = 'audio/ogg; codecs=opus'
134
- const fileEncSha256B64 = (isNewsletter ? fileSha256 : (fileEncSha256 ?? fileSha256)).toString('base64')
135
- const uploadSource = isNewsletter ? encWriteStream : (encFilePath || encWriteStream)
136
- const [{ mediaUrl, directPath, handle }] = await Promise.all([
137
- (async () => {
138
- const result = await options.upload(uploadSource, { fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs })
139
- options.logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
140
- return result
141
- })(),
142
- (async () => {
143
- try {
144
- if (requiresThumbnailComputation) {
145
- const { thumbnail, originalImageDimensions } = await generateThumbnail(bodyPath, mediaType, options)
146
- uploadData.jpegThumbnail = thumbnail
147
- if (!uploadData.width && originalImageDimensions) {
148
- uploadData.width = originalImageDimensions.width
149
- uploadData.height = originalImageDimensions.height
150
- options.logger?.debug('set dimensions')
151
- }
152
- options.logger?.debug('generated thumbnail')
153
- }
154
- if (requiresDurationComputation) {
155
- uploadData.seconds = await getAudioDuration(bodyPath)
156
- options.logger?.debug('computed audio duration')
157
- }
158
- if (requiresWaveformProcessing) {
159
- try {
160
- uploadData.waveform = await getAudioWaveform(bodyPath, options.logger)
161
- options.logger?.debug('processed waveform')
162
- } catch {
163
- options.logger?.warn('failed to generate waveform, using fallback')
164
- uploadData.waveform = new Uint8Array([0, 99, 0, 99, 0, 99, 0, 99, 88, 99, 0, 99, 0, 55, 0, 99, 0, 99, 0, 99, 0, 99, 0, 99, 88, 99, 0, 99, 0, 55, 0, 99])
165
- }
166
- }
167
- if (options.backgroundColor && mediaType === 'audio') uploadData.backgroundArgb = assertColor(options.backgroundColor)
168
- } catch (e) { options.logger?.warn({ trace: e.stack }, 'failed to obtain extra info') }
169
- })()
170
- ]).finally(async () => {
171
- if (encWriteStream && !Buffer.isBuffer(encWriteStream)) encWriteStream.destroy?.()
172
- if (encFilePath && typeof encFilePath === 'string') try { await fs.unlink(encFilePath) } catch { }
173
- if (didSaveToTmpPath && bodyPath) try { await fs.access(bodyPath); await fs.unlink(bodyPath) } catch { }
174
- })
175
- const obj = WAProto.Message.fromObject({
176
- [`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject({
177
- url: handle ? undefined : mediaUrl,
178
- directPath, mediaKey, fileEncSha256, fileSha256, fileLength,
179
- mediaKeyTimestamp: handle ? undefined : unixTimestampSeconds(),
180
- ...uploadData, media: undefined
181
- })
182
- })
183
- if (uploadData.ptv) { obj.ptvMessage = obj.videoMessage; delete obj.videoMessage }
184
- if (cacheableKey) {
185
- options.logger?.debug({ cacheableKey }, 'set cache')
186
- await options.mediaCache?.set(cacheableKey, WAProto.Message.encode(obj).finish())
187
- }
188
- return obj
189
- }
190
-
191
- export const prepareDisappearingMessageSettingContent = (ephemeralExpiration) => WAProto.Message.fromObject({
192
- ephemeralMessage: { message: { protocolMessage: { type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: ephemeralExpiration || 0 } } }
193
- })
194
-
195
- export const generateForwardMessageContent = (message, forceForward) => {
196
- let content = normalizeMessageContent(message.message)
197
- if (!content) throw new Boom('no content in message', { statusCode: 400 })
198
- content = proto.Message.decode(proto.Message.encode(content).finish())
199
- let key = Object.keys(content)[0]
200
- let score = (content?.[key]?.contextInfo?.forwardingScore || 0) + (message.key.fromMe && !forceForward ? 0 : 1)
201
- if (key === 'conversation') {
202
- content.extendedTextMessage = { text: content[key] }
203
- delete content.conversation
204
- key = 'extendedTextMessage'
205
- }
206
- content[key].contextInfo = score > 0 ? { forwardingScore: score, isForwarded: true } : {}
207
- return content
208
- }
209
-
210
- // ─── SUB-HANDLERS ─────────────────────────────────────────────────────────────
211
- const handleTextMessage = async (message, options) => {
212
- const extContent = { text: message.text }
213
- let urlInfo = message.linkPreview
214
- if (typeof urlInfo === 'undefined') urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger)
215
- if (urlInfo) {
216
- Object.assign(extContent, {
217
- matchedText: urlInfo['matched-text'], jpegThumbnail: urlInfo.jpegThumbnail,
218
- description: urlInfo.description, title: urlInfo.title, previewType: 0
219
- })
220
- const img = urlInfo.highQualityThumbnail
221
- if (img) Object.assign(extContent, {
222
- thumbnailDirectPath: img.directPath, mediaKey: img.mediaKey, mediaKeyTimestamp: img.mediaKeyTimestamp,
223
- thumbnailWidth: img.width, thumbnailHeight: img.height, thumbnailSha256: img.fileSha256, thumbnailEncSha256: img.fileEncSha256
224
- })
225
- }
226
- if (options.backgroundColor) extContent.backgroundArgb = assertColor(options.backgroundColor)
227
- if (options.font) extContent.font = options.font
228
- return { extendedTextMessage: extContent }
229
- }
230
-
231
- const handleSpecialMessages = async (message, options) => {
232
- if ('contacts' in message) {
233
- const { contacts } = message.contacts
234
- if (!contacts.length) throw new Boom('require atleast 1 contact', { statusCode: 400 })
235
- return contacts.length === 1
236
- ? { contactMessage: WAProto.Message.ContactMessage.create(contacts[0]) }
237
- : { contactsArrayMessage: WAProto.Message.ContactsArrayMessage.create(message.contacts) }
238
- }
239
- if ('location' in message) return { locationMessage: WAProto.Message.LocationMessage.create(message.location) }
240
- if ('react' in message) {
241
- if (!message.react.senderTimestampMs) message.react.senderTimestampMs = Date.now()
242
- return { reactionMessage: WAProto.Message.ReactionMessage.create(message.react) }
243
- }
244
- if ('delete' in message) return { protocolMessage: { key: message.delete, type: WAProto.Message.ProtocolMessage.Type.REVOKE } }
245
- if ('forward' in message) return generateForwardMessageContent(message.forward, message.force)
246
- if ('disappearingMessagesInChat' in message) {
247
- const exp = typeof message.disappearingMessagesInChat === 'boolean'
248
- ? (message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0)
249
- : message.disappearingMessagesInChat
250
- return prepareDisappearingMessageSettingContent(exp)
251
- }
252
- return null
253
- }
254
-
255
- const handleGroupInvite = async (message, options) => {
256
- const m = {
257
- groupInviteMessage: {
258
- inviteCode: message.groupInvite.inviteCode, inviteExpiration: message.groupInvite.inviteExpiration,
259
- caption: message.groupInvite.text, groupJid: message.groupInvite.jid, groupName: message.groupInvite.subject
260
- }
261
- }
262
- if (options.getProfilePicUrl) {
263
- const pfpUrl = await options.getProfilePicUrl(message.groupInvite.jid, 'preview')
264
- if (pfpUrl) {
265
- const resp = await fetch(pfpUrl, { method: 'GET', dispatcher: options?.options?.dispatcher })
266
- if (resp.ok) m.groupInviteMessage.jpegThumbnail = Buffer.from(await resp.arrayBuffer())
267
- }
268
- }
269
- return m
270
- }
271
-
272
- const handleEventMessage = async (message, options) => {
273
- const startTime = Math.floor(message.event.startDate.getTime() / 1000)
274
- const m = {
275
- eventMessage: {
276
- name: message.event.name, description: message.event.description, startTime,
277
- endTime: message.event.endDate ? message.event.endDate.getTime() / 1000 : undefined,
278
- isCanceled: message.event.isCancelled ?? false, extraGuestsAllowed: message.event.extraGuestsAllowed,
279
- isScheduleCall: message.event.isScheduleCall ?? false, location: message.event.location
280
- },
281
- messageContextInfo: { messageSecret: message.event.messageSecret || randomBytes(32) }
282
- }
283
- if (message.event.call && options.getCallLink) {
284
- const token = await options.getCallLink(message.event.call, { startTime })
285
- m.eventMessage.joinLink = (message.event.call === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + token
286
- }
287
- return m
288
- }
289
-
290
- const handlePollMessage = (message) => {
291
- message.poll.selectableCount ||= 0
292
- message.poll.toAnnouncementGroup ||= false
293
- if (!Array.isArray(message.poll.values)) throw new Boom('Invalid poll values', { statusCode: 400 })
294
- if (message.poll.selectableCount < 0 || message.poll.selectableCount > message.poll.values.length)
295
- throw new Boom(`poll.selectableCount should be >= 0 and <= ${message.poll.values.length}`, { statusCode: 400 })
296
- const pollMsg = {
297
- name: message.poll.name,
298
- selectableOptionsCount: message.poll.selectableCount,
299
- options: message.poll.values.map(optionName => ({ optionName }))
300
- }
301
- const m = { messageContextInfo: { messageSecret: message.poll.messageSecret || randomBytes(32) } }
302
- if (message.poll.toAnnouncementGroup) m.pollCreationMessageV2 = pollMsg
303
- else if (message.poll.selectableCount === 1) m.pollCreationMessageV3 = pollMsg
304
- else m.pollCreationMessage = pollMsg
305
- return m
306
- }
307
-
308
- const handleProductMessage = async (message, options) => {
309
- const { imageMessage } = await prepareWAMessageMedia({ image: message.product.productImage }, options)
310
- return { productMessage: WAProto.Message.ProductMessage.create({ ...message, product: { ...message.product, productImage: imageMessage } }) }
311
- }
312
-
313
- const handleRequestPayment = async (message, options) => {
314
- const data = message.requestPayment || message.payment
315
- const sticker = data.sticker ? await prepareWAMessageMedia({ sticker: data.sticker }, options) : null
316
- let notes
317
- if (sticker) {
318
- notes = { stickerMessage: { ...sticker.stickerMessage, contextInfo: data.contextInfo } }
319
- } else if (data.note) {
320
- notes = { extendedTextMessage: { text: data.note, contextInfo: data.contextInfo } }
321
- } else {
322
- notes = { extendedTextMessage: { text: data.note || 'Notes' } }
323
- }
324
- const m = {
325
- requestPaymentMessage: WAProto.Message.RequestPaymentMessage.fromObject({
326
- expiryTimestamp: data.expiryTimestamp || data.expiry || 0,
327
- amount1000: data.amount1000 || data.amount || 0,
328
- currencyCodeIso4217: data.currencyCodeIso4217 || data.currency || 'IDR',
329
- requestFrom: data.requestFrom || data.from || '0@s.whatsapp.net',
330
- noteMessage: notes,
331
- background: data.background ?? { id: 'DEFAULT', placeholderArgb: 0xfff0f0f0 }
332
- })
333
- }
334
- // BRL Pix key support
335
- if ((data.currencyCodeIso4217 === 'BRL' || data.currency === 'BRL') && data.pixKey) {
336
- if (!m.requestPaymentMessage.noteMessage.extendedTextMessage)
337
- m.requestPaymentMessage.noteMessage = { extendedTextMessage: { text: '' } }
338
- m.requestPaymentMessage.noteMessage.extendedTextMessage.text += `\nPix Key: ${data.pixKey}`
339
- }
340
- return m
341
- }
342
-
343
- const handleButtonReply = (message) => {
344
- switch (message.type) {
345
- case 'list': return { listResponseMessage: { title: message.buttonReply.title, description: message.buttonReply.description, singleSelectReply: { selectedRowId: message.buttonReply.rowId }, lisType: proto.Message.ListResponseMessage.ListType.SINGLE_SELECT } }
346
- case 'template': return { templateButtonReplyMessage: { selectedDisplayText: message.buttonReply.displayText, selectedId: message.buttonReply.id, selectedIndex: message.buttonReply.index } }
347
- case 'interactive': return { interactiveResponseMessage: { body: { text: message.buttonReply.displayText, format: proto.Message.InteractiveResponseMessage.Body.Format.EXTENSIONS_1 }, nativeFlowResponseMessage: { name: message.buttonReply.nativeFlows?.name, paramsJson: message.buttonReply.nativeFlows?.paramsJson, version: message.buttonReply.nativeFlows?.version } } }
348
- default: return { buttonsResponseMessage: { selectedButtonId: message.buttonReply.id, selectedDisplayText: message.buttonReply.displayText, type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT } }
349
- }
350
- }
351
-
352
- // ─── MAIN GENERATOR ───────────────────────────────────────────────────────────
353
- export const generateWAMessageContent = async (message, options = {}) => {
354
- const messageKeys = Object.keys(message)
355
-
356
- // ─── PROTO PASSTHROUGH ────────────────────────────────────────────────────
357
- const isRawProtoMessage = messageKeys.some(k => k.endsWith('Message') && typeof message[k] === 'object' && !HIGH_LEVEL_KEYS.includes(k))
358
- const isWrapperMessage = ['viewOnceMessage', 'ephemeralMessage', 'viewOnceMessageV2', 'documentWithCaptionMessage'].some(k => k in message)
359
- if ((isRawProtoMessage || isWrapperMessage) && messageKeys.length === 1) return WAProto.Message.create(message)
360
- if (!messageKeys.some(k => HIGH_LEVEL_KEYS.includes(k)) && isRawProtoMessage) return WAProto.Message.create(message)
361
-
362
- let m = {}
363
-
364
- // ─── TEXT ─────────────────────────────────────────────────────────────────
365
- if ('text' in message && !('buttons' in message) && !('templateButtons' in message) && !('sections' in message) && !('interactiveButtons' in message) && !('shop' in message)) {
366
- m = await handleTextMessage(message, options)
367
- }
368
-
369
- // ─── SPECIAL / MEDIA ──────────────────────────────────────────────────────
370
- else {
371
- const special = await handleSpecialMessages(message, options)
372
- if (special) {
373
- m = special
374
- } else if ('groupInvite' in message) {
375
- m = await handleGroupInvite(message, options)
376
- } else if ('stickerPack' in message) {
377
- return WAProto.Message.create({ stickerPackMessage: (await prepareStickerPackMessage(message.stickerPack, options)).stickerPackMessage })
378
- } else if ('pin' in message) {
379
- const messageKey = typeof message.pin === 'boolean'
380
- ? (options.quoted?.key || (() => { throw new Boom('No quoted message key found for pin operation') })())
381
- : typeof message.pin === 'object'
382
- ? (message.pin.key || (message.pin.id ? { remoteJid: options.jid, fromMe: message.pin.fromMe || false, id: message.pin.id, participant: message.pin.participant } : null))
383
- : message.pin
384
- const shouldPin = typeof message.pin === 'boolean' ? message.pin : (message.pin?.unpin !== true)
385
- const pinTime = typeof message.pin === 'object' ? message.pin.time : message.time
386
- if (!messageKey?.id) throw new Boom('Invalid message key for pin operation')
387
- m = { pinInChatMessage: { key: messageKey, type: shouldPin ? 1 : 2, senderTimestampMs: Date.now().toString() }, messageContextInfo: { messageAddOnDurationInSecs: shouldPin ? (pinTime || 86400) : 0 } }
388
- } else if ('keep' in message) {
389
- m = { keepInChatMessage: { key: message.keep, keepType: message.type, timestampMs: Date.now() } }
390
- } else if ('call' in message) {
391
- m = { scheduledCallCreationMessage: { scheduledTimestampMs: message.call.time || Date.now(), callType: message.call.type || 1, title: message.call.title } }
392
- } else if ('paymentInvite' in message) {
393
- m = { paymentInviteMessage: { serviceType: message.paymentInvite.type, expiryTimestamp: message.paymentInvite.expiry } }
394
- } else if ('buttonReply' in message) {
395
- m = handleButtonReply(message)
396
- } else if ('ptv' in message && message.ptv) {
397
- const { videoMessage } = await prepareWAMessageMedia({ video: message.video }, options)
398
- m = { ptvMessage: videoMessage }
399
- } else if ('product' in message) {
400
- m = await handleProductMessage(message, options)
401
- } else if ('order' in message) {
402
- m = { orderMessage: WAProto.Message.OrderMessage.fromObject({ orderId: message.order.id, thumbnail: message.order.thumbnail, itemCount: message.order.itemCount, status: message.order.status, surface: message.order.surface, orderTitle: message.order.title, message: message.order.text, sellerJid: message.order.seller, token: message.order.token, totalAmount1000: message.order.amount, totalCurrencyCode: message.order.currency }) }
403
- } else if ('sections' in message && message.sections) {
404
- m = {
405
- listMessage: {
406
- title: message.title, buttonText: message.buttonText, footerText: message.footer,
407
- description: message.text, sections: message.sections,
408
- listType: proto.Message.ListMessage.ListType.SINGLE_SELECT,
409
- contextInfo: { ...(message.contextInfo || {}), ...(message.mentions ? { mentionedJid: message.mentions } : {}) }
410
- }
411
- }
412
- } else if ('listReply' in message) {
413
- m = { listResponseMessage: { ...message.listReply } }
414
- } else if ('event' in message) {
415
- m = await handleEventMessage(message, options)
416
- } else if ('poll' in message) {
417
- m = handlePollMessage(message)
418
- } else if ('inviteAdmin' in message) {
419
- m = { newsletterAdminInviteMessage: { inviteExpiration: message.inviteAdmin.inviteExpiration, caption: message.inviteAdmin.text, newsletterJid: message.inviteAdmin.jid, newsletterName: message.inviteAdmin.subject, jpegThumbnail: message.inviteAdmin.thumbnail } }
420
- } else if ('requestPayment' in message || 'payment' in message) {
421
- m = await handleRequestPayment(message, options)
422
- } else if ('extendedTextMessage' in message) {
423
- m = { extendedTextMessage: WAProto.Message.ExtendedTextMessage.create(message.extendedTextMessage) }
424
- } else if ('interactiveMessage' in message) {
425
- m = { interactiveMessage: WAProto.Message.InteractiveMessage.create(message.interactiveMessage) }
426
- } else if ('sharePhoneNumber' in message) {
427
- m = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER } }
428
- } else if ('requestPhoneNumber' in message) {
429
- m = { requestPhoneNumberMessage: {} }
430
- } else if ('limitSharing' in message) {
431
- m = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, limitSharing: { sharingLimited: message.limitSharing === true, trigger: 1, limitSharingSettingTimestamp: Date.now(), initiatedByMe: true } } }
432
- } else if ('album' in message) {
433
- const imageItems = message.album.filter(i => 'image' in i)
434
- const videoItems = message.album.filter(i => 'video' in i)
435
- m = { albumMessage: { expectedImageCount: imageItems.length, expectedVideoCount: videoItems.length } }
436
- } else if (MEDIA_KEYS.some(k => k in message)) {
437
- m = await prepareWAMessageMedia(message, options)
438
- }
439
- }
440
-
441
- // ─── SMART BUTTON HANDLING ────────────────────────────────────────────────
442
- if ('buttons' in message && Array.isArray(message.buttons) && message.buttons.length > 0) {
443
- const hasNativeFlow = message.buttons.some(b => b.nativeFlowInfo || b.name || b.buttonParamsJson)
444
- if (hasNativeFlow) {
445
- const interactive = {
446
- body: { text: message.text || message.caption || message.contentText || '' },
447
- footer: { text: message.footer || message.footerText || '' },
448
- nativeFlowMessage: {
449
- buttons: message.buttons.map(btn => {
450
- if (btn.name && btn.buttonParamsJson) return btn
451
- if (btn.nativeFlowInfo) return { name: btn.nativeFlowInfo.name, buttonParamsJson: btn.nativeFlowInfo.paramsJson }
452
- return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.buttonText?.displayText || btn.displayText || '', id: btn.buttonId || btn.id || '' }) }
453
- })
454
- }
455
- }
456
- if (message.title) interactive.header = { title: message.title, subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment || false }
457
- if (Object.keys(m).length > 0) {
458
- interactive.header = interactive.header || { title: message.title || '', hasMediaAttachment: true }
459
- Object.assign(interactive.header, m)
460
- }
461
- m = { interactiveMessage: interactive }
462
- } else {
463
- const buttonsMessage = { buttons: message.buttons.map(b => ({ ...b, type: proto.Message.ButtonsMessage.Button.Type.RESPONSE })) }
464
- if ('text' in message) { buttonsMessage.contentText = message.text; buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType.EMPTY }
465
- else {
466
- if ('caption' in message) buttonsMessage.contentText = message.caption
467
- const type = Object.keys(m)[0]?.replace('Message', '').toUpperCase()
468
- buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType[type] || proto.Message.ButtonsMessage.HeaderType.EMPTY
469
- Object.assign(buttonsMessage, m)
470
- }
471
- if (message.title) { buttonsMessage.text = message.title; buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType.TEXT }
472
- if (message.footer) buttonsMessage.footerText = message.footer
473
- m = { buttonsMessage }
474
- }
475
- }
476
-
477
- // ─── TEMPLATE BUTTONS ─────────────────────────────────────────────────────
478
- else if ('templateButtons' in message && message.templateButtons) {
479
- const hydratedTemplate = { hydratedButtons: message.templateButtons }
480
- if ('text' in message) hydratedTemplate.hydratedContentText = message.text
481
- else { if ('caption' in message) hydratedTemplate.hydratedContentText = message.caption; Object.assign(hydratedTemplate, m) }
482
- if (message.footer) hydratedTemplate.hydratedFooterText = message.footer
483
- m = { templateMessage: { fourRowTemplate: hydratedTemplate, hydratedTemplate } }
484
- }
485
-
486
- // ─── INTERACTIVE BUTTONS ──────────────────────────────────────────────────
487
- else if ('interactiveButtons' in message && message.interactiveButtons) {
488
- const interactive = { nativeFlowMessage: WAProto.Message.InteractiveMessage.NativeFlowMessage.fromObject({ buttons: message.interactiveButtons }) }
489
- if ('text' in message) { interactive.body = { text: message.text }; interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: false } }
490
- else if ('caption' in message) {
491
- interactive.body = { text: message.caption }
492
- interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment ?? (Object.keys(m).length > 0) }
493
- if (Object.keys(m).length > 0) Object.assign(interactive.header, m)
494
- }
495
- if (message.footer) interactive.footer = { text: message.footer }
496
- m = { interactiveMessage: interactive, messageContextInfo: { messageSecret: randomBytes(32) } }
497
- }
498
-
499
- // ─── SHOP MESSAGE ─────────────────────────────────────────────────────────
500
- else if ('shop' in message && message.shop) {
501
- const interactive = {
502
- shopStorefrontMessage: WAProto.Message.InteractiveMessage.ShopMessage.fromObject({ surface: message.shop.surface || 1, id: message.shop.id || message.id })
503
- }
504
- if ('text' in message) interactive.body = { text: message.text }
505
- else if ('caption' in message) interactive.body = { text: message.caption }
506
- if (message.title || Object.keys(m).length > 0) {
507
- interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment ?? (Object.keys(m).length > 0) }
508
- if (Object.keys(m).length > 0) Object.assign(interactive.header, m)
509
- }
510
- if (message.footer) interactive.footer = { text: message.footer }
511
- m = { interactiveMessage: interactive }
512
- }
513
-
514
- // ─── COLLECTION ───────────────────────────────────────────────────────────
515
- else if ('collection' in message && message.collection) {
516
- const interactive = { collectionMessage: { bizJid: message.collection.bizJid, id: message.collection.id, messageVersion: message.collection.version } }
517
- if ('text' in message) { interactive.body = { text: message.text }; interactive.header = { title: message.title || '', hasMediaAttachment: false } }
518
- else if ('caption' in message) {
519
- interactive.body = { text: message.caption }
520
- interactive.header = { title: message.title || '', hasMediaAttachment: message.hasMediaAttachment ?? false }
521
- if (Object.keys(m).length > 0) Object.assign(interactive.header, m)
522
- }
523
- if (message.footer) interactive.footer = { text: message.footer }
524
- m = { interactiveMessage: interactive }
525
- }
526
-
527
- // ─── AUTO CONTEXT + MENTIONS MERGE ────────────────────────────────────────
528
- const finalKey = Object.keys(m)[0]
529
- if ((message.contextInfo || message.mentions?.length) && finalKey && m[finalKey] && typeof m[finalKey] === 'object') {
530
- m[finalKey].contextInfo = {
531
- ...(m[finalKey].contextInfo || {}),
532
- ...(message.contextInfo || {}),
533
- ...(message.mentions?.length ? { mentionedJid: message.mentions } : {})
534
- }
535
- }
536
-
537
- // ─── WRAPPERS ─────────────────────────────────────────────────────────────
538
- if (('viewOnce' in message && message.viewOnce) || ('viewOnceMessage' in message && message.viewOnceMessage)) {
539
- m = { viewOnceMessage: { message: m } }
540
- }
541
- if ('edit' in message) {
542
- m = { protocolMessage: { key: message.edit, editedMessage: m, timestampMs: Date.now(), type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT } }
543
- }
544
- if ('contextInfo' in message && message.contextInfo) {
545
- const k = Object.keys(m)[0]
546
- if (k && m[k]) m[k].contextInfo = { ...(m[k].contextInfo || {}), ...message.contextInfo }
547
- }
548
-
549
- if (shouldIncludeReportingToken(m)) {
550
- m.messageContextInfo = m.messageContextInfo || {}
551
- if (!m.messageContextInfo.messageSecret) {
552
- m.messageContextInfo.messageSecret = randomBytes(32)
553
- }
554
- }
555
-
556
- return WAProto.Message.create(m)
557
- }
558
-
559
- // ─── STICKER PACK ─────────────────────────────────────────────────────────────
560
- export const prepareStickerPackMessage = async (stickerPack, options) => {
561
- const { stickers, cover, name, publisher, packId, description } = stickerPack
562
- if (!stickers?.length) throw new Boom('Sticker pack requires at least one sticker', { statusCode: 400 })
563
- if (stickers.length > 120) throw new Boom('Sticker pack exceeds maximum of 120 stickers', { statusCode: 400 })
564
- const lib = await getImageProcessingLibrary()
565
- const packId_ = packId || generateMessageIDV2()
566
- const isWebPBuffer = (buf) => buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
567
- const isAnimatedWebP = (buf) => {
568
- if (!isWebPBuffer(buf)) return false
569
- let offset = 12
570
- while (offset < buf.length - 8) {
571
- const fourCC = buf.toString('ascii', offset, offset + 4)
572
- const chunkSize = buf.readUInt32LE(offset + 4)
573
- if (fourCC === 'VP8X' && offset + 8 < buf.length && (buf[offset + 8] & 0x02)) return true
574
- if (fourCC === 'ANIM' || fourCC === 'ANMF') return true
575
- offset += 8 + chunkSize + (chunkSize % 2)
576
- }
577
- return false
578
- }
579
- const toWebp = async (buffer) => {
580
- if (isWebPBuffer(buffer)) {
581
- if ('sharp' in lib && lib.sharp) return await lib.sharp.default(buffer).resize(512, 512, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75, effort: 6 }).toBuffer()
582
- return buffer
583
- }
584
- if ('sharp' in lib && lib.sharp) return await lib.sharp.default(buffer).resize(512, 512, { fit: 'inside', withoutEnlargement: true }).webp({ quality: 75, effort: 6 }).toBuffer()
585
- if ('jimp' in lib && lib.jimp) return await lib.jimp.Jimp.read(buffer).then(img => img.getBuffer('image/webp'))
586
- throw new Boom('No image processing library available for WebP conversion', { statusCode: 500 })
587
- }
588
- const validStickers = []
589
- await Promise.all(stickers.map(async (s) => {
590
- try {
591
- const { stream } = await getStream(s.data || s.sticker)
592
- const buffer = await toBuffer(stream)
593
- if (!buffer?.length) return
594
- const animated = isAnimatedWebP(buffer)
595
- let webpBuffer = await toWebp(buffer)
596
- if (webpBuffer.length > 1024 * 1024) {
597
- if ('sharp' in lib && lib.sharp) webpBuffer = await lib.sharp.default(webpBuffer).webp({ quality: 50 }).toBuffer()
598
- if (webpBuffer.length > 1024 * 1024) return
599
- }
600
- const hash = sha256(webpBuffer).toString('base64').replace(/\//g, '-').replace(/=/g, '')
601
- validStickers.push({
602
- fileName: `${hash}.webp`, buffer: webpBuffer, mimetype: 'image/webp',
603
- isAnimated: s.isAnimated ?? animated, isLottie: s.isLottie || false,
604
- emojis: s.emojis || [], accessibilityLabel: s.accessibilityLabel || ''
605
- })
606
- } catch (e) { options.logger?.warn({ err: e }, 'failed processing sticker') }
607
- }))
608
- if (!validStickers.length) throw new Boom('No valid stickers could be processed', { statusCode: 400 })
609
- const { stream: covStream } = await getStream(cover)
610
- const coverBuffer = await toWebp(await toBuffer(covStream))
611
- const processBatch = async (batch, batchIdx) => {
612
- const batchData = {}
613
- batch.forEach(s => { batchData[s.fileName] = [new Uint8Array(s.buffer), { level: 6 }] })
614
- const trayFile = `${packId_}_${batchIdx}.webp`
615
- batchData[trayFile] = [new Uint8Array(coverBuffer), { level: 6 }]
616
- const zipBuf = await new Promise((resolve, reject) => zip(batchData, { level: 6, memLevel: 9 }, (err, data) => err ? reject(err) : resolve(Buffer.from(data))))
617
- if (zipBuf.length > 10 * 1024 * 1024) throw new Boom(`Sticker pack batch ${batchIdx} too large: ${(zipBuf.length / 1024 / 1024).toFixed(2)}MB`, { statusCode: 400 })
618
- const upload = await encryptedStream(zipBuf, 'sticker-pack', { logger: options.logger, opts: options.options })
619
- const uploadRes = await options.upload(upload.encFilePath, { fileEncSha256B64: upload.fileEncSha256.toString('base64'), mediaType: 'sticker-pack', timeoutMs: options.mediaUploadTimeoutMs || 300000 })
620
- try { await fs.unlink(upload.encFilePath) } catch { }
621
- let thumbRes = null
622
- try {
623
- let thumbBuf
624
- if ('sharp' in lib && lib.sharp) thumbBuf = await lib.sharp.default(coverBuffer).resize(252, 252, { fit: 'cover' }).jpeg({ quality: 80 }).toBuffer()
625
- else if ('jimp' in lib && lib.jimp) thumbBuf = await lib.jimp.Jimp.read(coverBuffer).then(img => img.resize({ w: 252, h: 252 }).getBuffer('image/jpeg'))
626
- if (thumbBuf?.length) {
627
- const thumbUpload = await encryptedStream(thumbBuf, 'thumbnail-sticker-pack', { logger: options.logger, opts: options.options, mediaKey: upload.mediaKey })
628
- thumbRes = await options.upload(thumbUpload.encFilePath, { fileEncSha256B64: thumbUpload.fileEncSha256.toString('base64'), mediaType: 'thumbnail-sticker-pack', timeoutMs: options.mediaUploadTimeoutMs || 60000 })
629
- try { await fs.unlink(thumbUpload.encFilePath) } catch { }
630
- thumbRes._buf = thumbBuf
631
- thumbRes._enc = thumbUpload
632
- }
633
- } catch (e) { options.logger?.warn({ err: e }, 'failed generating sticker pack thumbnail') }
634
- return {
635
- name: batchIdx > 0 ? `${name} (${batchIdx + 1})` : name, publisher, packDescription: description,
636
- stickerPackId: batchIdx > 0 ? `${packId_}_${batchIdx}` : packId_,
637
- stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.THIRD_PARTY,
638
- stickerPackSize: zipBuf.length,
639
- stickers: batch.map(s => ({ fileName: s.fileName, mimetype: s.mimetype, isAnimated: s.isAnimated, isLottie: s.isLottie, emojis: s.emojis, accessibilityLabel: s.accessibilityLabel })),
640
- fileSha256: upload.fileSha256, fileEncSha256: upload.fileEncSha256, mediaKey: upload.mediaKey,
641
- directPath: uploadRes.directPath, fileLength: upload.fileLength,
642
- mediaKeyTimestamp: unixTimestampSeconds(), trayIconFileName: trayFile,
643
- ...(thumbRes && {
644
- thumbnailDirectPath: thumbRes.directPath, thumbnailHeight: 252, thumbnailWidth: 252,
645
- thumbnailSha256: thumbRes._enc?.fileSha256, thumbnailEncSha256: thumbRes._enc?.fileEncSha256,
646
- imageDataHash: thumbRes._buf ? sha256(thumbRes._buf).toString('base64') : undefined
647
- })
648
- }
649
- }
650
- if (validStickers.length > 60) {
651
- const batches = []
652
- for (let i = 0; i < validStickers.length; i += 60) batches.push(validStickers.slice(i, i + 60))
653
- const results = await Promise.all(batches.map((b, i) => processBatch(b, i)))
654
- return { stickerPackMessage: results, isBatched: true, batchCount: batches.length }
655
- }
656
- return { stickerPackMessage: await processBatch(validStickers, 0), isBatched: false }
657
- }
658
-
659
- // ─── MESSAGE BUILDERS ─────────────────────────────────────────────────────────
660
- export const generateWAMessageFromContent = (jid, message, options) => {
661
- if (!options.timestamp) options.timestamp = new Date()
662
- const innerMessage = normalizeMessageContent(message)
663
- const key = getContentType(innerMessage)
664
- const { quoted, userJid } = options
665
- if (quoted && !isJidNewsletter(jid)) {
666
- const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
667
- const normalizedQuoted = normalizeMessageContent(quoted.message)
668
- const quotedType = getContentType(normalizedQuoted)
669
- const quotedMsg = proto.Message.fromObject({ [quotedType]: normalizedQuoted[quotedType] })
670
- const quotedContent = quotedMsg[quotedType]
671
- if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) delete quotedContent.contextInfo
672
- const contextInfo = (innerMessage[key]?.contextInfo) || {}
673
- contextInfo.participant = jidNormalizedUser(participant)
674
- contextInfo.stanzaId = quoted.key.id
675
- contextInfo.quotedMessage = quotedMsg
676
- if (jid !== quoted.key.remoteJid) contextInfo.remoteJid = quoted.key.remoteJid
677
- if (innerMessage[key]) innerMessage[key].contextInfo = contextInfo
678
- }
679
- if (options?.ephemeralExpiration && key !== 'protocolMessage' && key !== 'ephemeralMessage' && !isJidNewsletter(jid)) {
680
- innerMessage[key].contextInfo = { ...(innerMessage[key].contextInfo || {}), expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL }
681
- }
682
- return WAProto.WebMessageInfo.fromObject({
683
- key: { remoteJid: jid, fromMe: true, id: options?.messageId || generateMessageIDV2() },
684
- message: WAProto.Message.fromObject(message),
685
- messageTimestamp: unixTimestampSeconds(options.timestamp),
686
- messageStubParameters: [],
687
- participant: (isJidGroup(jid) || isJidStatusBroadcast(jid)) ? userJid : undefined,
688
- status: WAMessageStatus.PENDING
689
- })
690
- }
691
-
692
- export const generateWAMessage = async (jid, content, options = {}) => {
693
- options.logger = options?.logger?.child({ msgId: options.messageId })
694
- return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options)
695
- }
696
-
697
- // ─── RECEIPTS / REACTIONS / POLLS ─────────────────────────────────────────────
698
- export const updateMessageWithReceipt = (msg, receipt) => {
699
- msg.userReceipt ||= []
700
- const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
701
- if (recp) Object.assign(recp, receipt)
702
- else msg.userReceipt.push(receipt)
703
- }
704
-
705
- export const updateMessageWithReaction = (msg, reaction) => {
706
- const authorID = getKeyAuthor(reaction.key)
707
- msg.reactions = (msg.reactions || []).filter(r => getKeyAuthor(r.key) !== authorID)
708
- reaction.text ||= ''
709
- msg.reactions.push(reaction)
710
- }
711
-
712
- export const updateMessageWithPollUpdate = (msg, update) => {
713
- const authorID = getKeyAuthor(update.pollUpdateMessageKey)
714
- msg.pollUpdates = (msg.pollUpdates || []).filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID)
715
- if (update.vote?.selectedOptions?.length) msg.pollUpdates.push(update)
716
- }
717
-
718
- export const updateMessageWithEventResponse = (msg, update) => {
719
- const authorID = getKeyAuthor(update.eventResponseMessageKey)
720
- msg.eventResponses = (msg.eventResponses || []).filter(r => getKeyAuthor(r.eventResponseMessageKey) !== authorID)
721
- msg.eventResponses.push(update)
722
- }
723
-
724
- export function getAggregateVotesInPollMessage({ message, pollUpdates }, meId) {
725
- const opts = message?.pollCreationMessage?.options || message?.pollCreationMessageV2?.options || message?.pollCreationMessageV3?.options || []
726
- const voteHashMap = opts.reduce((acc, opt) => {
727
- acc[sha256(Buffer.from(opt.optionName || '')).toString()] = { name: opt.optionName || '', voters: [] }
728
- return acc
729
- }, {})
730
- for (const update of pollUpdates || []) {
731
- if (!update.vote) continue
732
- for (const option of update.vote.selectedOptions || []) {
733
- const hash = option.toString()
734
- voteHashMap[hash] ||= { name: 'Unknown', voters: [] }
735
- voteHashMap[hash].voters.push(getKeyAuthor(update.pollUpdateMessageKey, meId))
736
- }
737
- }
738
- return Object.values(voteHashMap)
739
- }
740
-
741
- export function getAggregateResponsesInEventMessage({ eventResponses }, meId) {
742
- const responseMap = { GOING: { response: 'GOING', responders: [] }, NOT_GOING: { response: 'NOT_GOING', responders: [] }, MAYBE: { response: 'MAYBE', responders: [] } }
743
- for (const update of eventResponses || []) {
744
- const type = update.eventResponse || 'UNKNOWN'
745
- if (responseMap[type]) responseMap[type].responders.push(getKeyAuthor(update.eventResponseMessageKey, meId))
746
- }
747
- return Object.values(responseMap)
748
- }
749
-
750
- export const aggregateMessageKeysNotFromMe = (keys) => {
751
- const keyMap = {}
752
- for (const { remoteJid, id, participant, fromMe } of keys) {
753
- if (!fromMe) {
754
- const uqKey = `${remoteJid}:${participant || ''}`
755
- keyMap[uqKey] ||= { jid: remoteJid, participant, messageIds: [] }
756
- keyMap[uqKey].messageIds.push(id)
757
- }
758
- }
759
- return Object.values(keyMap)
760
- }
761
-
762
- // ─── DOWNLOAD ─────────────────────────────────────────────────────────────────
763
- const REUPLOAD_REQUIRED_STATUS = [410, 404]
764
-
765
- export const downloadMediaMessage = async (message, type, options, ctx) => {
766
- const downloadMsg = async () => {
767
- let normalized = message
768
- if (!message.message && message.key) normalized = { key: message.key, message: message.quoted?.message || message, messageTimestamp: message.messageTimestamp }
769
- const mContent = extractMessageContent(normalized.message)
770
- if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
771
- const contentType = getContentType(mContent)
772
- let mediaType = contentType?.replace('Message', '')
773
- const media = mContent[contentType]
774
- if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media)))
775
- throw new Boom(`"${contentType}" message is not a media message`)
776
- const download = ('thumbnailDirectPath' in media && !('url' in media))
777
- ? { directPath: media.thumbnailDirectPath, mediaKey: media.mediaKey }
778
- : media
779
- if ('thumbnailDirectPath' in media && !('url' in media)) mediaType = 'thumbnail-link'
780
- const stream = await downloadContentFromMessage(download, mediaType, options)
781
- if (type === 'buffer') {
782
- const chunks = []
783
- for await (const chunk of stream) chunks.push(chunk)
784
- return Buffer.concat(chunks)
785
- }
786
- return stream
787
- }
788
- return downloadMsg().catch(async (error) => {
789
- if (ctx && typeof error?.status === 'number' && REUPLOAD_REQUIRED_STATUS.includes(error.status)) {
790
- ctx.logger.info({ key: message.key }, 'sending reupload media request...')
791
- message = await ctx.reuploadRequest(message)
792
- return downloadMsg()
793
- }
794
- throw error
795
- })
796
- }
797
-
798
- export const assertMediaContent = (content) => {
799
- content = extractMessageContent(content)
800
- const mediaContent = content?.documentMessage || content?.imageMessage || content?.videoMessage || content?.audioMessage || content?.stickerMessage
801
- if (!mediaContent) throw new Boom('given message is not a media message', { statusCode: 400, data: content })
802
- return mediaContent
803
- }
804
-
805
- // ─── DEVICE / MD UTILS ────────────────────────────────────────────────────────
806
- export const getDevice = (id) => /^3A.{18}$/.test(id) ? 'ios' : /^3E.{20}$/.test(id) ? 'web' : /^(.{21}|.{32})$/.test(id) ? 'android' : /^(3F|.{18}$)/.test(id) ? 'desktop' : 'unknown'
807
-
808
- export const patchMessageForMdIfRequired = (message) => {
809
- if (message?.buttonsMessage || message?.templateMessage || message?.listMessage || message?.interactiveMessage?.nativeFlowMessage) {
810
- message = {
811
- viewOnceMessageV2Extension: {
812
- message: { messageContextInfo: { deviceListMetadataVersion: 2, deviceListMetadata: {} }, ...message }
813
- }
814
- }
815
- }
816
- return message
817
- }
818
-
819
- export const hasNonNullishProperty = (message, key) => typeof message === 'object' && message !== null && key in message && message[key] !== null && message[key] !== undefined
820
-
1
+ import { Boom } from '@hapi/boom'
2
+ import { randomBytes } from 'crypto'
3
+ import { promises as fs } from 'fs'
4
+ import { zip } from 'fflate'
5
+ import { proto } from '../../WAProto/index.js'
6
+ import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX, MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults/index.js'
7
+ import { WAMessageStatus, WAProto } from '../Types/index.js'
8
+ import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js'
9
+ import { sha256 } from './crypto.js'
10
+ import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics.js'
11
+ import { downloadContentFromMessage, encryptedStream, prepareStream, generateThumbnail, getAudioDuration, getAudioWaveform, getStream, toBuffer, getImageProcessingLibrary } from './messages-media.js'
12
+ import { shouldIncludeReportingToken } from './reporting-utils.js'
13
+
14
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
15
+ const MIMETYPE_MAP = {
16
+ image: 'image/jpeg', video: 'video/mp4', document: 'application/pdf',
17
+ audio: 'audio/ogg; codecs=opus', sticker: 'image/webp', 'product-catalog-image': 'image/jpeg'
18
+ }
19
+
20
+ const MessageTypeProto = {
21
+ image: WAProto.Message.ImageMessage, video: WAProto.Message.VideoMessage,
22
+ audio: WAProto.Message.AudioMessage, sticker: WAProto.Message.StickerMessage,
23
+ document: WAProto.Message.DocumentMessage
24
+ }
25
+
26
+ const HIGH_LEVEL_KEYS = [
27
+ 'text', 'image', 'video', 'audio', 'document', 'sticker', 'contacts', 'location',
28
+ 'react', 'delete', 'forward', 'disappearingMessagesInChat', 'groupInvite', 'stickerPack',
29
+ 'pin', 'buttonReply', 'ptv', 'product', 'listReply', 'event', 'poll', 'inviteAdmin',
30
+ 'requestPayment', 'sharePhoneNumber', 'requestPhoneNumber', 'limitSharing', 'viewOnce',
31
+ 'mentions', 'edit', 'buttons', 'templateButtons', 'sections', 'interactiveButtons',
32
+ 'album', 'call', 'paymentInvite', 'order', 'keep', 'shop', 'payment'
33
+ ]
34
+
35
+ const REUPLOAD_REQUIRED_STATUS = [410, 404]
36
+
37
+ // ─── UTILITIES ────────────────────────────────────────────────────────────────
38
+ export const extractUrlFromText = (text) => text.match(URL_REGEX)?.[0]
39
+
40
+ export const generateLinkPreviewIfRequired = async (text, getUrlInfo, logger) => {
41
+ const url = extractUrlFromText(text)
42
+ if (!getUrlInfo || !url) return
43
+ try { return await getUrlInfo(url) }
44
+ catch (e) { logger?.warn({ trace: e.stack }, 'url generation failed') }
45
+ }
46
+
47
+ const assertColor = (color) => {
48
+ if (typeof color === 'number') return color > 0 ? color : 0xffffffff + Number(color) + 1
49
+ const hex = color.trim().replace('#', '')
50
+ return parseInt(hex.length <= 6 ? 'FF' + hex.padStart(6, '0') : hex, 16)
51
+ }
52
+
53
+ export const getContentType = (content) => {
54
+ if (!content) return
55
+ return Object.keys(content).find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage')
56
+ }
57
+
58
+ export const normalizeMessageContent = (content) => {
59
+ if (!content) return
60
+ for (let i = 0; i < 5; i++) {
61
+ const inner = (
62
+ content?.ephemeralMessage || content?.viewOnceMessage ||
63
+ content?.documentWithCaptionMessage || content?.viewOnceMessageV2 ||
64
+ content?.viewOnceMessageV2Extension || content?.editedMessage ||
65
+ content?.groupMentionedMessage || content?.botInvokeMessage ||
66
+ content?.lottieStickerMessage || content?.eventCoverImage ||
67
+ content?.statusMentionMessage || content?.pollCreationOptionImageMessage ||
68
+ content?.associatedChildMessage || content?.groupStatusMentionMessage ||
69
+ content?.pollCreationMessageV4 || content?.pollCreationMessageV5 ||
70
+ content?.statusAddYours || content?.groupStatusMessage ||
71
+ content?.limitSharingMessage || content?.botTaskMessage ||
72
+ content?.questionMessage || content?.botForwardedMessage
73
+ )
74
+ if (!inner) break
75
+ content = inner.message
76
+ }
77
+ return content
78
+ }
79
+
80
+ export const extractMessageContent = (content) => {
81
+ content = normalizeMessageContent(content)
82
+ const extractFromButtons = (msg) => {
83
+ if (msg.imageMessage) return { imageMessage: msg.imageMessage }
84
+ if (msg.documentMessage) return { documentMessage: msg.documentMessage }
85
+ if (msg.videoMessage) return { videoMessage: msg.videoMessage }
86
+ if (msg.locationMessage) return { locationMessage: msg.locationMessage }
87
+ if (msg.productMessage) return { productMessage: msg.productMessage }
88
+ return { conversation: msg.contentText || msg.hydratedContentText || msg.body?.text || '' }
89
+ }
90
+ if (content?.buttonsMessage) return extractFromButtons(content.buttonsMessage)
91
+ if (content?.interactiveMessage) return extractFromButtons(content.interactiveMessage)
92
+ if (content?.templateMessage?.interactiveMessageTemplate) return extractFromButtons(content.templateMessage.interactiveMessageTemplate)
93
+ if (content?.templateMessage?.hydratedFourRowTemplate) return extractFromButtons(content.templateMessage.hydratedFourRowTemplate)
94
+ if (content?.templateMessage?.hydratedTemplate) return extractFromButtons(content.templateMessage.hydratedTemplate)
95
+ if (content?.templateMessage?.fourRowTemplate) return extractFromButtons(content.templateMessage.fourRowTemplate)
96
+ return content
97
+ }
98
+
99
+ // ─── MEDIA PREPARATION ────────────────────────────────────────────────────────
100
+ export const prepareWAMessageMedia = async (message, options) => {
101
+ const mediaType = MEDIA_KEYS.find(k => k in message)
102
+ if (!mediaType) throw new Boom('Invalid media type', { statusCode: 400 })
103
+
104
+ const uploadData = { ...message, media: message[mediaType] }
105
+ delete uploadData[mediaType]
106
+ if (mediaType === 'document' && !uploadData.fileName) uploadData.fileName = 'file'
107
+ if (!uploadData.mimetype) uploadData.mimetype = MIMETYPE_MAP[mediaType]
108
+
109
+ const cacheableKey = typeof uploadData.media === 'object' && 'url' in uploadData.media && uploadData.media.url && options.mediaCache
110
+ ? `${mediaType}:${uploadData.media.url.toString()}` : null
111
+
112
+ if (cacheableKey) {
113
+ const cached = await options.mediaCache?.get(cacheableKey)
114
+ if (cached) {
115
+ options.logger?.debug({ cacheableKey }, 'got media cache hit')
116
+ const obj = WAProto.Message.decode(cached)
117
+ Object.assign(obj[`${mediaType}Message`], { ...uploadData, media: undefined })
118
+ return obj
119
+ }
120
+ }
121
+
122
+ const isNewsletter = !!options.jid && isJidNewsletter(options.jid)
123
+ const requiresDurationComputation = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
124
+ const requiresThumbnailComputation = (mediaType === 'image' || mediaType === 'video') && typeof uploadData.jpegThumbnail === 'undefined'
125
+ const requiresWaveformProcessing = mediaType === 'audio' && (uploadData.ptt === true || !!options.backgroundColor)
126
+ const requiresOriginalForSomeProcessing = requiresDurationComputation || requiresThumbnailComputation || requiresWaveformProcessing
127
+
128
+ const encryptionResult = await (isNewsletter ? prepareStream : encryptedStream)(uploadData.media, options.mediaTypeOverride || mediaType, {
129
+ logger: options.logger,
130
+ saveOriginalFileIfRequired: requiresOriginalForSomeProcessing,
131
+ opts: options.options,
132
+ isPtt: uploadData.ptt,
133
+ forceOpus: mediaType === 'audio' && uploadData.mimetype?.includes('opus'),
134
+ convertVideo: mediaType === 'video'
135
+ })
136
+
137
+ const { mediaKey, encWriteStream, bodyPath, fileEncSha256, fileSha256, fileLength, didSaveToTmpPath, opusConverted, encFilePath, encBuffer, cleanup } = encryptionResult
138
+ if (mediaType === 'audio' && opusConverted) uploadData.mimetype = 'audio/ogg; codecs=opus'
139
+
140
+ const fileEncSha256B64 = (isNewsletter ? fileSha256 : (fileEncSha256 ?? fileSha256)).toString('base64')
141
+ const uploadSource = isNewsletter ? encWriteStream : (encFilePath || encBuffer || encWriteStream)
142
+
143
+ const [{ mediaUrl, directPath, handle }] = await Promise.all([
144
+ (async () => {
145
+ const result = await options.upload(uploadSource, { fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs })
146
+ options.logger?.debug({ mediaType, cacheableKey }, 'uploaded media')
147
+ return result
148
+ })(),
149
+ (async () => {
150
+ try {
151
+ if (requiresThumbnailComputation) {
152
+ const { thumbnail, originalImageDimensions } = await generateThumbnail(bodyPath, mediaType, options)
153
+ uploadData.jpegThumbnail = thumbnail
154
+ if (!uploadData.width && originalImageDimensions) {
155
+ uploadData.width = originalImageDimensions.width
156
+ uploadData.height = originalImageDimensions.height
157
+ }
158
+ }
159
+ if (requiresDurationComputation) uploadData.seconds = await getAudioDuration(bodyPath)
160
+ if (requiresWaveformProcessing) {
161
+ try { uploadData.waveform = await getAudioWaveform(bodyPath, options.logger) }
162
+ catch {
163
+ options.logger?.warn('failed to generate waveform, using fallback')
164
+ uploadData.waveform = new Uint8Array([0, 99, 0, 99, 0, 99, 0, 99, 88, 99, 0, 99, 0, 55, 0, 99, 0, 99, 0, 99, 0, 99, 0, 99, 88, 99, 0, 99, 0, 55, 0, 99])
165
+ }
166
+ }
167
+ if (options.backgroundColor && mediaType === 'audio') uploadData.backgroundArgb = assertColor(options.backgroundColor)
168
+ } catch (e) { options.logger?.warn({ trace: e.stack }, 'failed to obtain extra info') }
169
+ })()
170
+ ]).finally(async () => {
171
+ if (encWriteStream && !Buffer.isBuffer(encWriteStream)) encWriteStream.destroy?.()
172
+ if (cleanup) await cleanup()
173
+ })
174
+
175
+ const obj = WAProto.Message.fromObject({
176
+ [`${mediaType}Message`]: MessageTypeProto[mediaType].fromObject({
177
+ url: handle ? undefined : mediaUrl, directPath, mediaKey, fileEncSha256, fileSha256, fileLength,
178
+ mediaKeyTimestamp: handle ? undefined : unixTimestampSeconds(), ...uploadData, media: undefined
179
+ })
180
+ })
181
+ if (uploadData.ptv) { obj.ptvMessage = obj.videoMessage; delete obj.videoMessage }
182
+ if (cacheableKey) {
183
+ options.logger?.debug({ cacheableKey }, 'set cache')
184
+ await options.mediaCache?.set(cacheableKey, WAProto.Message.encode(obj).finish())
185
+ }
186
+ return obj
187
+ }
188
+
189
+ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration) => WAProto.Message.fromObject({
190
+ ephemeralMessage: { message: { protocolMessage: { type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: ephemeralExpiration || 0 } } }
191
+ })
192
+
193
+ export const generateForwardMessageContent = (message, forceForward) => {
194
+ let content = normalizeMessageContent(message.message)
195
+ if (!content) throw new Boom('no content in message', { statusCode: 400 })
196
+ content = proto.Message.decode(proto.Message.encode(content).finish())
197
+ let key = Object.keys(content)[0]
198
+ let score = (content?.[key]?.contextInfo?.forwardingScore || 0) + (message.key.fromMe && !forceForward ? 0 : 1)
199
+ if (key === 'conversation') { content.extendedTextMessage = { text: content[key] }; delete content.conversation; key = 'extendedTextMessage' }
200
+ content[key].contextInfo = score > 0 ? { forwardingScore: score, isForwarded: true } : {}
201
+ return content
202
+ }
203
+
204
+ // ─── MESSAGE HANDLERS ─────────────────────────────────────────────────────────
205
+ const handleTextMessage = async (message, options) => {
206
+ const extContent = { text: message.text }
207
+ let urlInfo = message.linkPreview
208
+ if (typeof urlInfo === 'undefined') urlInfo = await generateLinkPreviewIfRequired(message.text, options.getUrlInfo, options.logger)
209
+ if (urlInfo) {
210
+ Object.assign(extContent, { matchedText: urlInfo['matched-text'], jpegThumbnail: urlInfo.jpegThumbnail, description: urlInfo.description, title: urlInfo.title, previewType: urlInfo.previewType ?? 0 })
211
+ const img = urlInfo.highQualityThumbnail
212
+ if (img) Object.assign(extContent, { thumbnailDirectPath: img.directPath, mediaKey: img.mediaKey, mediaKeyTimestamp: img.mediaKeyTimestamp, thumbnailWidth: img.width, thumbnailHeight: img.height, thumbnailSha256: img.fileSha256, thumbnailEncSha256: img.fileEncSha256 })
213
+ }
214
+ if (options.backgroundColor) extContent.backgroundArgb = assertColor(options.backgroundColor)
215
+ if (options.font) extContent.font = options.font
216
+ return { extendedTextMessage: extContent }
217
+ }
218
+
219
+ const handleSpecialMessages = async (message, options) => {
220
+ if ('contacts' in message) {
221
+ const { contacts } = message.contacts
222
+ if (!contacts.length) throw new Boom('require atleast 1 contact', { statusCode: 400 })
223
+ return contacts.length === 1 ? { contactMessage: WAProto.Message.ContactMessage.create(contacts[0]) } : { contactsArrayMessage: WAProto.Message.ContactsArrayMessage.create(message.contacts) }
224
+ }
225
+ if ('location' in message) return { locationMessage: WAProto.Message.LocationMessage.create(message.location) }
226
+ if ('react' in message) { if (!message.react.senderTimestampMs) message.react.senderTimestampMs = Date.now(); return { reactionMessage: WAProto.Message.ReactionMessage.create(message.react) } }
227
+ if ('delete' in message) return { protocolMessage: { key: message.delete, type: WAProto.Message.ProtocolMessage.Type.REVOKE } }
228
+ if ('forward' in message) return generateForwardMessageContent(message.forward, message.force)
229
+ if ('disappearingMessagesInChat' in message) {
230
+ const exp = typeof message.disappearingMessagesInChat === 'boolean' ? (message.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : message.disappearingMessagesInChat
231
+ return prepareDisappearingMessageSettingContent(exp)
232
+ }
233
+ return null
234
+ }
235
+
236
+ const handleGroupInvite = async (message, options) => {
237
+ const m = { groupInviteMessage: { inviteCode: message.groupInvite.inviteCode, inviteExpiration: message.groupInvite.inviteExpiration, caption: message.groupInvite.text, groupJid: message.groupInvite.jid, groupName: message.groupInvite.subject } }
238
+ if (options.getProfilePicUrl) {
239
+ const pfpUrl = await options.getProfilePicUrl(message.groupInvite.jid, 'preview')
240
+ if (pfpUrl) {
241
+ const resp = await fetch(pfpUrl, { method: 'GET', dispatcher: options?.options?.dispatcher })
242
+ if (resp.ok) m.groupInviteMessage.jpegThumbnail = Buffer.from(await resp.arrayBuffer())
243
+ }
244
+ }
245
+ return m
246
+ }
247
+
248
+ const handleEventMessage = async (message, options) => {
249
+ const startTime = Math.floor(message.event.startDate.getTime() / 1000)
250
+ const m = {
251
+ eventMessage: {
252
+ name: message.event.name, description: message.event.description, startTime,
253
+ endTime: message.event.endDate ? message.event.endDate.getTime() / 1000 : undefined,
254
+ isCanceled: message.event.isCancelled ?? false, extraGuestsAllowed: message.event.extraGuestsAllowed,
255
+ isScheduleCall: message.event.isScheduleCall ?? false, location: message.event.location
256
+ },
257
+ messageContextInfo: { messageSecret: message.event.messageSecret || randomBytes(32) }
258
+ }
259
+ if (message.event.call && options.getCallLink) {
260
+ const token = await options.getCallLink(message.event.call, { startTime })
261
+ m.eventMessage.joinLink = (message.event.call === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + token
262
+ }
263
+ return m
264
+ }
265
+
266
+ const handlePollMessage = (message) => {
267
+ message.poll.selectableCount ||= 0
268
+ message.poll.toAnnouncementGroup ||= false
269
+ if (!Array.isArray(message.poll.values)) throw new Boom('Invalid poll values', { statusCode: 400 })
270
+ if (message.poll.selectableCount < 0 || message.poll.selectableCount > message.poll.values.length)
271
+ throw new Boom(`poll.selectableCount should be >= 0 and <= ${message.poll.values.length}`, { statusCode: 400 })
272
+ const pollMsg = { name: message.poll.name, selectableOptionsCount: message.poll.selectableCount, options: message.poll.values.map(optionName => ({ optionName })) }
273
+ const m = { messageContextInfo: { messageSecret: message.poll.messageSecret || randomBytes(32) } }
274
+ if (message.poll.toAnnouncementGroup) m.pollCreationMessageV2 = pollMsg
275
+ else if (message.poll.selectableCount === 1) m.pollCreationMessageV3 = pollMsg
276
+ else m.pollCreationMessage = pollMsg
277
+ return m
278
+ }
279
+
280
+ const handleProductMessage = async (message, options) => {
281
+ const { imageMessage } = await prepareWAMessageMedia({ image: message.product.productImage }, options)
282
+ return { productMessage: WAProto.Message.ProductMessage.create({ ...message, product: { ...message.product, productImage: imageMessage } }) }
283
+ }
284
+
285
+ const handleRequestPayment = async (message, options) => {
286
+ const data = message.requestPayment || message.payment
287
+ const sticker = data.sticker ? await prepareWAMessageMedia({ sticker: data.sticker }, options) : null
288
+ let notes
289
+ if (sticker) notes = { stickerMessage: { ...sticker.stickerMessage, contextInfo: data.contextInfo } }
290
+ else if (data.note) notes = { extendedTextMessage: { text: data.note, contextInfo: data.contextInfo } }
291
+ else notes = { extendedTextMessage: { text: data.note || 'Notes' } }
292
+ const m = {
293
+ requestPaymentMessage: WAProto.Message.RequestPaymentMessage.fromObject({
294
+ expiryTimestamp: data.expiryTimestamp || data.expiry || 0,
295
+ amount1000: data.amount1000 || data.amount || 0,
296
+ currencyCodeIso4217: data.currencyCodeIso4217 || data.currency || 'IDR',
297
+ requestFrom: data.requestFrom || data.from || '0@s.whatsapp.net',
298
+ noteMessage: notes,
299
+ background: data.background ?? { id: 'DEFAULT', placeholderArgb: 0xfff0f0f0 }
300
+ })
301
+ }
302
+ if ((data.currencyCodeIso4217 === 'BRL' || data.currency === 'BRL') && data.pixKey) {
303
+ if (!m.requestPaymentMessage.noteMessage.extendedTextMessage) m.requestPaymentMessage.noteMessage = { extendedTextMessage: { text: '' } }
304
+ m.requestPaymentMessage.noteMessage.extendedTextMessage.text += `\nPix Key: ${data.pixKey}`
305
+ }
306
+ return m
307
+ }
308
+
309
+ const handleButtonReply = (message) => {
310
+ switch (message.type) {
311
+ case 'list': return { listResponseMessage: { title: message.buttonReply.title, description: message.buttonReply.description, singleSelectReply: { selectedRowId: message.buttonReply.rowId }, lisType: proto.Message.ListResponseMessage.ListType.SINGLE_SELECT } }
312
+ case 'template': return { templateButtonReplyMessage: { selectedDisplayText: message.buttonReply.displayText, selectedId: message.buttonReply.id, selectedIndex: message.buttonReply.index } }
313
+ case 'interactive': return { interactiveResponseMessage: { body: { text: message.buttonReply.displayText, format: proto.Message.InteractiveResponseMessage.Body.Format.EXTENSIONS_1 }, nativeFlowResponseMessage: { name: message.buttonReply.nativeFlows?.name, paramsJson: message.buttonReply.nativeFlows?.paramsJson, version: message.buttonReply.nativeFlows?.version } } }
314
+ default: return { buttonsResponseMessage: { selectedButtonId: message.buttonReply.id, selectedDisplayText: message.buttonReply.displayText, type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT } }
315
+ }
316
+ }
317
+
318
+ // ─── MAIN GENERATOR ───────────────────────────────────────────────────────────
319
+ export const generateWAMessageContent = async (message, options = {}) => {
320
+ const messageKeys = Object.keys(message)
321
+ const isRawProtoMessage = messageKeys.some(k => k.endsWith('Message') && typeof message[k] === 'object' && !HIGH_LEVEL_KEYS.includes(k))
322
+ const isWrapperMessage = ['viewOnceMessage', 'ephemeralMessage', 'viewOnceMessageV2', 'documentWithCaptionMessage'].some(k => k in message)
323
+ if ((isRawProtoMessage || isWrapperMessage) && messageKeys.length === 1) return WAProto.Message.create(message)
324
+ if (!messageKeys.some(k => HIGH_LEVEL_KEYS.includes(k)) && isRawProtoMessage) return WAProto.Message.create(message)
325
+
326
+ let m = {}
327
+
328
+ if ('text' in message && !('buttons' in message) && !('templateButtons' in message) && !('sections' in message) && !('interactiveButtons' in message) && !('shop' in message)) {
329
+ m = await handleTextMessage(message, options)
330
+ } else {
331
+ const special = await handleSpecialMessages(message, options)
332
+ if (special) {
333
+ m = special
334
+ } else if ('groupInvite' in message) {
335
+ m = await handleGroupInvite(message, options)
336
+ } else if ('stickerPack' in message) {
337
+ const result = await prepareStickerPackMessage(message.stickerPack, options)
338
+ return result.isBatched
339
+ ? { stickerPackMessage: result.stickerPackMessage, isBatched: true, batchCount: result.batchCount }
340
+ : WAProto.Message.create({ stickerPackMessage: result.stickerPackMessage })
341
+ } else if ('pin' in message) {
342
+ const messageKey = typeof message.pin === 'boolean'
343
+ ? (options.quoted?.key || (() => { throw new Boom('No quoted message key found for pin operation') })())
344
+ : typeof message.pin === 'object'
345
+ ? (message.pin.key || (message.pin.id ? { remoteJid: options.jid, fromMe: message.pin.fromMe || false, id: message.pin.id, participant: message.pin.participant } : null))
346
+ : message.pin
347
+ const shouldPin = typeof message.pin === 'boolean' ? message.pin : (message.pin?.unpin !== true)
348
+ const pinTime = typeof message.pin === 'object' ? message.pin.time : message.time
349
+ if (!messageKey?.id) throw new Boom('Invalid message key for pin operation')
350
+ m = { pinInChatMessage: { key: messageKey, type: shouldPin ? 1 : 2, senderTimestampMs: Date.now().toString() }, messageContextInfo: { messageAddOnDurationInSecs: shouldPin ? (pinTime || 86400) : 0 } }
351
+ } else if ('keep' in message) {
352
+ m = { keepInChatMessage: { key: message.keep, keepType: message.type, timestampMs: Date.now() } }
353
+ } else if ('call' in message) {
354
+ m = { scheduledCallCreationMessage: { scheduledTimestampMs: message.call.time || Date.now(), callType: message.call.type || 1, title: message.call.title } }
355
+ } else if ('paymentInvite' in message) {
356
+ m = { paymentInviteMessage: { serviceType: message.paymentInvite.type, expiryTimestamp: message.paymentInvite.expiry } }
357
+ } else if ('buttonReply' in message) {
358
+ m = handleButtonReply(message)
359
+ } else if ('ptv' in message && message.ptv) {
360
+ const { videoMessage } = await prepareWAMessageMedia({ video: message.video }, options)
361
+ m = { ptvMessage: videoMessage }
362
+ } else if ('product' in message) {
363
+ m = await handleProductMessage(message, options)
364
+ } else if ('order' in message) {
365
+ m = { orderMessage: WAProto.Message.OrderMessage.fromObject({ orderId: message.order.id, thumbnail: message.order.thumbnail, itemCount: message.order.itemCount, status: message.order.status, surface: message.order.surface, orderTitle: message.order.title, message: message.order.text, sellerJid: message.order.seller, token: message.order.token, totalAmount1000: message.order.amount, totalCurrencyCode: message.order.currency }) }
366
+ } else if ('sections' in message) {
367
+ m = { listMessage: { title: message.title, buttonText: message.buttonText, footerText: message.footer, description: message.text, sections: message.sections, listType: proto.Message.ListMessage.ListType.SINGLE_SELECT, contextInfo: { ...(message.contextInfo || {}), ...(message.mentions ? { mentionedJid: message.mentions } : {}) } } }
368
+ } else if ('listReply' in message) {
369
+ m = { listResponseMessage: { ...message.listReply } }
370
+ } else if ('event' in message) {
371
+ m = await handleEventMessage(message, options)
372
+ } else if ('poll' in message) {
373
+ m = handlePollMessage(message)
374
+ } else if ('inviteAdmin' in message) {
375
+ m = { newsletterAdminInviteMessage: { inviteExpiration: message.inviteAdmin.inviteExpiration, caption: message.inviteAdmin.text, newsletterJid: message.inviteAdmin.jid, newsletterName: message.inviteAdmin.subject, jpegThumbnail: message.inviteAdmin.thumbnail } }
376
+ } else if ('requestPayment' in message || 'payment' in message) {
377
+ m = await handleRequestPayment(message, options)
378
+ } else if ('extendedTextMessage' in message) {
379
+ m = { extendedTextMessage: WAProto.Message.ExtendedTextMessage.create(message.extendedTextMessage) }
380
+ } else if ('interactiveMessage' in message) {
381
+ m = { interactiveMessage: WAProto.Message.InteractiveMessage.create(message.interactiveMessage) }
382
+ } else if ('sharePhoneNumber' in message) {
383
+ m = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER } }
384
+ } else if ('requestPhoneNumber' in message) {
385
+ m = { requestPhoneNumberMessage: {} }
386
+ } else if ('limitSharing' in message) {
387
+ m = { protocolMessage: { type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, limitSharing: { sharingLimited: message.limitSharing === true, trigger: 1, limitSharingSettingTimestamp: Date.now(), initiatedByMe: true } } }
388
+ } else if ('album' in message) {
389
+ const imageItems = message.album.filter(i => 'image' in i)
390
+ const videoItems = message.album.filter(i => 'video' in i)
391
+ m = { albumMessage: { expectedImageCount: imageItems.length, expectedVideoCount: videoItems.length } }
392
+ } else if (MEDIA_KEYS.some(k => k in message)) {
393
+ m = await prepareWAMessageMedia(message, options)
394
+ }
395
+ }
396
+
397
+ // ─── BUTTONS ──────────────────────────────────────────────────────────────
398
+ if ('buttons' in message && Array.isArray(message.buttons) && message.buttons.length > 0) {
399
+ const hasNativeFlow = message.buttons.some(b => b.nativeFlowInfo || b.name || b.buttonParamsJson)
400
+ if (hasNativeFlow) {
401
+ const interactive = {
402
+ body: { text: message.text || message.caption || message.contentText || '' },
403
+ footer: { text: message.footer || message.footerText || '' },
404
+ nativeFlowMessage: { buttons: message.buttons.map(btn => { if (btn.name && btn.buttonParamsJson) return btn; if (btn.nativeFlowInfo) return { name: btn.nativeFlowInfo.name, buttonParamsJson: btn.nativeFlowInfo.paramsJson }; return { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: btn.buttonText?.displayText || btn.displayText || '', id: btn.buttonId || btn.id || '' }) } }) }
405
+ }
406
+ if (message.title) interactive.header = { title: message.title, subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment || false }
407
+ if (Object.keys(m).length > 0) { interactive.header = interactive.header || { title: message.title || '', hasMediaAttachment: true }; Object.assign(interactive.header, m) }
408
+ m = { interactiveMessage: interactive }
409
+ } else {
410
+ const buttonsMessage = { buttons: message.buttons.map(b => ({ ...b, type: proto.Message.ButtonsMessage.Button.Type.RESPONSE })) }
411
+ if ('text' in message) { buttonsMessage.contentText = message.text; buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType.EMPTY }
412
+ else { if ('caption' in message) buttonsMessage.contentText = message.caption; const type = Object.keys(m)[0]?.replace('Message', '').toUpperCase(); buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType[type] || proto.Message.ButtonsMessage.HeaderType.EMPTY; Object.assign(buttonsMessage, m) }
413
+ if (message.title) { buttonsMessage.text = message.title; buttonsMessage.headerType = proto.Message.ButtonsMessage.HeaderType.TEXT }
414
+ if (message.footer) buttonsMessage.footerText = message.footer
415
+ m = { buttonsMessage }
416
+ }
417
+ } else if ('templateButtons' in message && message.templateButtons) {
418
+ const hydratedTemplate = { hydratedButtons: message.templateButtons }
419
+ if ('text' in message) hydratedTemplate.hydratedContentText = message.text
420
+ else { if ('caption' in message) hydratedTemplate.hydratedContentText = message.caption; Object.assign(hydratedTemplate, m) }
421
+ if (message.footer) hydratedTemplate.hydratedFooterText = message.footer
422
+ m = { templateMessage: { fourRowTemplate: hydratedTemplate, hydratedTemplate } }
423
+ } else if ('interactiveButtons' in message && message.interactiveButtons) {
424
+ const interactive = { nativeFlowMessage: WAProto.Message.InteractiveMessage.NativeFlowMessage.fromObject({ buttons: message.interactiveButtons }) }
425
+ if ('text' in message) { interactive.body = { text: message.text }; interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: false } }
426
+ else if ('caption' in message) { interactive.body = { text: message.caption }; interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment ?? (Object.keys(m).length > 0) }; if (Object.keys(m).length > 0) Object.assign(interactive.header, m) }
427
+ if (message.footer) interactive.footer = { text: message.footer }
428
+ m = { interactiveMessage: interactive, messageContextInfo: { messageSecret: randomBytes(32) } }
429
+ } else if ('shop' in message && message.shop) {
430
+ const interactive = { shopStorefrontMessage: WAProto.Message.InteractiveMessage.ShopMessage.fromObject({ surface: message.shop.surface || 1, id: message.shop.id || message.id }) }
431
+ if ('text' in message) interactive.body = { text: message.text }
432
+ else if ('caption' in message) interactive.body = { text: message.caption }
433
+ if (message.title || Object.keys(m).length > 0) { interactive.header = { title: message.title || '', subtitle: message.subtitle || '', hasMediaAttachment: message.hasMediaAttachment ?? (Object.keys(m).length > 0) }; if (Object.keys(m).length > 0) Object.assign(interactive.header, m) }
434
+ if (message.footer) interactive.footer = { text: message.footer }
435
+ m = { interactiveMessage: interactive }
436
+ } else if ('collection' in message && message.collection) {
437
+ const interactive = { collectionMessage: { bizJid: message.collection.bizJid, id: message.collection.id, messageVersion: message.collection.version } }
438
+ if ('text' in message) { interactive.body = { text: message.text }; interactive.header = { title: message.title || '', hasMediaAttachment: false } }
439
+ else if ('caption' in message) { interactive.body = { text: message.caption }; interactive.header = { title: message.title || '', hasMediaAttachment: message.hasMediaAttachment ?? false }; if (Object.keys(m).length > 0) Object.assign(interactive.header, m) }
440
+ if (message.footer) interactive.footer = { text: message.footer }
441
+ m = { interactiveMessage: interactive }
442
+ }
443
+
444
+ // ─── AUTO CONTEXT + MENTIONS ──────────────────────────────────────────────
445
+ const finalKey = Object.keys(m)[0]
446
+ if ((message.contextInfo || message.mentions?.length) && finalKey && m[finalKey] && typeof m[finalKey] === 'object') {
447
+ m[finalKey].contextInfo = { ...(m[finalKey].contextInfo || {}), ...(message.contextInfo || {}), ...(message.mentions?.length ? { mentionedJid: message.mentions } : {}) }
448
+ }
449
+
450
+ // ─── WRAPPERS ─────────────────────────────────────────────────────────────
451
+ if (('viewOnce' in message && message.viewOnce) || ('viewOnceMessage' in message && message.viewOnceMessage)) m = { viewOnceMessage: { message: m } }
452
+ if ('edit' in message) m = { protocolMessage: { key: message.edit, editedMessage: m, timestampMs: Date.now(), type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT } }
453
+ if ('contextInfo' in message && message.contextInfo) { const k = Object.keys(m)[0]; if (k && m[k]) m[k].contextInfo = { ...(m[k].contextInfo || {}), ...message.contextInfo } }
454
+
455
+ if (shouldIncludeReportingToken(m)) {
456
+ m.messageContextInfo = m.messageContextInfo || {}
457
+ if (!m.messageContextInfo.messageSecret) m.messageContextInfo.messageSecret = randomBytes(32)
458
+ }
459
+
460
+ return WAProto.Message.create(m)
461
+ }
462
+
463
+ // ─── STICKER PACK ─────────────────────────────────────────────────────────────
464
+ export const prepareStickerPackMessage = async (stickerPack, options) => {
465
+ const { stickers, cover, name, publisher, packId, description } = stickerPack
466
+ const MAX_STICKER_SIZE = 1024 * 1024
467
+ const MAX_STICKERS_PER_PACK = 60
468
+
469
+ if (!stickers?.length) throw new Boom('Sticker pack must contain at least one sticker', { statusCode: 400 })
470
+
471
+ const stickerPackIdValue = packId || generateMessageIDV2()
472
+ const lib = await getImageProcessingLibrary()
473
+ const validStickers = []
474
+ const skippedStickers = []
475
+
476
+ const isWebPBuffer = (buf) => buf.length >= 12 && buf[0] === 0x52 && buf[1] === 0x49 && buf[2] === 0x46 && buf[3] === 0x46 && buf[8] === 0x57 && buf[9] === 0x45 && buf[10] === 0x42 && buf[11] === 0x50
477
+
478
+ const stickerPromises = stickers.map(async (s, i) => {
479
+ try {
480
+ const { stream } = await getStream(s.data || s.sticker)
481
+ const buffer = await toBuffer(stream)
482
+ if (!buffer?.length) { skippedStickers.push({ index: i, reason: 'Empty buffer' }); return null }
483
+
484
+ const isWebP = isWebPBuffer(buffer)
485
+ let webpBuffer
486
+ try {
487
+ if (isWebP) webpBuffer = buffer
488
+ else if ('sharp' in lib && lib.sharp) webpBuffer = await lib.sharp.default(buffer).webp().toBuffer()
489
+ else if ('jimp' in lib && lib.jimp) webpBuffer = await lib.jimp.Jimp.read(buffer).then(img => img.getBuffer('image/webp'))
490
+ else { skippedStickers.push({ index: i, reason: 'No image processing library' }); return null }
491
+ } catch (convertError) {
492
+ if (!isWebP) { skippedStickers.push({ index: i, reason: `Conversion failed: ${convertError.message}` }); return null }
493
+ throw convertError
494
+ }
495
+
496
+ if (webpBuffer.length > MAX_STICKER_SIZE) {
497
+ options.logger?.warn?.(`Sticker at index ${i} exceeds 1MB, compressing...`)
498
+ try {
499
+ if ('sharp' in lib && lib.sharp) webpBuffer = await lib.sharp.default(buffer).webp({ quality: 70 }).toBuffer()
500
+ if (webpBuffer.length > MAX_STICKER_SIZE && 'sharp' in lib && lib.sharp) webpBuffer = await lib.sharp.default(buffer).webp({ quality: 50 }).toBuffer()
501
+ if (webpBuffer.length > MAX_STICKER_SIZE) { skippedStickers.push({ index: i, reason: 'Still exceeds 1MB after compression' }); return null }
502
+ options.logger?.info?.(`Sticker ${i} compressed successfully`)
503
+ } catch {
504
+ skippedStickers.push({ index: i, reason: 'Compression failed' })
505
+ return null
506
+ }
507
+ }
508
+
509
+ const hash = sha256(webpBuffer).toString('base64').replace(/\//g, '-').replace(/=/g, '')
510
+ return { fileName: `${hash}.webp`, webpBuffer, mimetype: 'image/webp', isAnimated: s.isAnimated || false, isLottie: s.isLottie || false, emojis: s.emojis || [], accessibilityLabel: s.accessibilityLabel || '' }
511
+ } catch (e) {
512
+ options.logger?.warn({ err: e }, 'failed processing sticker')
513
+ skippedStickers.push({ index: i, reason: e.message })
514
+ return null
515
+ }
516
+ })
517
+
518
+ validStickers.push(...(await Promise.all(stickerPromises)).filter(Boolean))
519
+ if (!validStickers.length) throw new Boom('No valid stickers could be processed', { statusCode: 400 })
520
+
521
+ const { stream: covStream } = await getStream(cover)
522
+ const coverBuffer = await toBuffer(covStream)
523
+ let coverWebpBuffer
524
+ if (isWebPBuffer(coverBuffer)) coverWebpBuffer = coverBuffer
525
+ else if ('sharp' in lib && lib.sharp) coverWebpBuffer = await lib.sharp.default(coverBuffer).webp().toBuffer()
526
+ else if ('jimp' in lib && lib.jimp) coverWebpBuffer = await lib.jimp.Jimp.read(coverBuffer).then(img => img.getBuffer('image/webp'))
527
+ else throw new Boom('No image processing library available for cover', { statusCode: 500 })
528
+
529
+ const processBatch = async (batch, batchIdx, totalBatches) => {
530
+ const batchData = {}
531
+ batch.forEach(s => { batchData[s.fileName] = [new Uint8Array(s.webpBuffer), { level: 0 }] })
532
+ const trayFile = totalBatches > 1 ? `${stickerPackIdValue}_batch${batchIdx}.webp` : `${stickerPackIdValue}.webp`
533
+ batchData[trayFile] = [new Uint8Array(coverWebpBuffer), { level: 0 }]
534
+
535
+ const zipBuf = await new Promise((resolve, reject) => zip(batchData, (err, data) => err ? reject(err) : resolve(Buffer.from(data))))
536
+
537
+ const upload = await encryptedStream(zipBuf, 'sticker-pack', { logger: options.logger, opts: options.options })
538
+ const uploadRes = await options.upload(upload.encFilePath || upload.encBuffer, { fileEncSha256B64: upload.fileEncSha256.toString('base64'), mediaType: 'sticker-pack', timeoutMs: options.mediaUploadTimeoutMs })
539
+ if (upload.encFilePath) await fs.unlink(upload.encFilePath).catch(() => { })
540
+
541
+ let thumbRes = null
542
+ try {
543
+ let thumbBuf
544
+ if ('sharp' in lib && lib.sharp) thumbBuf = await lib.sharp.default(coverBuffer).resize(252, 252).jpeg().toBuffer()
545
+ else if ('jimp' in lib && lib.jimp) thumbBuf = await lib.jimp.Jimp.read(coverBuffer).then(img => img.resize({ w: 252, h: 252 }).getBuffer('image/jpeg'))
546
+ if (thumbBuf?.length) {
547
+ const thumbUpload = await encryptedStream(thumbBuf, 'thumbnail-sticker-pack', { logger: options.logger, opts: options.options, mediaKey: upload.mediaKey })
548
+ thumbRes = await options.upload(thumbUpload.encFilePath || thumbUpload.encBuffer, { fileEncSha256B64: thumbUpload.fileEncSha256.toString('base64'), mediaType: 'thumbnail-sticker-pack', timeoutMs: options.mediaUploadTimeoutMs })
549
+ if (thumbUpload.encFilePath) await fs.unlink(thumbUpload.encFilePath).catch(() => { })
550
+ thumbRes._buf = thumbBuf; thumbRes._enc = thumbUpload
551
+ }
552
+ } catch (e) { options.logger?.warn({ err: e }, 'failed generating sticker pack thumbnail') }
553
+
554
+ return {
555
+ name: totalBatches > 1 ? `${name} (${batchIdx + 1}/${totalBatches})` : name,
556
+ publisher, packDescription: description,
557
+ stickerPackId: totalBatches > 1 ? `${stickerPackIdValue}_${batchIdx}` : stickerPackIdValue,
558
+ stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.USER_CREATED,
559
+ stickerPackSize: zipBuf.length,
560
+ stickers: batch.map(s => ({ fileName: s.fileName, mimetype: s.mimetype, isAnimated: s.isAnimated, isLottie: s.isLottie, emojis: s.emojis, accessibilityLabel: s.accessibilityLabel })),
561
+ fileSha256: upload.fileSha256, fileEncSha256: upload.fileEncSha256, mediaKey: upload.mediaKey,
562
+ directPath: uploadRes.directPath, fileLength: upload.fileLength, mediaKeyTimestamp: unixTimestampSeconds(), trayIconFileName: trayFile,
563
+ ...(thumbRes && { thumbnailDirectPath: thumbRes.directPath, thumbnailHeight: 252, thumbnailWidth: 252, thumbnailSha256: thumbRes._enc?.fileSha256, thumbnailEncSha256: thumbRes._enc?.fileEncSha256, imageDataHash: thumbRes._buf ? sha256(thumbRes._buf).toString('base64') : undefined })
564
+ }
565
+ }
566
+
567
+ if (validStickers.length > MAX_STICKERS_PER_PACK) {
568
+ const batches = []
569
+ for (let i = 0; i < validStickers.length; i += MAX_STICKERS_PER_PACK) batches.push(validStickers.slice(i, i + MAX_STICKERS_PER_PACK))
570
+ const results = []
571
+ for (let i = 0; i < batches.length; i++) results.push(await processBatch(batches[i], i, batches.length))
572
+ return { stickerPackMessage: results, isBatched: true, batchCount: batches.length }
573
+ }
574
+ return { stickerPackMessage: await processBatch(validStickers, 0, 1), isBatched: false }
575
+ }
576
+
577
+ // ─── MESSAGE BUILDERS ─────────────────────────────────────────────────────────
578
+ export const generateWAMessageFromContent = (jid, message, options) => {
579
+ if (!options.timestamp) options.timestamp = new Date()
580
+ const innerMessage = normalizeMessageContent(message)
581
+ const key = getContentType(innerMessage)
582
+ const { quoted, userJid } = options
583
+
584
+ if (quoted && !isJidNewsletter(jid)) {
585
+ const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
586
+ const normalizedQuoted = normalizeMessageContent(quoted.message)
587
+ if (!normalizedQuoted) {
588
+ // stub message with no content — skip quoted context
589
+ } else {
590
+ const quotedType = getContentType(normalizedQuoted)
591
+ const quotedMsg = proto.Message.fromObject({ [quotedType]: normalizedQuoted[quotedType] })
592
+ const quotedContent = quotedMsg[quotedType]
593
+ if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) delete quotedContent.contextInfo
594
+ const contextInfo = (innerMessage[key]?.contextInfo) || {}
595
+ contextInfo.participant = jidNormalizedUser(participant)
596
+ contextInfo.stanzaId = quoted.key.id
597
+ contextInfo.quotedMessage = quotedMsg
598
+ if (jid !== quoted.key.remoteJid) contextInfo.remoteJid = quoted.key.remoteJid
599
+ if (innerMessage[key]) innerMessage[key].contextInfo = contextInfo
600
+ }
601
+ }
602
+ if (options?.ephemeralExpiration && key !== 'protocolMessage' && key !== 'ephemeralMessage' && !isJidNewsletter(jid)) {
603
+ innerMessage[key].contextInfo = { ...(innerMessage[key].contextInfo || {}), expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL }
604
+ }
605
+
606
+ return WAProto.WebMessageInfo.fromObject({
607
+ key: { remoteJid: jid, fromMe: true, id: options?.messageId || generateMessageIDV2() },
608
+ message: WAProto.Message.fromObject(message),
609
+ messageTimestamp: unixTimestampSeconds(options.timestamp),
610
+ messageStubParameters: [],
611
+ participant: (isJidGroup(jid) || isJidStatusBroadcast(jid)) ? userJid : undefined,
612
+ status: WAMessageStatus.PENDING
613
+ })
614
+ }
615
+
616
+ export const generateWAMessage = async (jid, content, options = {}) => {
617
+ options.logger = options?.logger?.child({ msgId: options.messageId })
618
+ return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options)
619
+ }
620
+
621
+ // ─── RECEIPTS / REACTIONS / POLLS ─────────────────────────────────────────────
622
+ export const updateMessageWithReceipt = (msg, receipt) => {
623
+ msg.userReceipt ||= []
624
+ const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
625
+ if (recp) Object.assign(recp, receipt)
626
+ else msg.userReceipt.push(receipt)
627
+ }
628
+
629
+ export const updateMessageWithReaction = (msg, reaction) => {
630
+ const authorID = getKeyAuthor(reaction.key)
631
+ msg.reactions = (msg.reactions || []).filter(r => getKeyAuthor(r.key) !== authorID)
632
+ reaction.text ||= ''
633
+ msg.reactions.push(reaction)
634
+ }
635
+
636
+ export const updateMessageWithPollUpdate = (msg, update) => {
637
+ const authorID = getKeyAuthor(update.pollUpdateMessageKey)
638
+ msg.pollUpdates = (msg.pollUpdates || []).filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID)
639
+ if (update.vote?.selectedOptions?.length) msg.pollUpdates.push(update)
640
+ }
641
+
642
+ export const updateMessageWithEventResponse = (msg, update) => {
643
+ const authorID = getKeyAuthor(update.eventResponseMessageKey)
644
+ msg.eventResponses = (msg.eventResponses || []).filter(r => getKeyAuthor(r.eventResponseMessageKey) !== authorID)
645
+ msg.eventResponses.push(update)
646
+ }
647
+
648
+ export function getAggregateVotesInPollMessage({ message, pollUpdates }, meId) {
649
+ const opts = message?.pollCreationMessage?.options || message?.pollCreationMessageV2?.options || message?.pollCreationMessageV3?.options || []
650
+ const voteHashMap = opts.reduce((acc, opt) => { acc[sha256(Buffer.from(opt.optionName || '')).toString()] = { name: opt.optionName || '', voters: [] }; return acc }, {})
651
+ for (const update of pollUpdates || []) {
652
+ if (!update.vote) continue
653
+ for (const option of update.vote.selectedOptions || []) {
654
+ const hash = option.toString()
655
+ voteHashMap[hash] ||= { name: 'Unknown', voters: [] }
656
+ voteHashMap[hash].voters.push(getKeyAuthor(update.pollUpdateMessageKey, meId))
657
+ }
658
+ }
659
+ return Object.values(voteHashMap)
660
+ }
661
+
662
+ export function getAggregateResponsesInEventMessage({ eventResponses }, meId) {
663
+ const responseMap = { GOING: { response: 'GOING', responders: [] }, NOT_GOING: { response: 'NOT_GOING', responders: [] }, MAYBE: { response: 'MAYBE', responders: [] } }
664
+ for (const update of eventResponses || []) {
665
+ const type = update.eventResponse || 'UNKNOWN'
666
+ if (responseMap[type]) responseMap[type].responders.push(getKeyAuthor(update.eventResponseMessageKey, meId))
667
+ }
668
+ return Object.values(responseMap)
669
+ }
670
+
671
+ export const aggregateMessageKeysNotFromMe = (keys) => {
672
+ const keyMap = {}
673
+ for (const { remoteJid, id, participant, fromMe } of keys) {
674
+ if (!fromMe) { const uqKey = `${remoteJid}:${participant || ''}`; keyMap[uqKey] ||= { jid: remoteJid, participant, messageIds: [] }; keyMap[uqKey].messageIds.push(id) }
675
+ }
676
+ return Object.values(keyMap)
677
+ }
678
+
679
+ // ─── DOWNLOAD ─────────────────────────────────────────────────────────────────
680
+ export const downloadMediaMessage = async (message, type, options, ctx) => {
681
+ const downloadMsg = async () => {
682
+ let normalized = message
683
+ if (!message.message && message.key) normalized = { key: message.key, message: message.quoted?.message || message, messageTimestamp: message.messageTimestamp }
684
+ const mContent = extractMessageContent(normalized.message)
685
+ if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
686
+ const contentType = getContentType(mContent)
687
+ let mediaType = contentType?.replace('Message', '')
688
+ const media = mContent[contentType]
689
+ if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) throw new Boom(`"${contentType}" message is not a media message`)
690
+ const download = ('thumbnailDirectPath' in media && !('url' in media)) ? { directPath: media.thumbnailDirectPath, mediaKey: media.mediaKey } : media
691
+ if ('thumbnailDirectPath' in media && !('url' in media)) mediaType = 'thumbnail-link'
692
+ const stream = await downloadContentFromMessage(download, mediaType, options)
693
+ if (type === 'buffer') { const chunks = []; for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks) }
694
+ return stream
695
+ }
696
+ return downloadMsg().catch(async (error) => {
697
+ if (ctx && typeof error?.status === 'number' && REUPLOAD_REQUIRED_STATUS.includes(error.status)) {
698
+ ctx.logger.info({ key: message.key }, 'sending reupload media request...')
699
+ message = await ctx.reuploadRequest(message)
700
+ return downloadMsg()
701
+ }
702
+ throw error
703
+ })
704
+ }
705
+
706
+ export const assertMediaContent = (content) => {
707
+ content = extractMessageContent(content)
708
+ const mediaContent = content?.documentMessage || content?.imageMessage || content?.videoMessage || content?.audioMessage || content?.stickerMessage || content?.stickerPackMessage
709
+ if (!mediaContent) throw new Boom('given message is not a media message', { statusCode: 400, data: content })
710
+ return mediaContent
711
+ }
712
+
713
+ // ─── DEVICE / MD UTILS ────────────────────────────────────────────────────────
714
+ export const getDevice = (id) => /^3A.{18}$/.test(id) ? 'ios' : /^3E.{20}$/.test(id) ? 'web' : /^(.{21}|.{32})$/.test(id) ? 'android' : /^(3F|.{18}$)/.test(id) ? 'desktop' : 'unknown'
715
+
716
+ export const patchMessageForMdIfRequired = (message) => {
717
+ if (message?.buttonsMessage || message?.templateMessage || message?.listMessage || message?.interactiveMessage?.nativeFlowMessage) {
718
+ message = { viewOnceMessageV2Extension: { message: { messageContextInfo: { deviceListMetadataVersion: 2, deviceListMetadata: {} }, ...message } } }
719
+ }
720
+ return message
721
+ }
722
+
723
+ export const hasNonNullishProperty = (message, key) => typeof message === 'object' && message !== null && key in message && message[key] !== null && message[key] !== undefined
821
724
  export const hasOptionalProperty = (obj, key) => typeof obj === 'object' && obj !== null && key in obj && obj[key] !== null