@zervonex/baileys 0.0.0-stage → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (124) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3206 -3
  3. package/WAProto/WAProtoCompile.js +291 -0
  4. package/WAProto/fetcher.js +69 -0
  5. package/WAProto/index.js +88 -0
  6. package/WAProto/parser.js +313 -0
  7. package/engine-requirements.js +10 -0
  8. package/lib/Builders/AIRich.js +643 -0
  9. package/lib/Builders/base.js +130 -0
  10. package/lib/Builders/builders.js +449 -0
  11. package/lib/Builders/index.js +3 -0
  12. package/lib/Defaults/index.js +225 -0
  13. package/lib/Defaults/phonenumber-mcc.json +223 -0
  14. package/lib/Signal/index.js +2 -0
  15. package/lib/Signal/libsignal.js +523 -0
  16. package/lib/Signal/lid-mapping.js +283 -0
  17. package/lib/Socket/Client/index.js +2 -0
  18. package/lib/Socket/Client/types.js +10 -0
  19. package/lib/Socket/Client/websocket.js +84 -0
  20. package/lib/Socket/business.js +152 -0
  21. package/lib/Socket/chats.js +855 -0
  22. package/lib/Socket/communities.js +147 -0
  23. package/lib/Socket/groups.js +294 -0
  24. package/lib/Socket/index.js +15 -0
  25. package/lib/Socket/messages-recv.js +981 -0
  26. package/lib/Socket/messages-send.js +708 -0
  27. package/lib/Socket/mex.js +34 -0
  28. package/lib/Socket/newsletter.js +781 -0
  29. package/lib/Socket/registration.js +155 -0
  30. package/lib/Socket/socket.js +797 -0
  31. package/lib/Socket/spam-report.js +65 -0
  32. package/lib/Socket/username.js +82 -0
  33. package/lib/Socket/zervonex-handler.js +55 -0
  34. package/lib/Store/index.js +6 -0
  35. package/lib/Store/make-cache-manager-store.js +81 -0
  36. package/lib/Store/make-in-memory-store.js +425 -0
  37. package/lib/Store/make-ordered-dictionary.js +82 -0
  38. package/lib/Store/object-repository.js +31 -0
  39. package/lib/Store/reporting-info-store.js +38 -0
  40. package/lib/Types/Auth.js +2 -0
  41. package/lib/Types/Bussines.js +2 -0
  42. package/lib/Types/Call.js +2 -0
  43. package/lib/Types/Chat.js +8 -0
  44. package/lib/Types/Contact.js +2 -0
  45. package/lib/Types/Events.js +2 -0
  46. package/lib/Types/GroupMetadata.js +2 -0
  47. package/lib/Types/Label.js +25 -0
  48. package/lib/Types/LabelAssociation.js +7 -0
  49. package/lib/Types/Message.js +15 -0
  50. package/lib/Types/Newsletter.js +100 -0
  51. package/lib/Types/Product.js +2 -0
  52. package/lib/Types/Signal.js +2 -0
  53. package/lib/Types/Socket.js +14 -0
  54. package/lib/Types/State.js +26 -0
  55. package/lib/Types/USync.js +2 -0
  56. package/lib/Types/index.js +30 -0
  57. package/lib/Utils/auth-utils.js +142 -0
  58. package/lib/Utils/baileys-event-stream.js +33 -0
  59. package/lib/Utils/bot-capabilities.js +40 -0
  60. package/lib/Utils/bot-planning-replay.js +44 -0
  61. package/lib/Utils/browser-utils.js +26 -0
  62. package/lib/Utils/business.js +75 -0
  63. package/lib/Utils/chat-utils.js +317 -0
  64. package/lib/Utils/companion-reg-client-utils.js +20 -0
  65. package/lib/Utils/consumer-application.js +38 -0
  66. package/lib/Utils/crypto.js +52 -0
  67. package/lib/Utils/decode-wa-message.js +197 -0
  68. package/lib/Utils/event-buffer.js +273 -0
  69. package/lib/Utils/extras.js +186 -0
  70. package/lib/Utils/generics.js +195 -0
  71. package/lib/Utils/history.js +51 -0
  72. package/lib/Utils/identity-change-handler.js +19 -0
  73. package/lib/Utils/index.js +38 -0
  74. package/lib/Utils/link-preview.js +51 -0
  75. package/lib/Utils/logger.js +2 -0
  76. package/lib/Utils/lt-hash.js +3 -0
  77. package/lib/Utils/make-mutex.js +16 -0
  78. package/lib/Utils/message-composer.js +59 -0
  79. package/lib/Utils/message-retry-manager.js +48 -0
  80. package/lib/Utils/messages-media.js +520 -0
  81. package/lib/Utils/messages.js +752 -0
  82. package/lib/Utils/meta-ai-msmsg.js +122 -0
  83. package/lib/Utils/meta-compositing.js +75 -0
  84. package/lib/Utils/modern-messages.js +235 -0
  85. package/lib/Utils/native-flow.js +55 -0
  86. package/lib/Utils/noise-handler.js +161 -0
  87. package/lib/Utils/pre-key-manager.js +76 -0
  88. package/lib/Utils/process-message.js +399 -0
  89. package/lib/Utils/reporting-utils.js +171 -0
  90. package/lib/Utils/signal.js +167 -0
  91. package/lib/Utils/spam-report-utils.js +135 -0
  92. package/lib/Utils/sync-action-utils.js +33 -0
  93. package/lib/Utils/tc-token-utils.js +154 -0
  94. package/lib/Utils/use-multi-file-auth-state.js +307 -0
  95. package/lib/Utils/validate-connection.js +181 -0
  96. package/lib/WABinary/constants.js +1321 -0
  97. package/lib/WABinary/decode.js +152 -0
  98. package/lib/WABinary/encode.js +140 -0
  99. package/lib/WABinary/generic-utils.js +179 -0
  100. package/lib/WABinary/index.js +6 -0
  101. package/lib/WABinary/jid-utils.js +235 -0
  102. package/lib/WABinary/types.js +4 -0
  103. package/lib/WAM/BinaryInfo.js +10 -0
  104. package/lib/WAM/constants.js +22853 -0
  105. package/lib/WAM/encode.js +150 -0
  106. package/lib/WAM/index.js +3 -0
  107. package/lib/WAUSync/Protocols/USyncBotProfileProtocol.js +30 -0
  108. package/lib/WAUSync/Protocols/USyncBusinessProtocol.js +48 -0
  109. package/lib/WAUSync/Protocols/USyncContactProtocol.js +23 -0
  110. package/lib/WAUSync/Protocols/USyncDeviceProtocol.js +28 -0
  111. package/lib/WAUSync/Protocols/USyncDisappearingModeProtocol.js +15 -0
  112. package/lib/WAUSync/Protocols/USyncFeatureProtocol.js +24 -0
  113. package/lib/WAUSync/Protocols/USyncLIDProtocol.js +9 -0
  114. package/lib/WAUSync/Protocols/USyncPictureProtocol.js +21 -0
  115. package/lib/WAUSync/Protocols/USyncSidelistProtocol.js +19 -0
  116. package/lib/WAUSync/Protocols/USyncStatusProtocol.js +19 -0
  117. package/lib/WAUSync/Protocols/USyncTextStatusProtocol.js +22 -0
  118. package/lib/WAUSync/Protocols/USyncUsernameProtocol.js +16 -0
  119. package/lib/WAUSync/Protocols/index.js +12 -0
  120. package/lib/WAUSync/USyncQuery.js +100 -0
  121. package/lib/WAUSync/USyncUser.js +20 -0
  122. package/lib/WAUSync/index.js +3 -0
  123. package/lib/index.js +67 -0
  124. package/package.json +220 -5
