@nexustechpro/baileys 2.0.2 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (108) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +924 -1299
  3. package/WAProto/index.js +22 -18
  4. package/lib/Defaults/baileys-version.json +6 -2
  5. package/lib/Defaults/index.js +173 -172
  6. package/lib/Signal/libsignal.js +395 -292
  7. package/lib/Signal/lid-mapping.js +264 -171
  8. package/lib/Socket/Client/index.js +2 -2
  9. package/lib/Socket/Client/types.js +10 -10
  10. package/lib/Socket/Client/websocket.js +45 -310
  11. package/lib/Socket/business.js +375 -375
  12. package/lib/Socket/chats.js +916 -963
  13. package/lib/Socket/communities.js +430 -430
  14. package/lib/Socket/groups.js +342 -342
  15. package/lib/Socket/index.js +21 -22
  16. package/lib/Socket/messages-recv.js +963 -743
  17. package/lib/Socket/messages-send.js +273 -321
  18. package/lib/Socket/mex.js +50 -50
  19. package/lib/Socket/newsletter.js +148 -148
  20. package/lib/Socket/nexus-handler.js +296 -247
  21. package/lib/Socket/registration.js +50 -33
  22. package/lib/Socket/socket.js +872 -1201
  23. package/lib/Store/index.js +5 -5
  24. package/lib/Store/make-cache-manager-store.js +81 -81
  25. package/lib/Store/make-in-memory-store.js +416 -416
  26. package/lib/Store/make-ordered-dictionary.js +81 -81
  27. package/lib/Store/object-repository.js +30 -30
  28. package/lib/Types/Auth.js +1 -1
  29. package/lib/Types/Bussines.js +1 -1
  30. package/lib/Types/Call.js +1 -1
  31. package/lib/Types/Chat.js +7 -7
  32. package/lib/Types/Contact.js +1 -1
  33. package/lib/Types/Events.js +1 -1
  34. package/lib/Types/GroupMetadata.js +1 -1
  35. package/lib/Types/Label.js +24 -24
  36. package/lib/Types/LabelAssociation.js +6 -6
  37. package/lib/Types/Message.js +10 -10
  38. package/lib/Types/Newsletter.js +37 -29
  39. package/lib/Types/Product.js +1 -1
  40. package/lib/Types/Signal.js +1 -1
  41. package/lib/Types/Socket.js +2 -2
  42. package/lib/Types/State.js +55 -12
  43. package/lib/Types/USync.js +1 -1
  44. package/lib/Types/index.js +25 -25
  45. package/lib/Utils/auth-utils.js +264 -256
  46. package/lib/Utils/baileys-event-stream.js +55 -55
  47. package/lib/Utils/browser-utils.js +27 -27
  48. package/lib/Utils/business.js +228 -230
  49. package/lib/Utils/chat-utils.js +726 -764
  50. package/lib/Utils/companion-reg-client-utils.js +34 -0
  51. package/lib/Utils/crypto.js +109 -135
  52. package/lib/Utils/decode-wa-message.js +342 -314
  53. package/lib/Utils/event-buffer.js +547 -547
  54. package/lib/Utils/generics.js +295 -297
  55. package/lib/Utils/history.js +91 -83
  56. package/lib/Utils/index.js +25 -20
  57. package/lib/Utils/key-store.js +17 -0
  58. package/lib/Utils/link-preview.js +107 -98
  59. package/lib/Utils/logger.js +2 -2
  60. package/lib/Utils/lt-hash.js +47 -47
  61. package/lib/Utils/make-mutex.js +39 -39
  62. package/lib/Utils/message-retry-manager.js +148 -148
  63. package/lib/Utils/messages-media.js +579 -535
  64. package/lib/Utils/messages.js +821 -706
  65. package/lib/Utils/noise-handler.js +255 -255
  66. package/lib/Utils/pre-key-manager.js +105 -105
  67. package/lib/Utils/process-message.js +430 -412
  68. package/lib/Utils/reporting-utils.js +155 -0
  69. package/lib/Utils/signal.js +191 -159
  70. package/lib/Utils/sync-action-utils.js +33 -0
  71. package/lib/Utils/tc-token-utils.js +162 -0
  72. package/lib/Utils/use-multi-file-auth-state.js +120 -120
  73. package/lib/Utils/validate-connection.js +194 -194
  74. package/lib/WABinary/constants.js +1306 -1300
  75. package/lib/WABinary/decode.js +237 -237
  76. package/lib/WABinary/encode.js +232 -232
  77. package/lib/WABinary/generic-utils.js +252 -211
  78. package/lib/WABinary/index.js +6 -5
  79. package/lib/WABinary/jid-utils.js +279 -95
  80. package/lib/WABinary/types.js +1 -1
  81. package/lib/WAM/BinaryInfo.js +9 -9
  82. package/lib/WAM/constants.js +22852 -22852
  83. package/lib/WAM/encode.js +149 -149
  84. package/lib/WAM/index.js +3 -3
  85. package/lib/WAUSync/Protocols/USyncContactProtocol.js +28 -28
  86. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +53 -53
  87. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +26 -26
  88. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +37 -37
  89. package/lib/WAUSync/Protocols/UsyncBotProfileProtocol.js +50 -50
  90. package/lib/WAUSync/Protocols/UsyncLIDProtocol.js +28 -28
  91. package/lib/WAUSync/Protocols/index.js +4 -4
  92. package/lib/WAUSync/USyncQuery.js +93 -93
  93. package/lib/WAUSync/USyncUser.js +22 -22
  94. package/lib/WAUSync/index.js +3 -3
  95. package/lib/index.js +65 -66
  96. package/package.json +172 -143
  97. package/lib/Signal/Group/ciphertext-message.js +0 -12
  98. package/lib/Signal/Group/group-session-builder.js +0 -30
  99. package/lib/Signal/Group/group_cipher.js +0 -100
  100. package/lib/Signal/Group/index.js +0 -12
  101. package/lib/Signal/Group/keyhelper.js +0 -18
  102. package/lib/Signal/Group/sender-chain-key.js +0 -26
  103. package/lib/Signal/Group/sender-key-distribution-message.js +0 -63
  104. package/lib/Signal/Group/sender-key-message.js +0 -66
  105. package/lib/Signal/Group/sender-key-name.js +0 -48
  106. package/lib/Signal/Group/sender-key-record.js +0 -41
  107. package/lib/Signal/Group/sender-key-state.js +0 -84
  108. package/lib/Signal/Group/sender-message-key.js +0 -26
@@ -1,706 +1,821 @@
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, 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
+
821
+ export const hasOptionalProperty = (obj, key) => typeof obj === 'object' && obj !== null && key in obj && obj[key] !== null