@nexustechpro/baileys 2.0.5 → 2.1.0

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