@@ -0,0 +1,752 @@
1
+ import { Boom } from '@hapi/boom'
2
+ import { Sticker, StickerTypes } from 'wa-sticker-formatter'
3
+ import sharp from 'sharp'
4
+ import { fileTypeFromBuffer } from 'file-type'
5
+ import ffmpegStatic from 'ffmpeg-static'
6
+ import { randomBytes } from 'crypto'
7
+ import { promises as fs, readFileSync, mkdtempSync, writeFileSync, rmSync } from 'fs'
8
+ import { gunzipSync } from 'zlib'
9
+ import path from 'path'
10
+ import { tmpdir } from './messages-media.js'
11
+ import { execFile } from 'child_process'
12
+ import { zip } from 'fflate'
13
+ import { createRequire } from 'module'
14
+ import { proto } from '../../WAProto/index.js'
15
+ import { CALL_AUDIO_PREFIX, CALL_VIDEO_PREFIX, MEDIA_KEYS, URL_REGEX, WA_DEFAULT_EPHEMERAL } from '../Defaults/index.js'
16
+ import { WAMessageStatus, WAProto } from '../Types/index.js'
17
+ import { isJidGroup, isJidNewsletter, isJidStatusBroadcast, jidNormalizedUser } from '../WABinary/index.js'
18
+ import { sha256 } from './crypto.js'
19
+ import { generateMessageIDV2, getKeyAuthor, unixTimestampSeconds } from './generics.js'
20
+ import { downloadContentFromMessage, encryptedStream, prepareStream, generateThumbnail, getAudioDuration, getAudioWaveform, getStream, toBuffer, normalizeToBuffer } from './messages-media.js'
21
+ import { shouldIncludeReportingToken } from './reporting-utils.js'
22
+
23
+ const require = createRequire(import.meta.url)
24
+ const { Exif: StickerExif } = require('wa-sticker-formatter')
25
+ if (ffmpegStatic) process.env.FFMPEG_PATH = ffmpegStatic
26
+
27
+ // ─── CONSTANTS ────────────────────────────────────────────────────────────────
28
+
29
+ // Default mimetypes — these are WA protocol constants, not proto schema, so a small map is correct
30
+ const MIMETYPE_DEFAULTS = { image: 'image/jpeg', video: 'video/mp4', document: 'application/pdf', audio: 'audio/ogg; codecs=opus', sticker: 'image/webp', 'product-catalog-image': 'image/jpeg' }
31
+
32
+ // High-level user-facing send keys that are NOT proto field names — used only to detect pass-through
33
+ const HIGH_LEVEL_KEYS = new Set(['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', 'payment', 'collection'])
34
+
35
+ // Wrapper field names for normalizeMessageContent — derived from what WA wraps messages in
36
+ const WRAPPER_KEYS = ['ephemeralMessage', 'viewOnceMessage', 'documentWithCaptionMessage', 'viewOnceMessageV2', 'viewOnceMessageV2Extension', 'editedMessage', 'groupMentionedMessage', 'botInvokeMessage', 'lottieStickerMessage', 'eventCoverImage', 'statusMentionMessage', 'pollCreationOptionImageMessage', 'associatedChildMessage', 'groupStatusMentionMessage', 'pollCreationMessageV4', 'pollCreationMessageV5', 'statusAddYours', 'groupStatusMessage', 'limitSharingMessage', 'botTaskMessage', 'questionMessage', 'botForwardedMessage']
37
+
38
+ const REUPLOAD_REQUIRED_STATUS = [410, 404]
39
+ const STICKER_MAX_BYTES = 1_000_000
40
+ const CIRCLE_GEQ = `clip(256-hypot(X-W/2\\,Y-H/2)\\,0\\,1)*255`
41
+ const ROUNDED_GEQ = `st(0\\,abs(X-W/2)-W/2+50);st(1\\,abs(Y-H/2)-H/2+50);st(2\\,hypot(max(ld(0)\\,0)\\,max(ld(1)\\,0))+min(max(ld(0)\\,ld(1))\\,0)-50);clip(-ld(2)\\,0\\,1)*255`
42
+ const BASE_CROP_VF = 'scale=512:512:force_original_aspect_ratio=increase,crop=512:512:(iw-512)/2:(ih-512)/2,fps=8'
43
+ const BASE_FULL_VF = 'scale=512:512,fps=8'
44
+ const WEBP_ANIM_CODEC = ['-vcodec', 'libwebp_anim', '-lossless', '0', '-compression_level', '4', '-q:v', '35', '-loop', '0', '-preset', 'default']
45
+ const WEBP_STATIC_CODEC = (q) => ['-vcodec', 'libwebp', '-lossless', '0', '-compression_level', '6', '-q:v', String(q), '-loop', '0', '-preset', 'picture']
46
+
47
+ // ─── STICKER ──────────────────────────────────────────────────────────────────
48
+
49
+ const ffmpegRun = (args) => new Promise((resolve, reject) => execFile(ffmpegStatic, args, { timeout: 120_000 }, (err) => err ? reject(err) : resolve()))
50
+
51
+ const buildArgs = async (stickerType, inPath, outPath, extraIn = [], codecArgs = []) => {
52
+ const isShape = stickerType === StickerTypes.CIRCLE || stickerType === StickerTypes.ROUNDED
53
+ if (isShape) {
54
+ const geq = stickerType === StickerTypes.CIRCLE ? CIRCLE_GEQ : ROUNDED_GEQ
55
+ return ['-y', '-threads', '0', ...extraIn, '-i', inPath, '-filter_complex', `[0:v]${BASE_CROP_VF},format=rgba,geq=r='r(X\\,Y)':g='g(X\\,Y)':b='b(X\\,Y)':a='${geq}'[out]`, '-map', '[out]', ...codecArgs, '-an', outPath]
56
+ }
57
+ const vf = stickerType === StickerTypes.DEFAULT ? null : stickerType === StickerTypes.FULL ? BASE_FULL_VF : BASE_CROP_VF
58
+ return ['-y', '-threads', '0', ...extraIn, '-i', inPath, ...(vf ? ['-vf', vf] : []), ...codecArgs, '-an', outPath]
59
+ }
60
+
61
+ const compressWebp = async (buf, { animated, quality = 80, stickerType = StickerTypes.ROUNDED, packName, authorName } = {}) => {
62
+ const detected = await fileTypeFromBuffer(buf)
63
+ const cuid = generateMessageIDV2()
64
+ const videoExts = new Set(['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'gif'])
65
+ if (detected && videoExts.has(detected.ext)) {
66
+ const tmpIn = path.join(tmpdir(), `stk_in_${cuid}.${detected.ext}`)
67
+ const tmpOut = path.join(tmpdir(), `stk_out_${cuid}.webp`)
68
+ try {
69
+ writeFileSync(tmpIn, buf)
70
+ await ffmpegRun(await buildArgs(stickerType, tmpIn, tmpOut, ['-t', '10'], WEBP_ANIM_CODEC))
71
+ const out = readFileSync(tmpOut)
72
+ return { buffer: packName ? await tagExif(await shrinkToLimit(out, { animated: true }), packName, authorName) : out, isAnimated: true }
73
+ } finally { fs.unlink(tmpIn).catch(() => { }); fs.unlink(tmpOut).catch(() => { }) }
74
+ }
75
+ if (detected?.mime === 'image/webp') {
76
+ const meta = await sharp(buf, { animated: true }).metadata()
77
+ const isAnim = animated ?? ((meta.pages || 1) > 1)
78
+ const tmpOut = path.join(tmpdir(), `stk_out_${cuid}.webp`)
79
+ if (isAnim) {
80
+ try {
81
+ await sharp(buf, { animated: true }).webp({ quality, effort: 1, loop: 0 }).toFile(tmpOut)
82
+ const out = readFileSync(tmpOut)
83
+ return { buffer: packName ? await tagExif(await shrinkToLimit(out, { animated: true }), packName, authorName) : out, isAnimated: true }
84
+ } finally { fs.unlink(tmpOut).catch(() => { }) }
85
+ }
86
+ const tmpIn = path.join(tmpdir(), `stk_in_${cuid}.webp`)
87
+ try {
88
+ writeFileSync(tmpIn, buf)
89
+ await ffmpegRun(await buildArgs(stickerType, tmpIn, tmpOut, [], WEBP_STATIC_CODEC(quality)))
90
+ const out = readFileSync(tmpOut)
91
+ return { buffer: packName ? await tagExif(await shrinkToLimit(out, { animated: false }), packName, authorName) : out, isAnimated: false }
92
+ } finally { fs.unlink(tmpIn).catch(() => { }); fs.unlink(tmpOut).catch(() => { }) }
93
+ }
94
+ const converted = await new Sticker(buf, { pack: packName, author: authorName, type: stickerType, quality }).toBuffer()
95
+ return { buffer: converted.length <= STICKER_MAX_BYTES ? converted : await shrinkToLimit(converted, { animated: false }), isAnimated: false }
96
+ }
97
+
98
+ const shrinkToLimit = async (buf, { animated } = {}, _depth = 0) => {
99
+ if (buf.length <= STICKER_MAX_BYTES) return buf
100
+ if (_depth >= 2) return buf
101
+ const ratio = STICKER_MAX_BYTES / buf.length
102
+ const q = Math.max(10, Math.min(60, Math.floor(ratio * 80 * 0.85)))
103
+ const out = (await compressWebp(buf, { animated, quality: q })).buffer
104
+ if (out.length <= STICKER_MAX_BYTES) return out
105
+ const q2 = Math.max(10, Math.floor(q * (STICKER_MAX_BYTES / out.length) * 0.85))
106
+ const out2 = (await compressWebp(buf, { animated, quality: q2 })).buffer
107
+ return out.length < out2.length ? out : out2
108
+ }
109
+
110
+ const tagExif = async (buf, packName, authorName) => new StickerExif({ pack: packName, author: authorName }).add(buf)
111
+
112
+ let _rlottieApi = null
113
+ const getRlottieApi = async () => {
114
+ if (_rlottieApi) return _rlottieApi
115
+ const { init } = await import('rlottie')
116
+ const wasmBuf = readFileSync(require.resolve('rlottie/wasm'))
117
+ _rlottieApi = await init('data:application/wasm;base64,' + wasmBuf.toString('base64'))
118
+ return _rlottieApi
119
+ }
120
+
121
+ const tgsToWebp = async (tgsBuffer, { quality = 80, fps = 30 } = {}) => {
122
+ const lottieJson = JSON.parse(gunzipSync(tgsBuffer).toString())
123
+ const api = await getRlottieApi()
124
+ const W = 512, H = 512, handle = api.lottie_init()
125
+ const jsonBuf = Buffer.from(JSON.stringify(lottieJson) + '\0')
126
+ const ptr = api._malloc(jsonBuf.length)
127
+ api.HEAPU8.set(jsonBuf, ptr)
128
+ const totalFrames = api.lottie_load_from_data(handle, ptr)
129
+ api.lottie_resize(handle, W, H)
130
+ const bufPtr = api.lottie_buffer(handle)
131
+ const tgsTmpDir = mkdtempSync(path.join(tmpdir(), 'tgs_'))
132
+ try {
133
+ for (let i = 0; i < totalFrames; i++) {
134
+ api.lottie_render(handle, i)
135
+ const rgba = Buffer.from(api.HEAPU8.buffer, bufPtr, W * H * 4)
136
+ await sharp(Buffer.from(rgba), { raw: { width: W, height: H, channels: 4 } }).png().toFile(path.join(tgsTmpDir, `f${String(i).padStart(4, '0')}.png`))
137
+ }
138
+ const encode = async (outFps, q) => { const outPath = path.join(tgsTmpDir, `out_${outFps}_${q}.webp`); await ffmpegRun(['-y', '-framerate', String(fps), '-i', path.join(tgsTmpDir, 'f%04d.png'), '-vcodec', 'libwebp', '-vf', `scale=512:512,fps=${outFps}`, '-lossless', '0', '-compression_level', '6', '-q:v', String(q), '-loop', '0', '-preset', 'default', '-an', '-vsync', '0', outPath]); return readFileSync(outPath) }
139
+ let out = await encode(fps, quality)
140
+ if (out.length <= STICKER_MAX_BYTES) return out
141
+ const targetFps = Math.max(8, Math.floor(fps * (STICKER_MAX_BYTES / out.length) * 0.9))
142
+ out = await encode(targetFps, quality)
143
+ if (out.length <= STICKER_MAX_BYTES) return out
144
+ const targetQ = Math.max(10, Math.floor(quality * (STICKER_MAX_BYTES / out.length) * 0.9))
145
+ return encode(targetFps, targetQ)
146
+ } finally { api.lottie_destroy(handle); rmSync(tgsTmpDir, { recursive: true, force: true }) }
147
+ }
148
+
149
+ // ─── EXPORTS: URL / LINK PREVIEW ─────────────────────────────────────────────
150
+
151
+ export const extractUrlFromText = (text) => text.match(URL_REGEX)?.[0]
152
+
153
+ export const generateLinkPreviewIfRequired = async (text, getUrlInfo, logger) => {
154
+ const url = extractUrlFromText(text)
155
+ if (!getUrlInfo || !url) return
156
+ try { return await getUrlInfo(url) } catch (e) { logger?.warn({ trace: e.stack }, 'url generation failed') }
157
+ }
158
+
159
+ // ─── EXPORTS: MESSAGE HELPERS ─────────────────────────────────────────────────
160
+
161
+ export const getContentType = (content) => {
162
+ if (!content) return
163
+ return Object.keys(content).find(k => (k === 'conversation' || k.includes('Message')) && k !== 'senderKeyDistributionMessage')
164
+ }
165
+
166
+ export const normalizeMessageContent = (content, logger) => {
167
+ if (!content) return
168
+ for (let i = 0; i < 5; i++) {
169
+ const inner = WRAPPER_KEYS.reduce((found, k) => found || content?.[k], null)
170
+ if (!inner) break
171
+ if (!inner.message) { logger?.warn({ i, keys: Object.keys(inner) }, 'normalizeMessageContent: inner wrapper has no message field'); break }
172
+ content = inner.message
173
+ }
174
+ return content
175
+ }
176
+
177
+ export const extractMessageContent = (content) => {
178
+ content = normalizeMessageContent(content)
179
+ for (const t of ['image', 'video', 'audio', 'document']) { if (content?.[`${t}Message`]?.viewOnce) return { [`${t}Message`]: content[`${t}Message`] } }
180
+ const fromButtons = (msg) => {
181
+ for (const t of ['image', 'document', 'video', 'location', 'product']) { if (msg[`${t}Message`]) return { [`${t}Message`]: msg[`${t}Message`] } }
182
+ return { conversation: msg.contentText || msg.hydratedContentText || msg.body?.text || '' }
183
+ }
184
+ if (content?.buttonsMessage) return fromButtons(content.buttonsMessage)
185
+ if (content?.interactiveMessage) return fromButtons(content.interactiveMessage)
186
+ for (const v of ['interactiveMessageTemplate', 'hydratedFourRowTemplate', 'hydratedTemplate', 'fourRowTemplate']) {
187
+ if (content?.templateMessage?.[v]) return fromButtons(content.templateMessage[v])
188
+ }
189
+ return content
190
+ }
191
+
192
+ const normalizeExternalAdReplyThumbnail = async (ext) => {
193
+ if (!ext?.thumbnail || !Buffer.isBuffer(ext.thumbnail) || ext.thumbnail.length <= 50_000) return ext
194
+ let quality = 90
195
+ while (quality >= 10) {
196
+ const thumbnail = await sharp(ext.thumbnail, { sequentialRead: true }).resize(200, 200, { fit: 'inside' }).jpeg({ quality }).toBuffer().catch(() => null)
197
+ if (!thumbnail) return ext
198
+ if (thumbnail.length <= 50_000) return { ...ext, thumbnail }
199
+ quality = Math.max(10, Math.floor(quality * (50_000 / thumbnail.length) * 0.85))
200
+ }
201
+ return ext
202
+ }
203
+
204
+ export const generateForwardMessageContent = (message, forceForward) => {
205
+ let content = normalizeMessageContent(message.message)
206
+ if (!content) throw new Boom('no content in message', { statusCode: 400 })
207
+ content = proto.Message.decode(proto.Message.encode(content).finish())
208
+ let key = Object.keys(content)[0]
209
+ const score = (content?.[key]?.contextInfo?.forwardingScore || 0) + (message.key.fromMe && !forceForward ? 0 : 1)
210
+ if (key === 'conversation') { content.extendedTextMessage = { text: content[key] }; delete content.conversation; key = 'extendedTextMessage' }
211
+ content[key].contextInfo = score > 0 ? { forwardingScore: score, isForwarded: true } : {}
212
+ return content
213
+ }
214
+
215
+ export const prepareDisappearingMessageSettingContent = (ephemeralExpiration) => WAProto.Message.fromObject({
216
+ ephemeralMessage: { message: { protocolMessage: { type: WAProto.Message.ProtocolMessage.Type.EPHEMERAL_SETTING, ephemeralExpiration: ephemeralExpiration || 0 } } }
217
+ })
218
+
219
+ // ─── EXPORTS: MEDIA ───────────────────────────────────────────────────────────
220
+
221
+ export const prepareWAMessageMedia = async (message, options) => {
222
+ const mediaType = MEDIA_KEYS.find(k => k in message)
223
+ if (!mediaType) throw new Boom('Invalid media type', { statusCode: 400 })
224
+ const protoKey = `${mediaType}Message`
225
+ const uploadData = { ...message, media: message[mediaType] }
226
+ delete uploadData[mediaType]
227
+ if (mediaType === 'document' && !uploadData.fileName) uploadData.fileName = 'file'
228
+ if (!uploadData.mimetype) uploadData.mimetype = MIMETYPE_DEFAULTS[mediaType] || 'application/octet-stream'
229
+
230
+ if (mediaType === 'sticker') {
231
+ try {
232
+ const rawBuf = await toBuffer((await getStream(uploadData.media)).stream)
233
+ const packName = message.pack || message.packName || options?.pack || options?.packName || 'ZervonexStickers'
234
+ const authorName = message.author || message.publisher || message.packPublisher || options?.author || options?.publisher || options?.packPublisher || 'Zervonex'
235
+ const { buffer } = await compressWebp(rawBuf, { packName, authorName, stickerType: message.stickerType || options?.stickerType || StickerTypes.ROUNDED, quality: message.quality || options?.quality || 80 })
236
+ uploadData.media = buffer
237
+ uploadData.stickerSentTs = Date.now()
238
+ } catch (e) { options.logger?.warn({ err: e }, 'sticker formatting failed, sending raw') }
239
+ }
240
+
241
+ const cacheableKey = typeof uploadData.media === 'object' && 'url' in uploadData.media && uploadData.media.url && options.mediaCache
242
+ ? `${mediaType}:${uploadData.media.url.toString()}` : null
243
+ if (cacheableKey) {
244
+ const cached = await options.mediaCache?.get(cacheableKey)
245
+ if (cached) {
246
+ const obj = WAProto.Message.decode(cached)
247
+ Object.assign(obj[protoKey], { ...uploadData, media: undefined })
248
+ return obj
249
+ }
250
+ }
251
+
252
+ const isNewsletter = !!options.jid && isJidNewsletter(options.jid)
253
+ const requiresDuration = mediaType === 'audio' && typeof uploadData.seconds === 'undefined'
254
+ const requiresThumbnail = (mediaType === 'image' || mediaType === 'video') && typeof uploadData.jpegThumbnail === 'undefined'
255
+ if (uploadData.jpegThumbnail) uploadData.jpegThumbnail = await normalizeToBuffer(uploadData.jpegThumbnail)
256
+ const requiresWaveform = mediaType === 'audio' && (uploadData.ptt === true || !!options.backgroundColor)
257
+
258
+ const enc = await (isNewsletter ? prepareStream : encryptedStream)(uploadData.media, options.mediaTypeOverride || mediaType, {
259
+ logger: options.logger, saveOriginalFileIfRequired: requiresDuration || requiresThumbnail || requiresWaveform,
260
+ opts: options.options, isPtt: uploadData.ptt, forceOpus: mediaType === 'audio' && uploadData.mimetype?.includes('opus'), convertVideo: mediaType === 'video',
261
+ })
262
+ const { mediaKey, encWriteStream, bodyPath, fileEncSha256, fileSha256, fileLength, opusConverted, encFilePath, encBuffer, cleanup } = enc
263
+ if (mediaType === 'audio' && opusConverted) uploadData.mimetype = 'audio/ogg; codecs=opus'
264
+ const fileEncSha256B64 = (isNewsletter ? fileSha256 : (fileEncSha256 ?? fileSha256)).toString('base64')
265
+
266
+ const [{ mediaUrl, directPath, handle }] = await Promise.all([
267
+ options.upload(encFilePath || encBuffer || encWriteStream, { fileEncSha256B64, mediaType, timeoutMs: options.mediaUploadTimeoutMs }),
268
+ (async () => {
269
+ try {
270
+ if (requiresThumbnail) {
271
+ const { thumbnail, originalImageDimensions } = await generateThumbnail(bodyPath, mediaType, options)
272
+ uploadData.jpegThumbnail = thumbnail
273
+ if (!uploadData.width && originalImageDimensions) { uploadData.width = originalImageDimensions.width; uploadData.height = originalImageDimensions.height }
274
+ }
275
+ if (requiresDuration) uploadData.seconds = await getAudioDuration(bodyPath)
276
+ if (requiresWaveform) {
277
+ try { uploadData.waveform = await getAudioWaveform(bodyPath, options.logger) } catch {
278
+ 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])
279
+ }
280
+ }
281
+ if (options.backgroundColor && mediaType === 'audio') uploadData.backgroundArgb = assertColor(options.backgroundColor)
282
+ } catch (e) { options.logger?.warn({ trace: e.stack }, 'failed to obtain extra info') }
283
+ })()
284
+ ]).finally(async () => {
285
+ if (encWriteStream && !Buffer.isBuffer(encWriteStream)) encWriteStream.destroy?.()
286
+ if (cleanup) await cleanup()
287
+ })
288
+
289
+ // Resolve proto constructor dynamically from proto.Message — no hardcoded map
290
+ const CtorName = mediaType.charAt(0).toUpperCase() + mediaType.slice(1) + 'Message'
291
+ const Ctor = WAProto.Message[CtorName] || { fromObject: o => o }
292
+ const obj = WAProto.Message.fromObject({
293
+ [protoKey]: Ctor.fromObject({ url: handle ? undefined : mediaUrl, directPath, mediaKey, fileEncSha256, fileSha256, fileLength, mediaKeyTimestamp: handle ? undefined : unixTimestampSeconds(), ...uploadData, media: undefined })
294
+ })
295
+ if (uploadData.ptv) { obj.ptvMessage = obj.videoMessage; delete obj.videoMessage }
296
+ if (cacheableKey) await options.mediaCache?.set(cacheableKey, WAProto.Message.encode(obj).finish())
297
+ return obj
298
+ }
299
+
300
+ const assertColor = (color) => {
301
+ if (typeof color === 'number') return color > 0 ? color : 0xffffffff + Number(color) + 1
302
+ const hex = color.trim().replace('#', '')
303
+ return parseInt(hex.length <= 6 ? 'FF' + hex.padStart(6, '0') : hex, 16)
304
+ }
305
+
306
+ // ─── EXPORTS: STICKER PACK ────────────────────────────────────────────────────
307
+
308
+ export const prepareStickerPackMessage = async (stickerPack, options) => {
309
+ if (Array.isArray(stickerPack)) stickerPack = { stickers: stickerPack }
310
+ else if (stickerPack && typeof stickerPack === 'object' && !stickerPack.stickers) {
311
+ const keys = Object.keys(stickerPack)
312
+ if (keys.length && keys.every(k => !isNaN(k))) stickerPack = { stickers: Object.values(stickerPack) }
313
+ }
314
+ const { stickers, cover, name, publisher, packId, packName: packNameAlias, packPublisher, author } = stickerPack
315
+ if (!stickers?.length) throw new Boom('Sticker pack must contain at least one sticker', { statusCode: 400 })
316
+ const stickerPackIdValue = packId || generateMessageIDV2()
317
+ const packName = name || packNameAlias || options?.packName || options?.name || 'ZervonexStickers'
318
+ const authorName = publisher || packPublisher || author || options?.packPublisher || options?.publisher || options?.author || 'Zervonex'
319
+ const MAX_STICKERS_PER_PACK = 60, skippedStickers = []
320
+ const runWithLimit = (() => {
321
+ const limit = 6, queue = []
322
+ let running = 0
323
+ return (fn) => new Promise((resolve, reject) => {
324
+ const run = () => { running++; fn().then(resolve, reject).finally(() => { running--; queue.shift()?.() }) }
325
+ running < limit ? run() : queue.push(run)
326
+ })
327
+ })()
328
+
329
+ const processSticker = async (s, i) => {
330
+ const uid = generateMessageIDV2()
331
+ const tmpOut = path.join(tmpdir(), `stk_out_${i}_${uid}.webp`)
332
+ try {
333
+ const raw = s.data || s.sticker || s.buffer || s.image || s.webp || s.file || s.path || s.url
334
+ if (!raw) { skippedStickers.push({ index: i, reason: 'No sticker data found' }); return null }
335
+ const buf = Buffer.isBuffer(raw) ? raw : await toBuffer((await getStream(raw)).stream)
336
+ if (!buf?.length) { skippedStickers.push({ index: i, reason: 'Empty buffer' }); return null }
337
+ const emojis = Array.isArray(s.emojis) ? s.emojis : Object.values(s.emojis || {})
338
+ let finalBuf
339
+ if (s.isLottie) {
340
+ finalBuf = await tagExif(await shrinkToLimit(await tgsToWebp(buf, { quality: 80, fps: 30 }), { animated: true }), packName, authorName)
341
+ } else if (s.isAnimated) {
342
+ const tmpIn = path.join(tmpdir(), `stk_in_${i}_${uid}.webp`), tmpRaw = path.join(tmpdir(), `stk_raw_${i}_${uid}.webp`)
343
+ writeFileSync(tmpIn, buf)
344
+ try {
345
+ await ffmpegRun(await buildArgs(s.type || StickerTypes.ROUNDED, tmpIn, tmpRaw, ['-vcodec', 'vp9', '-t', '10'], WEBP_ANIM_CODEC))
346
+ finalBuf = await tagExif(await shrinkToLimit(readFileSync(tmpRaw), { animated: true }), packName, authorName)
347
+ } finally { fs.unlink(tmpIn).catch(() => { }); fs.unlink(tmpRaw).catch(() => { }) }
348
+ } else {
349
+ finalBuf = await shrinkToLimit(await new Sticker(buf, { pack: packName, author: authorName, type: s.type || StickerTypes.ROUNDED, quality: 80 }).toBuffer(), { animated: false })
350
+ }
351
+ const hash = sha256(finalBuf).toString('base64').replace(/\//g, '-').replace(/=/g, '')
352
+ const fileSize = finalBuf.length
353
+ writeFileSync(tmpOut, finalBuf); finalBuf = null
354
+ return { fileName: `${hash}.webp`, filePath: tmpOut, fileSize, mimetype: 'image/webp', isAnimated: s.isAnimated || false, isLottie: s.isLottie || false, emojis, accessibilityLabel: s.accessibilityLabel || '' }
355
+ } catch (e) {
356
+ options.logger?.warn({ err: e }, `failed processing sticker at index ${i}`)
357
+ skippedStickers.push({ index: i, reason: e.message }); fs.unlink(tmpOut).catch(() => { }); return null
358
+ }
359
+ }
360
+
361
+ const processBatch = async (batch, batchIdx, totalBatches, coverFilePath) => {
362
+ const batchData = {}
363
+ for (const s of batch) batchData[s.fileName] = [new Uint8Array(readFileSync(s.filePath)), { level: 0 }]
364
+ const trayFile = totalBatches > 1 ? `${stickerPackIdValue}_batch${batchIdx}.webp` : `${stickerPackIdValue}.webp`
365
+ batchData[trayFile] = [new Uint8Array(readFileSync(coverFilePath)), { level: 0 }]
366
+ const zipBuf = await new Promise((resolve, reject) => zip(batchData, (err, data) => err ? reject(err) : resolve(Buffer.from(data))))
367
+ const upload = await encryptedStream(zipBuf, 'sticker-pack', { logger: options.logger, opts: options.options })
368
+ const uploadRes = await options.upload(upload.encFilePath || upload.encBuffer, { fileEncSha256B64: upload.fileEncSha256.toString('base64'), mediaType: 'sticker-pack', timeoutMs: options.mediaUploadTimeoutMs })
369
+ if (upload.encFilePath) fs.unlink(upload.encFilePath).catch(() => { })
370
+ let thumbRes = null
371
+ try {
372
+ const thumbTmpPath = path.join(tmpdir(), `stk_thumb_${generateMessageIDV2()}.jpg`)
373
+ await sharp(coverFilePath).resize(252, 252).jpeg().toFile(thumbTmpPath)
374
+ const thumbBuf = readFileSync(thumbTmpPath); fs.unlink(thumbTmpPath).catch(() => { })
375
+ const thumbUpload = await encryptedStream(thumbBuf, 'thumbnail-sticker-pack', { logger: options.logger, opts: options.options, mediaKey: upload.mediaKey })
376
+ thumbRes = await options.upload(thumbUpload.encFilePath || thumbUpload.encBuffer, { fileEncSha256B64: thumbUpload.fileEncSha256.toString('base64'), mediaType: 'thumbnail-sticker-pack', timeoutMs: options.mediaUploadTimeoutMs })
377
+ if (thumbUpload.encFilePath) fs.unlink(thumbUpload.encFilePath).catch(() => { })
378
+ thumbRes._enc = thumbUpload; thumbRes._thumbBuf = thumbBuf
379
+ } catch (e) { options.logger?.warn({ err: e }, 'failed generating sticker pack thumbnail') }
380
+ return {
381
+ name: totalBatches > 1 ? `${packName} (${batchIdx + 1}/${totalBatches})` : packName, publisher: authorName,
382
+ stickerPackId: totalBatches > 1 ? `${stickerPackIdValue}_${batchIdx}` : stickerPackIdValue,
383
+ stickerPackOrigin: proto.Message.StickerPackMessage.StickerPackOrigin.USER_CREATED, stickerPackSize: zipBuf.length,
384
+ stickers: batch.map(s => ({ fileName: s.fileName, mimetype: s.mimetype, isAnimated: s.isAnimated, isLottie: s.isLottie, emojis: s.emojis, accessibilityLabel: s.accessibilityLabel })),
385
+ fileSha256: upload.fileSha256, fileEncSha256: upload.fileEncSha256, mediaKey: upload.mediaKey,
386
+ directPath: uploadRes.directPath, fileLength: upload.fileLength, mediaKeyTimestamp: unixTimestampSeconds(), trayIconFileName: trayFile,
387
+ ...(thumbRes && { thumbnailDirectPath: thumbRes.directPath, thumbnailHeight: 252, thumbnailWidth: 252, thumbnailSha256: thumbRes._enc?.fileSha256, thumbnailEncSha256: thumbRes._enc?.fileEncSha256, imageDataHash: thumbRes._thumbBuf ? sha256(thumbRes._thumbBuf).toString('base64') : undefined }),
388
+ }
389
+ }
390
+
391
+ const coverTmpPath = path.join(tmpdir(), `stk_cover_${generateMessageIDV2()}.webp`)
392
+ let coverFilePath
393
+ try {
394
+ if (cover) {
395
+ const coverBuf = Buffer.isBuffer(cover) ? cover : await toBuffer((await getStream(cover)).stream)
396
+ const isWebp = coverBuf[0] === 0x52 && coverBuf[1] === 0x49 && coverBuf[8] === 0x57 && coverBuf[9] === 0x45
397
+ writeFileSync(coverTmpPath, isWebp ? coverBuf : await new Sticker(coverBuf, { pack: packName, author: authorName, type: StickerTypes.ROUNDED, quality: 95 }).toBuffer())
398
+ coverFilePath = coverTmpPath
399
+ }
400
+ const allProcessed = (await Promise.all(stickers.map((s, j) => runWithLimit(() => processSticker(s, j))))).filter(Boolean)
401
+ if (!coverFilePath && allProcessed.length) coverFilePath = allProcessed[0].filePath
402
+ const sizeBatches = []
403
+ let curBatch = []
404
+ for (const s of allProcessed) { if (curBatch.length >= MAX_STICKERS_PER_PACK) { sizeBatches.push(curBatch); curBatch = [] }; curBatch.push(s) }
405
+ if (curBatch.length) sizeBatches.push(curBatch)
406
+ const totalBatches = sizeBatches.length
407
+ const allResults = await Promise.all(sizeBatches.map((batch, idx) => processBatch(batch, idx, totalBatches, coverFilePath).finally(() => { for (const s of batch) fs.unlink(s.filePath).catch(() => { }) })))
408
+ if (!allResults.length) throw new Boom('No valid stickers could be processed', { statusCode: 400 })
409
+ return allResults.length > 1 ? { stickerPackMessage: allResults, isBatched: true, batchCount: allResults.length } : { stickerPackMessage: allResults[0], isBatched: false }
410
+ } finally { fs.unlink(coverTmpPath).catch(() => { }) }
411
+ }
412
+
413
+ // ─── MESSAGE CONTENT GENERATION ───────────────────────────────────────────────
414
+
415
+ // Handler dispatch: each key maps to an async fn(message, options) -> raw proto-compatible object.
416
+ // To add a new message type: add one entry here. No else-if chains, no declarations elsewhere.
417
+ // Proto enum values come directly from proto.Message.* — nothing is hardcoded.
418
+ const MESSAGE_HANDLERS = {
419
+ text: async (msg, opts) => {
420
+ const ext = { text: msg.text }
421
+ let urlInfo = msg.linkPreview ?? await generateLinkPreviewIfRequired(msg.text, opts.getUrlInfo, opts.logger)
422
+ if (urlInfo) {
423
+ Object.assign(ext, { matchedText: urlInfo['matched-text'], jpegThumbnail: urlInfo.jpegThumbnail, description: urlInfo.description, title: urlInfo.title, previewType: urlInfo.previewType ?? 0 })
424
+ const img = urlInfo.highQualityThumbnail
425
+ if (img) Object.assign(ext, { thumbnailDirectPath: img.directPath, mediaKey: img.mediaKey, mediaKeyTimestamp: img.mediaKeyTimestamp, thumbnailWidth: img.width, thumbnailHeight: img.height, thumbnailSha256: img.fileSha256, thumbnailEncSha256: img.fileEncSha256 })
426
+ }
427
+ if (opts.backgroundColor) ext.backgroundArgb = assertColor(opts.backgroundColor)
428
+ if (opts.font) ext.font = opts.font
429
+ return { extendedTextMessage: ext }
430
+ },
431
+ contacts: async (msg) => {
432
+ const { contacts } = msg.contacts
433
+ if (!contacts.length) throw new Boom('require atleast 1 contact', { statusCode: 400 })
434
+ return contacts.length === 1 ? { contactMessage: WAProto.Message.ContactMessage.create(contacts[0]) } : { contactsArrayMessage: WAProto.Message.ContactsArrayMessage.create(msg.contacts) }
435
+ },
436
+ location: async (msg) => ({ locationMessage: WAProto.Message.LocationMessage.create(msg.location) }),
437
+ react: async (msg) => { if (!msg.react.senderTimestampMs) msg.react.senderTimestampMs = Date.now(); return { reactionMessage: WAProto.Message.ReactionMessage.create(msg.react) } },
438
+ delete: async (msg) => ({ protocolMessage: { key: msg.delete, type: WAProto.Message.ProtocolMessage.Type.REVOKE } }),
439
+ forward: async (msg) => generateForwardMessageContent(msg.forward, msg.force),
440
+ disappearingMessagesInChat: async (msg) => prepareDisappearingMessageSettingContent(typeof msg.disappearingMessagesInChat === 'boolean' ? (msg.disappearingMessagesInChat ? WA_DEFAULT_EPHEMERAL : 0) : msg.disappearingMessagesInChat),
441
+ groupInvite: async (msg, opts) => {
442
+ const m = { groupInviteMessage: { inviteCode: msg.groupInvite.inviteCode, inviteExpiration: msg.groupInvite.inviteExpiration, caption: msg.groupInvite.text, groupJid: msg.groupInvite.jid, groupName: msg.groupInvite.subject } }
443
+ if (opts.getProfilePicUrl) {
444
+ const pfpUrl = await opts.getProfilePicUrl(msg.groupInvite.jid, 'preview')
445
+ if (pfpUrl) { const resp = await fetch(pfpUrl, { method: 'GET', dispatcher: opts?.options?.dispatcher }); if (resp.ok) m.groupInviteMessage.jpegThumbnail = Buffer.from(await resp.arrayBuffer()) }
446
+ }
447
+ return m
448
+ },
449
+ stickerPack: async (msg, opts) => {
450
+ const result = await prepareStickerPackMessage(msg.stickerPack, opts)
451
+ if (result.isBatched) return { _batched: true, stickerPackMessage: result.stickerPackMessage, batchCount: result.batchCount }
452
+ return { _finalize: true, stickerPackMessage: result.stickerPackMessage }
453
+ },
454
+ pin: async (msg, opts) => {
455
+ const messageKey = typeof msg.pin === 'boolean' ? (opts.quoted?.key || (() => { throw new Boom('No quoted message key found for pin operation') })()) : typeof msg.pin === 'object' ? (msg.pin.key || (msg.pin.id ? { remoteJid: opts.jid, fromMe: msg.pin.fromMe || false, id: msg.pin.id, participant: msg.pin.participant } : null)) : msg.pin
456
+ if (!messageKey?.id) throw new Boom('Invalid message key for pin operation')
457
+ const shouldPin = typeof msg.pin === 'boolean' ? msg.pin : (msg.pin?.unpin !== true)
458
+ return { pinInChatMessage: { key: messageKey, type: shouldPin ? 1 : 2, senderTimestampMs: Date.now().toString() }, messageContextInfo: { messageAddOnDurationInSecs: shouldPin ? (msg.pin?.time || msg.time || 86400) : 0 } }
459
+ },
460
+ keep: async (msg) => ({ keepInChatMessage: { key: msg.keep, keepType: msg.type, timestampMs: Date.now() } }),
461
+ call: async (msg) => ({ scheduledCallCreationMessage: { scheduledTimestampMs: msg.call.time || Date.now(), callType: msg.call.type || 1, title: msg.call.title } }),
462
+ paymentInvite: async (msg) => ({ paymentInviteMessage: { serviceType: msg.paymentInvite.type, expiryTimestamp: msg.paymentInvite.expiry } }),
463
+ buttonReply: async (msg) => ({
464
+ list: { listResponseMessage: { title: msg.buttonReply.title, description: msg.buttonReply.description, singleSelectReply: { selectedRowId: msg.buttonReply.rowId }, lisType: proto.Message.ListResponseMessage.ListType.SINGLE_SELECT } },
465
+ template: { templateButtonReplyMessage: { selectedDisplayText: msg.buttonReply.displayText, selectedId: msg.buttonReply.id, selectedIndex: msg.buttonReply.index } },
466
+ interactive: { interactiveResponseMessage: { body: { text: msg.buttonReply.displayText, format: proto.Message.InteractiveResponseMessage.Body.Format.EXTENSIONS_1 }, nativeFlowResponseMessage: { name: msg.buttonReply.nativeFlows?.name, paramsJson: msg.buttonReply.nativeFlows?.paramsJson, version: msg.buttonReply.nativeFlows?.version } } },
467
+ }[msg.type] || { buttonsResponseMessage: { selectedButtonId: msg.buttonReply.id, selectedDisplayText: msg.buttonReply.displayText, type: proto.Message.ButtonsResponseMessage.Type.DISPLAY_TEXT } }),
468
+ ptv: async (msg, opts) => { const { videoMessage } = await prepareWAMessageMedia({ video: msg.video }, opts); return { ptvMessage: videoMessage } },
469
+ product: async (msg, opts) => { const { imageMessage } = await prepareWAMessageMedia({ image: msg.product.productImage }, opts); return { productMessage: WAProto.Message.ProductMessage.create({ ...msg, product: { ...msg.product, productImage: imageMessage } }) } },
470
+ order: async (msg) => ({ orderMessage: WAProto.Message.OrderMessage.fromObject({ orderId: msg.order.id, thumbnail: await normalizeToBuffer(msg.order.thumbnail), itemCount: msg.order.itemCount, status: msg.order.status, surface: msg.order.surface, orderTitle: msg.order.title, message: msg.order.text, sellerJid: msg.order.seller, token: msg.order.token, totalAmount1000: msg.order.amount, totalCurrencyCode: msg.order.currency }) }),
471
+ sections: async (msg) => ({ listMessage: { title: msg.title, buttonText: msg.buttonText, footerText: msg.footer, description: msg.text, sections: msg.sections, listType: proto.Message.ListMessage.ListType.SINGLE_SELECT, contextInfo: { ...(msg.contextInfo || {}), ...(msg.mentions ? { mentionedJid: msg.mentions } : {}) } } }),
472
+ listReply: async (msg) => ({ listResponseMessage: { ...msg.listReply } }),
473
+ event: async (msg, opts) => {
474
+ const startTime = Math.floor(msg.event.startDate.getTime() / 1000)
475
+ const eventMessage = { name: msg.event.name, description: msg.event.description, startTime, isCanceled: msg.event.isCancelled ?? false, extraGuestsAllowed: msg.event.extraGuestsAllowed, isScheduleCall: msg.event.isScheduleCall ?? false, location: msg.event.location, ...(msg.event.endDate ? { endTime: msg.event.endDate.getTime() / 1000 } : {}) }
476
+ if (msg.event.call && opts.getCallLink) eventMessage.joinLink = (msg.event.call === 'audio' ? CALL_AUDIO_PREFIX : CALL_VIDEO_PREFIX) + await opts.getCallLink(msg.event.call, { startTime })
477
+ return { eventMessage, messageContextInfo: { messageSecret: msg.event.messageSecret || randomBytes(32) } }
478
+ },
479
+ poll: async (msg) => {
480
+ msg.poll.selectableCount ||= 0; msg.poll.toAnnouncementGroup ||= false
481
+ if (!Array.isArray(msg.poll.values)) throw new Boom('Invalid poll values', { statusCode: 400 })
482
+ if (msg.poll.selectableCount < 0 || msg.poll.selectableCount > msg.poll.values.length) throw new Boom(`poll.selectableCount should be >= 0 and <= ${msg.poll.values.length}`, { statusCode: 400 })
483
+ const pollMsg = { name: msg.poll.name, selectableOptionsCount: msg.poll.selectableCount, options: msg.poll.values.map(optionName => ({ optionName })) }
484
+ const key = msg.poll.toAnnouncementGroup ? 'pollCreationMessageV2' : msg.poll.selectableCount === 1 ? 'pollCreationMessageV3' : 'pollCreationMessage'
485
+ return { [key]: pollMsg, messageContextInfo: { messageSecret: msg.poll.messageSecret || randomBytes(32) } }
486
+ },
487
+ inviteAdmin: async (msg) => ({ newsletterAdminInviteMessage: { inviteExpiration: msg.inviteAdmin.inviteExpiration, caption: msg.inviteAdmin.text, newsletterJid: msg.inviteAdmin.jid, newsletterName: msg.inviteAdmin.subject, jpegThumbnail: await normalizeToBuffer(msg.inviteAdmin.thumbnail) } }),
488
+ requestPayment: async (msg, opts) => {
489
+ const data = msg.requestPayment || msg.payment
490
+ const sticker = data.sticker ? await prepareWAMessageMedia({ sticker: data.sticker }, opts) : null
491
+ const noteMessage = sticker ? { stickerMessage: { ...sticker.stickerMessage, contextInfo: data.contextInfo } } : { extendedTextMessage: { text: data.note || 'Notes', ...(data.contextInfo ? { contextInfo: data.contextInfo } : {}) } }
492
+ const m = { requestPaymentMessage: WAProto.Message.RequestPaymentMessage.fromObject({ expiryTimestamp: data.expiryTimestamp || data.expiry || 0, amount1000: data.amount1000 || data.amount || 0, currencyCodeIso4217: data.currencyCodeIso4217 || data.currency || 'IDR', requestFrom: data.requestFrom || data.from || '0@s.whatsapp.net', noteMessage, background: data.background ?? { id: 'DEFAULT', placeholderArgb: 0xfff0f0f0 } }) }
493
+ if ((data.currencyCodeIso4217 === 'BRL' || data.currency === 'BRL') && data.pixKey) { m.requestPaymentMessage.noteMessage.extendedTextMessage ??= { text: '' }; m.requestPaymentMessage.noteMessage.extendedTextMessage.text += `\nPix Key: ${data.pixKey}` }
494
+ return m
495
+ },
496
+ album: async (msg) => ({ albumMessage: { expectedImageCount: msg.album.filter(i => 'image' in i).length, expectedVideoCount: msg.album.filter(i => 'video' in i).length } }),
497
+ // Proto pass-throughs: one-liners using proto enum values directly
498
+ sharePhoneNumber: async () => ({ protocolMessage: { type: proto.Message.ProtocolMessage.Type.SHARE_PHONE_NUMBER } }),
499
+ requestPhoneNumber: async () => ({ requestPhoneNumberMessage: {} }),
500
+ limitSharing: async (msg) => ({ protocolMessage: { type: proto.Message.ProtocolMessage.Type.LIMIT_SHARING, limitSharing: { sharingLimited: msg.limitSharing === true, trigger: 1, limitSharingSettingTimestamp: Date.now(), initiatedByMe: true } } }),
501
+ payment: async (msg, opts) => MESSAGE_HANDLERS.requestPayment(msg, opts),
502
+ }
503
+
504
+ // Interactive overlays: keyed by the input field, each returns a complete replacement for m.
505
+ // Looked up dynamically — adding a new overlay type = one new entry, nothing else.
506
+ const applyContextInfoAndMentions = async (interactive, msg) => {
507
+ if (msg.contextInfo) { const ctx = { ...(interactive.contextInfo || {}), ...msg.contextInfo }; if (ctx.externalAdReply) ctx.externalAdReply = await normalizeExternalAdReplyThumbnail(ctx.externalAdReply); interactive.contextInfo = ctx }
508
+ if (msg.mentions?.length) interactive.contextInfo = { ...(interactive.contextInfo || {}), mentionedJid: msg.mentions }
509
+ }
510
+
511
+ const OVERLAY_HANDLERS = {
512
+ buttons: async (m, msg) => {
513
+ const hasNativeFlow = msg.buttons.some(b => b.nativeFlowInfo || b.name || b.buttonParamsJson)
514
+ if (hasNativeFlow) {
515
+ const interactive = { body: { text: msg.text || msg.caption || msg.contentText || '' }, footer: { text: msg.footer || msg.footerText || '' }, nativeFlowMessage: { buttons: msg.buttons.map(b => b.name && b.buttonParamsJson ? b : b.nativeFlowInfo ? { name: b.nativeFlowInfo.name, buttonParamsJson: b.nativeFlowInfo.paramsJson } : { name: 'quick_reply', buttonParamsJson: JSON.stringify({ display_text: b.buttonText?.displayText || b.displayText || '', id: b.buttonId || b.id || '' }) }) } }
516
+ if (msg.title) interactive.header = { title: msg.title, subtitle: msg.subtitle || '', hasMediaAttachment: msg.hasMediaAttachment || false }
517
+ if (Object.keys(m).length) { interactive.header ??= { title: msg.title || '', hasMediaAttachment: true }; Object.assign(interactive.header, m) }
518
+ await applyContextInfoAndMentions(interactive, msg)
519
+ return { interactiveMessage: interactive }
520
+ }
521
+ const bm = { buttons: msg.buttons.map(b => ({ ...b, type: proto.Message.ButtonsMessage.Button.Type.RESPONSE })) }
522
+ if ('text' in msg) { bm.contentText = msg.text; bm.headerType = proto.Message.ButtonsMessage.HeaderType.EMPTY }
523
+ else {
524
+ if ('caption' in msg) bm.contentText = msg.caption
525
+ const type = Object.keys(m)[0]?.replace('Message', '').toUpperCase()
526
+ bm.headerType = proto.Message.ButtonsMessage.HeaderType[type] || proto.Message.ButtonsMessage.HeaderType.EMPTY
527
+ Object.assign(bm, m)
528
+ }
529
+ if (msg.title) { bm.text = msg.title; bm.headerType = proto.Message.ButtonsMessage.HeaderType.TEXT }
530
+ if (msg.footer) bm.footerText = msg.footer
531
+ if (msg.contextInfo) bm.contextInfo = { ...(bm.contextInfo || {}), ...msg.contextInfo }
532
+ if (msg.mentions?.length) bm.contextInfo = { ...(bm.contextInfo || {}), mentionedJid: msg.mentions }
533
+ return { buttonsMessage: bm }
534
+ },
535
+ templateButtons: (m, msg) => {
536
+ const ht = { hydratedButtons: msg.templateButtons, ...('text' in msg ? { hydratedContentText: msg.text } : { ...('caption' in msg ? { hydratedContentText: msg.caption } : {}), ...m }) }
537
+ if (msg.footer) ht.hydratedFooterText = msg.footer
538
+ if (msg.contextInfo) ht.contextInfo = { ...(ht.contextInfo || {}), ...msg.contextInfo }
539
+ if (msg.mentions?.length) ht.contextInfo = { ...(ht.contextInfo || {}), mentionedJid: msg.mentions }
540
+ return { templateMessage: { fourRowTemplate: ht, hydratedTemplate: ht } }
541
+ },
542
+ interactiveButtons: async (m, msg) => {
543
+ const interactive = { nativeFlowMessage: WAProto.Message.InteractiveMessage.NativeFlowMessage.fromObject({ buttons: msg.interactiveButtons }) }
544
+ if ('text' in msg) { interactive.body = { text: msg.text }; interactive.header = { title: msg.title || '', subtitle: msg.subtitle || '', hasMediaAttachment: false } }
545
+ else if ('caption' in msg) { interactive.body = { text: msg.caption }; interactive.header = { title: msg.title || '', subtitle: msg.subtitle || '', hasMediaAttachment: msg.hasMediaAttachment ?? !!Object.keys(m).length }; if (Object.keys(m).length) Object.assign(interactive.header, m) }
546
+ if (msg.footer) interactive.footer = { text: msg.footer }
547
+ await applyContextInfoAndMentions(interactive, msg)
548
+ return { interactiveMessage: interactive, messageContextInfo: { messageSecret: randomBytes(32) } }
549
+ },
550
+ shop: async (m, msg) => {
551
+ const interactive = { shopStorefrontMessage: WAProto.Message.InteractiveMessage.ShopMessage.fromObject({ surface: msg.shop.surface || 1, id: msg.shop.id || msg.id }) }
552
+ if ('text' in msg) interactive.body = { text: msg.text }
553
+ else if ('caption' in msg) interactive.body = { text: msg.caption }
554
+ if (msg.title || Object.keys(m).length) { interactive.header = { title: msg.title || '', subtitle: msg.subtitle || '', hasMediaAttachment: msg.hasMediaAttachment ?? !!Object.keys(m).length }; if (Object.keys(m).length) Object.assign(interactive.header, m) }
555
+ if (msg.footer) interactive.footer = { text: msg.footer }
556
+ await applyContextInfoAndMentions(interactive, msg)
557
+ return { interactiveMessage: interactive }
558
+ },
559
+ collection: async (m, msg) => {
560
+ const interactive = { collectionMessage: { bizJid: msg.collection.bizJid, id: msg.collection.id, messageVersion: msg.collection.version } }
561
+ if ('text' in msg) { interactive.body = { text: msg.text }; interactive.header = { title: msg.title || '', hasMediaAttachment: false } }
562
+ else if ('caption' in msg) { interactive.body = { text: msg.caption }; interactive.header = { title: msg.title || '', hasMediaAttachment: msg.hasMediaAttachment ?? false }; if (Object.keys(m).length) Object.assign(interactive.header, m) }
563
+ if (msg.footer) interactive.footer = { text: msg.footer }
564
+ await applyContextInfoAndMentions(interactive, msg)
565
+ return { interactiveMessage: interactive }
566
+ },
567
+ }
568
+
569
+ export const generateWAMessageContent = async (message, options = {}) => {
570
+ if (!message || typeof message !== 'object') return WAProto.Message.create({})
571
+ const msgKeys = Object.keys(message)
572
+ // Raw proto pass-through: no high-level keys and already has proto field names, or is a known wrapper
573
+ const isWrapper = ['viewOnceMessage', 'ephemeralMessage', 'viewOnceMessageV2', 'documentWithCaptionMessage'].some(k => k in message)
574
+ if (isWrapper || (!msgKeys.some(k => HIGH_LEVEL_KEYS.has(k)) && msgKeys.some(k => k.endsWith('Message') || k === 'conversation'))) return WAProto.Message.create(message)
575
+
576
+ let m = {}
577
+ // Text without interactive overlay keys goes straight to text handler
578
+ const overlayKeys = Object.keys(OVERLAY_HANDLERS)
579
+ if ('text' in message && !overlayKeys.some(k => k in message)) {
580
+ m = await MESSAGE_HANDLERS.text(message, options)
581
+ } else {
582
+ // Find first matching handler key (skip 'text' here — handled above)
583
+ const handlerKey = msgKeys.find(k => k !== 'text' && MESSAGE_HANDLERS[k])
584
+ if (handlerKey) {
585
+ const result = await MESSAGE_HANDLERS[handlerKey](message, options)
586
+ if (result?._batched) return { stickerPackMessage: result.stickerPackMessage, isBatched: true, batchCount: result.batchCount }
587
+ if (result?._finalize) return WAProto.Message.create({ stickerPackMessage: result.stickerPackMessage })
588
+ m = result
589
+ } else if (MEDIA_KEYS.some(k => k in message)) {
590
+ m = await prepareWAMessageMedia(message, options)
591
+ }
592
+ }
593
+
594
+ // Apply overlays: any overlay key present in message replaces m
595
+ for (const key of overlayKeys) { if (key in message && message[key]) { m = await OVERLAY_HANDLERS[key](m, message); break } }
596
+
597
+ // Merge contextInfo / mentions onto the primary content key — deep-merges externalAdReply to prevent thumbnail/field loss
598
+ const finalKey = Object.keys(m)[0]
599
+ if ((message.contextInfo || message.mentions?.length) && finalKey && m[finalKey] && typeof m[finalKey] === 'object') {
600
+ const existing = m[finalKey].contextInfo || {}; const incoming = message.contextInfo || {}
601
+ const mergedExt = (existing.externalAdReply || incoming.externalAdReply) ? await normalizeExternalAdReplyThumbnail({ ...(existing.externalAdReply || {}), ...(incoming.externalAdReply || {}) }) : undefined
602
+ m[finalKey].contextInfo = { ...existing, ...incoming, ...(mergedExt ? { externalAdReply: mergedExt } : {}), ...(message.mentions?.length ? { mentionedJid: message.mentions } : {}) }
603
+ }
604
+
605
+ if ('viewOnce' in message && message.viewOnce || 'viewOnceMessage' in message) m = { viewOnceMessage: { message: m } }
606
+ if ('edit' in message) m = { protocolMessage: { key: message.edit, editedMessage: m, timestampMs: Date.now(), type: WAProto.Message.ProtocolMessage.Type.MESSAGE_EDIT } }
607
+
608
+ if (shouldIncludeReportingToken(m)) { m.messageContextInfo ??= {}; m.messageContextInfo.messageSecret ??= randomBytes(32) }
609
+ return WAProto.Message.create(m)
610
+ }
611
+
612
+ // ─── ASSEMBLE FULL WA MESSAGE ─────────────────────────────────────────────────
613
+
614
+ export const generateWAMessageFromContent = (jid, message, options) => {
615
+ if (!options.timestamp) options.timestamp = new Date()
616
+ const innerMessage = normalizeMessageContent(message)
617
+ const key = getContentType(innerMessage)
618
+ const { quoted, userJid } = options
619
+ if (quoted && !isJidNewsletter(jid)) {
620
+ const participant = quoted.key.fromMe ? userJid : (quoted.participant || quoted.key.participant || quoted.key.remoteJid)
621
+ const normalizedQuoted = normalizeMessageContent(quoted.message)
622
+ if (normalizedQuoted) {
623
+ const quotedType = getContentType(normalizedQuoted)
624
+ const quotedMsg = proto.Message.fromObject({ [quotedType]: normalizedQuoted[quotedType] })
625
+ const quotedContent = quotedMsg[quotedType]
626
+ if (typeof quotedContent === 'object' && quotedContent && 'contextInfo' in quotedContent) delete quotedContent.contextInfo
627
+ const contextInfo = innerMessage[key]?.contextInfo || {}
628
+ contextInfo.participant = jidNormalizedUser(participant)
629
+ contextInfo.stanzaId = quoted.key.id
630
+ contextInfo.quotedMessage = quotedMsg
631
+ if (jid !== quoted.key.remoteJid) contextInfo.remoteJid = quoted.key.remoteJid
632
+ if (innerMessage[key]) innerMessage[key].contextInfo = contextInfo
633
+ }
634
+ }
635
+ if (options?.ephemeralExpiration && key !== 'protocolMessage' && key !== 'ephemeralMessage' && !isJidNewsletter(jid)) {
636
+ innerMessage[key].contextInfo = { ...(innerMessage[key].contextInfo || {}), expiration: options.ephemeralExpiration || WA_DEFAULT_EPHEMERAL }
637
+ }
638
+ return WAProto.WebMessageInfo.fromObject({
639
+ key: { remoteJid: jid, fromMe: true, id: options?.messageId || generateMessageIDV2() },
640
+ message: WAProto.Message.create(message),
641
+ messageTimestamp: unixTimestampSeconds(options.timestamp),
642
+ messageStubParameters: [],
643
+ participant: (isJidGroup(jid) || isJidStatusBroadcast(jid)) ? userJid : undefined,
644
+ status: WAMessageStatus.PENDING,
645
+ })
646
+ }
647
+
648
+ // Primary entry point. generateWAMessageContent returns a proto Message object.
649
+ // generateWAMessageFromContent wraps existing content into a WebMessageInfo.
650
+ // Call the wrong one by accident and it self-corrects rather than failing silently.
651
+ export const generateWAMessage = async (jid, content, options = {}) => {
652
+ if (content?.key?.remoteJid && content?.message) return content
653
+ options.logger = options?.logger?.child({ msgId: options.messageId })
654
+ return generateWAMessageFromContent(jid, await generateWAMessageContent(content, { ...options, jid }), options)
655
+ }
656
+
657
+ // ─── UPDATE UTILITIES ─────────────────────────────────────────────────────────
658
+
659
+ export const updateMessageWithReceipt = (msg, receipt) => {
660
+ msg.userReceipt ||= []
661
+ const recp = msg.userReceipt.find(m => m.userJid === receipt.userJid)
662
+ if (recp) Object.assign(recp, receipt)
663
+ else msg.userReceipt.push(receipt)
664
+ }
665
+
666
+ export const updateMessageWithReaction = (msg, reaction) => {
667
+ const authorID = getKeyAuthor(reaction.key)
668
+ msg.reactions = (msg.reactions || []).filter(r => getKeyAuthor(r.key) !== authorID)
669
+ reaction.text ||= ''
670
+ msg.reactions.push(reaction)
671
+ }
672
+
673
+ export const updateMessageWithPollUpdate = (msg, update) => {
674
+ const authorID = getKeyAuthor(update.pollUpdateMessageKey)
675
+ msg.pollUpdates = (msg.pollUpdates || []).filter(r => getKeyAuthor(r.pollUpdateMessageKey) !== authorID)
676
+ if (update.vote?.selectedOptions?.length) msg.pollUpdates.push(update)
677
+ }
678
+
679
+ export const updateMessageWithEventResponse = (msg, update) => {
680
+ const authorID = getKeyAuthor(update.eventResponseMessageKey)
681
+ msg.eventResponses = (msg.eventResponses || []).filter(r => getKeyAuthor(r.eventResponseMessageKey) !== authorID)
682
+ msg.eventResponses.push(update)
683
+ }
684
+
685
+ export const getAggregateVotesInPollMessage = ({ message, pollUpdates }, meId) => {
686
+ const opts = message?.pollCreationMessage?.options || message?.pollCreationMessageV2?.options || message?.pollCreationMessageV3?.options || []
687
+ const voteHashMap = opts.reduce((acc, opt) => { acc[sha256(Buffer.from(opt.optionName || '')).toString()] = { name: opt.optionName || '', voters: [] }; return acc }, {})
688
+ for (const update of pollUpdates || []) {
689
+ if (!update.vote) continue
690
+ for (const option of update.vote.selectedOptions || []) {
691
+ const hash = option.toString()
692
+ voteHashMap[hash] ||= { name: 'Unknown', voters: [] }
693
+ voteHashMap[hash].voters.push(getKeyAuthor(update.pollUpdateMessageKey, meId))
694
+ }
695
+ }
696
+ return Object.values(voteHashMap)
697
+ }
698
+
699
+ export const getAggregateResponsesInEventMessage = ({ eventResponses }, meId) => {
700
+ const responseMap = { GOING: { response: 'GOING', responders: [] }, NOT_GOING: { response: 'NOT_GOING', responders: [] }, MAYBE: { response: 'MAYBE', responders: [] } }
701
+ for (const update of eventResponses || []) { const type = update.eventResponse || 'UNKNOWN'; if (responseMap[type]) responseMap[type].responders.push(getKeyAuthor(update.eventResponseMessageKey, meId)) }
702
+ return Object.values(responseMap)
703
+ }
704
+
705
+ export const aggregateMessageKeysNotFromMe = (keys) => {
706
+ const keyMap = {}
707
+ for (const { remoteJid, id, participant, fromMe } of keys) {
708
+ if (!fromMe) { const uqKey = `${remoteJid}:${participant || ''}`; keyMap[uqKey] ||= { jid: remoteJid, participant, messageIds: [] }; keyMap[uqKey].messageIds.push(id) }
709
+ }
710
+ return Object.values(keyMap)
711
+ }
712
+
713
+ export const downloadMediaMessage = async (message, type, options, ctx) => {
714
+ const downloadMsg = async () => {
715
+ let normalized = message
716
+ if (!message.message && message.key) normalized = { key: message.key, message: message.quoted?.message || message, messageTimestamp: message.messageTimestamp }
717
+ const mContent = extractMessageContent(normalized.message)
718
+ if (!mContent) throw new Boom('No message present', { statusCode: 400, data: message })
719
+ const contentType = getContentType(mContent)
720
+ let mediaType = contentType?.replace('Message', '')
721
+ const media = mContent[contentType]
722
+ if (!media || typeof media !== 'object' || (!('url' in media) && !('thumbnailDirectPath' in media))) throw new Boom(`"${contentType}" message is not a media message`)
723
+ const download = ('thumbnailDirectPath' in media && !('url' in media)) ? { directPath: media.thumbnailDirectPath, mediaKey: media.mediaKey } : media
724
+ if ('thumbnailDirectPath' in media && !('url' in media)) mediaType = 'thumbnail-link'
725
+ const stream = await downloadContentFromMessage(download, mediaType, options)
726
+ if (type === 'buffer') { const chunks = []; for await (const chunk of stream) chunks.push(chunk); return Buffer.concat(chunks) }
727
+ return stream
728
+ }
729
+ return downloadMsg().catch(async (error) => {
730
+ if (ctx && typeof error?.status === 'number' && REUPLOAD_REQUIRED_STATUS.includes(error.status)) { ctx.logger.info({ key: message.key }, 'sending reupload media request...'); message = await ctx.reuploadRequest(message); return downloadMsg() }
731
+ throw error
732
+ })
733
+ }
734
+
735
+ export const assertMediaContent = (content) => {
736
+ content = extractMessageContent(content)
737
+ const mediaContent = content?.documentMessage || content?.imageMessage || content?.videoMessage || content?.audioMessage || content?.stickerMessage || content?.stickerPackMessage
738
+ if (!mediaContent) throw new Boom('given message is not a media message', { statusCode: 400, data: content })
739
+ return mediaContent
740
+ }
741
+
742
+ 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'
743
+
744
+ export const patchMessageForMdIfRequired = (message) => {
745
+ if (message?.buttonsMessage || message?.templateMessage || message?.listMessage || message?.interactiveMessage?.nativeFlowMessage) {
746
+ message = { viewOnceMessageV2Extension: { message: { messageContextInfo: { deviceListMetadataVersion: 2, deviceListMetadata: {} }, ...message } } }
747
+ }
748
+ return message
749
+ }
750
+
751
+ export const hasNonNullishProperty = (message, key) => typeof message === 'object' && message !== null && key in message && message[key] !== null && message[key] !== undefined
752
+ export const hasOptionalProperty = (obj, key) => typeof obj === 'object' && obj !== null && key in obj && obj[key] !== null && obj[key] !== undefined