@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,520 @@
1
+ import { Boom } from '@hapi/boom'
2
+ import { spawn } from 'child_process'
3
+ import * as Crypto from 'crypto'
4
+ import { once } from 'events'
5
+ import { createReadStream, createWriteStream, mkdirSync, unlinkSync, promises as fs } from 'fs'
6
+ import { tmpdir as osTmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { Readable, Transform } from 'stream'
9
+ import { URL } from 'url'
10
+ import { fileTypeFromBuffer } from 'file-type'
11
+ import https from 'node:https'
12
+ import http from 'node:http'
13
+ import { proto } from '../../WAProto/index.js'
14
+ import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP } from '../Defaults/index.js'
15
+ import { getBinaryNodeChild, getBinaryNodeChildBuffer, jidNormalizedUser } from '../WABinary/index.js'
16
+ import { aesDecryptGCM, aesEncryptGCM, hkdf } from './crypto.js'
17
+ import { generateMessageIDV2 } from './generics.js'
18
+
19
+ // ─── TMP REGISTRY ─────────────────────────────────────────────────────────────
20
+
21
+ const _tmpRegistry = new Set()
22
+ process.on('exit', () => { for (const p of _tmpRegistry) try { unlinkSync(p) } catch { } })
23
+ const _trackTmp = p => { _tmpRegistry.add(p); return p }
24
+ const _untrackTmp = p => _tmpRegistry.delete(p)
25
+
26
+ const BAILEYS_TMP_DIR = (() => {
27
+ for (const dir of [join(process.cwd(), '.b-tmp'), join(osTmpdir(), 'b-tmp')]) {
28
+ try {
29
+ mkdirSync(dir, { recursive: true, mode: 0o700 })
30
+ process.env.TMPDIR = dir
31
+ process.env.VIPS_TMPDIR = dir
32
+ process.env.VIPS_DISC_THRESHOLD = '999999999999'
33
+ return dir
34
+ } catch { }
35
+ }
36
+ throw new Error('No writable tmp directory found')
37
+ })()
38
+
39
+ export const tmpdir = () => BAILEYS_TMP_DIR
40
+
41
+ // ─── FFMPEG ───────────────────────────────────────────────────────────────────
42
+
43
+ let _ffmpegPath = null
44
+ const getFfmpegPath = async () => {
45
+ if (_ffmpegPath) return _ffmpegPath
46
+ try { const { default: p } = await import('ffmpeg-static'); if (p) return (_ffmpegPath = p) } catch { }
47
+ return (_ffmpegPath = 'ffmpeg')
48
+ }
49
+
50
+ const _spawnFfmpeg = (ffmpegPath, args) => new Promise((resolve, reject) => {
51
+ const ff = spawn(ffmpegPath, args, { stdio: ['ignore', 'ignore', 'pipe'] })
52
+ let stderr = ''
53
+ ff.stderr.on('data', d => { stderr += d })
54
+ ff.on('close', code => code === 0 ? resolve() : reject(new Error(`FFmpeg exited ${code}: ${stderr.slice(-500)}`)))
55
+ ff.on('error', reject)
56
+ })
57
+
58
+ // ─── IMAGE PROCESSING ─────────────────────────────────────────────────────────
59
+
60
+ export const getImageProcessingLibrary = async () => {
61
+ const [jimp, sharp] = await Promise.all([import('jimp').catch(() => null), import('sharp').catch(() => null)])
62
+ if (sharp) return { sharp }
63
+ if (jimp) return { jimp }
64
+ throw new Boom('No image processing library available')
65
+ }
66
+
67
+ export const extractImageThumb = async (bufferOrFilePath, width = 32) => {
68
+ if (bufferOrFilePath instanceof Readable) bufferOrFilePath = await toBuffer(bufferOrFilePath)
69
+ const lib = await getImageProcessingLibrary()
70
+ if ('sharp' in lib && typeof lib.sharp?.default === 'function') {
71
+ const img = lib.sharp.default(bufferOrFilePath)
72
+ const { width: w, height: h } = await img.metadata()
73
+ return { buffer: await img.resize(width).jpeg({ quality: 95 }).toBuffer(), original: { width: w, height: h } }
74
+ }
75
+ if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
76
+ const jimp = await lib.jimp.Jimp.read(bufferOrFilePath)
77
+ return { buffer: await jimp.resize({ w: width, mode: lib.jimp.ResizeStrategy.BILINEAR }).getBuffer('image/jpeg', { quality: 95 }), original: { width: jimp.width, height: jimp.height } }
78
+ }
79
+ throw new Boom('No image processing library available')
80
+ }
81
+
82
+ export const generateProfilePicture = async mediaUpload => {
83
+ const src = Buffer.isBuffer(mediaUpload) ? mediaUpload : 'url' in mediaUpload ? mediaUpload.url.toString() : await toBuffer(mediaUpload.stream)
84
+ const lib = await getImageProcessingLibrary()
85
+ if ('sharp' in lib && typeof lib.sharp?.default === 'function') return { img: await lib.sharp.default(src).resize(720, 720, { fit: 'inside' }).jpeg({ quality: 80 }).toBuffer() }
86
+ if ('jimp' in lib && typeof lib.jimp?.read === 'function') {
87
+ const { read, MIME_JPEG } = lib.jimp
88
+ const image = await read(src)
89
+ return { img: await image.crop(0, 0, image.getWidth(), image.getHeight()).scaleToFit(720, 720).getBufferAsync(MIME_JPEG) }
90
+ }
91
+ throw new Boom('No image processing library available')
92
+ }
93
+
94
+ export const generateThumbnail = async (file, mediaType, options) => {
95
+ if (mediaType === 'image') {
96
+ const { buffer, original } = await extractImageThumb(file)
97
+ return { thumbnail: buffer.toString('base64'), originalImageDimensions: (original.width && original.height) ? original : undefined }
98
+ }
99
+ if (mediaType === 'video') {
100
+ const imgFilename = join(tmpdir(), generateMessageIDV2() + '.jpg')
101
+ try {
102
+ const ff = await getFfmpegPath()
103
+ await new Promise((resolve, reject) => {
104
+ const proc = spawn(ff, ['-ss', '00:00:00', '-i', file, '-y', '-vf', 'scale=32:-1', '-vframes', '1', '-f', 'image2', imgFilename])
105
+ proc.on('close', code => code === 0 ? resolve() : reject(new Error(`FFmpeg thumb exit ${code}`)))
106
+ proc.on('error', reject)
107
+ })
108
+ const thumbnail = (await fs.readFile(imgFilename)).toString('base64')
109
+ await fs.unlink(imgFilename).catch(() => { })
110
+ return { thumbnail, originalImageDimensions: undefined }
111
+ } catch (err) { options.logger?.debug('could not generate video thumb: ' + err) }
112
+ }
113
+ return { thumbnail: undefined, originalImageDimensions: undefined }
114
+ }
115
+
116
+ // ─── HKDF ─────────────────────────────────────────────────────────────────────
117
+
118
+ export const hkdfInfoKey = type => `WhatsApp ${MEDIA_HKDF_KEY_MAPPING[type]} Keys`
119
+
120
+ export const getMediaKeys = async (buffer, mediaType) => {
121
+ if (!buffer) throw new Boom('Cannot derive from empty media key')
122
+ if (typeof buffer === 'string') buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
123
+ const k = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
124
+ return { iv: k.slice(0, 16), cipherKey: k.slice(16, 48), macKey: k.slice(48, 80) }
125
+ }
126
+
127
+ // ─── STREAM UTILS ─────────────────────────────────────────────────────────────
128
+
129
+ export const toReadable = buffer => { const r = new Readable({ read: () => { } }); r.push(buffer); r.push(null); return r }
130
+
131
+ export const toBuffer = async stream => {
132
+ if (Buffer.isBuffer(stream)) return stream
133
+ const chunks = []
134
+ for await (const chunk of stream) chunks.push(chunk)
135
+ stream.destroy?.()
136
+ return Buffer.concat(chunks)
137
+ }
138
+
139
+ const _isHttpUrl = str => { try { const { protocol } = new URL(str); return protocol === 'http:' || protocol === 'https:' } catch { return false } }
140
+
141
+
142
+ export const getStream = async (item, opts) => {
143
+ if (!item) throw new Boom('Item is required for getStream', { statusCode: 400 })
144
+ if (Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' }
145
+ if (item?.stream?.pipe) return { stream: item.stream, type: 'readable' }
146
+ if (item?.pipe) return { stream: item, type: 'readable' }
147
+ if (item && typeof item === 'object' && 'url' in item) {
148
+ const urlStr = item.url.toString()
149
+ if (Buffer.isBuffer(item.url)) return { stream: toReadable(item.url), type: 'buffer' }
150
+ if (urlStr.startsWith('data:')) return { stream: toReadable(Buffer.from(urlStr.split(',')[1], 'base64')), type: 'buffer' }
151
+ if (_isHttpUrl(urlStr)) return { stream: await getHttpStream(item.url, opts), type: 'remote' }
152
+ return { stream: createReadStream(item.url), type: 'file' }
153
+ }
154
+ if (typeof item === 'string') {
155
+ if (item.startsWith('data:')) return { stream: toReadable(Buffer.from(item.split(',')[1], 'base64')), type: 'buffer' }
156
+ if (_isHttpUrl(item)) return { stream: await getHttpStream(item, opts), type: 'remote' }
157
+ return { stream: createReadStream(item), type: 'file' }
158
+ }
159
+ throw new Boom(`Invalid input type for getStream: ${typeof item}`, { statusCode: 400 })
160
+ }
161
+
162
+ export const getHttpStream = (url, options = {}) => new Promise((resolve, reject) => {
163
+ const urlObj = new URL(url.toString())
164
+ const isHttps = urlObj.protocol === 'https:'
165
+ const mod = isHttps ? https : http
166
+ const req = mod.request({ hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'GET', family: 4, headers: { ...(options.headers ?? {}), Origin: DEFAULT_ORIGIN } }, res => {
167
+ if (res.statusCode < 200 || res.statusCode >= 300) { res.destroy(); return reject(new Boom(`Failed to fetch stream from ${url}`, { statusCode: res.statusCode, data: { url } })) }
168
+ resolve(res)
169
+ })
170
+ req.on('error', reject)
171
+ req.end()
172
+ })
173
+
174
+ export const normalizeToBuffer = async (input) => {
175
+ if (!input) return undefined; if (Buffer.isBuffer(input)) return input; if (input?.url) return normalizeToBuffer(input.url.toString())
176
+ if (typeof input === 'string' && input.startsWith('data:')) return Buffer.from(input.split(',')[1], 'base64')
177
+ return toBuffer((await getStream(input)).stream)
178
+ }
179
+ // ─── RAW UPLOAD ───────────────────────────────────────────────────────────────
180
+
181
+ export const getRawMediaUploadData = async (media, mediaType, logger) => {
182
+ const { stream } = await getStream(media)
183
+ const hasher = Crypto.createHash('sha256')
184
+ const filePath = join(tmpdir(), mediaType + generateMessageIDV2())
185
+ const fileWriteStream = createWriteStream(filePath)
186
+ let fileLength = 0
187
+ try {
188
+ for await (const data of stream) { fileLength += data.length; hasher.update(data); if (!fileWriteStream.write(data)) await once(fileWriteStream, 'drain') }
189
+ fileWriteStream.end()
190
+ await once(fileWriteStream, 'finish')
191
+ stream.destroy()
192
+ return { filePath, fileSha256: hasher.digest(), fileLength }
193
+ } catch (error) {
194
+ fileWriteStream.destroy(); stream.destroy()
195
+ await fs.unlink(filePath).catch(() => { })
196
+ throw error
197
+ }
198
+ }
199
+
200
+ // ─── AUDIO / VIDEO ────────────────────────────────────────────────────────────
201
+
202
+ export const mediaMessageSHA256B64 = message => { const media = Object.values(message)[0]; return media?.fileSha256 && Buffer.from(media.fileSha256).toString('base64') }
203
+
204
+ export const getAudioDuration = async (buffer, logger) => {
205
+ try {
206
+ const mm = await import('music-metadata')
207
+ if (Buffer.isBuffer(buffer)) return (await mm.parseBuffer(buffer, undefined, { duration: true })).format.duration
208
+ if (typeof buffer === 'string') return (await mm.parseFile(buffer, { duration: true })).format.duration
209
+ return (await mm.parseStream(buffer, undefined, { duration: true })).format.duration
210
+ } catch (e) { logger?.debug({ trace: e?.stack || e }, 'failed to determine audio duration'); return undefined }
211
+ }
212
+
213
+ export const getAudioWaveform = async (buffer, logger) => {
214
+ const fallback = 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, 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, 0, 99])
215
+ const bars = 64
216
+ try {
217
+ const ffmpegPath = await getFfmpegPath()
218
+ const rawPCM = await new Promise(async (resolve, reject) => {
219
+ const chunks = []
220
+ let stderr = ''
221
+ const ff = spawn(ffmpegPath, ['-i', 'pipe:0', '-f', 's16le', '-ac', '1', '-ar', '16000', 'pipe:1'], { stdio: ['pipe', 'pipe', 'pipe'] })
222
+ ff.stdout.on('data', d => chunks.push(d))
223
+ ff.stderr.on('data', d => { stderr += d })
224
+ ff.on('close', code => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`FFmpeg waveform exit ${code}: ${stderr.slice(-300)}`)))
225
+ ff.on('error', reject)
226
+ ff.stdin.end(Buffer.isBuffer(buffer) ? buffer : typeof buffer === 'string' ? await fs.readFile(buffer) : await toBuffer(buffer))
227
+ })
228
+ if (!rawPCM.length) throw new Error('empty PCM output')
229
+ const samples = rawPCM.length >> 1
230
+ const blockSize = Math.max(1, Math.floor(samples / bars))
231
+ const blockSums = new Float64Array(bars)
232
+ for (let i = 0; i < samples; i++) blockSums[Math.min(bars - 1, Math.floor(i / blockSize))] += Math.abs(rawPCM.readInt16LE(i * 2))
233
+ const avg = Array.from(blockSums, sum => sum / blockSize / 32768)
234
+ const max = Math.max(...avg, 0.0001)
235
+ return new Uint8Array(avg.map(v => Math.max(0, Math.min(100, Math.round((v / max) * 100)))))
236
+ } catch (e) { logger?.debug({ trace: e?.stack || e }, 'failed to generate waveform, using fallback'); return fallback }
237
+ }
238
+
239
+ // ─── FFMPEG CONVERTERS ────────────────────────────────────────────────────────
240
+
241
+ const _cleanupFiles = async (...paths) => Promise.all(paths.map(p => p ? fs.unlink(p).catch(() => { }) : Promise.resolve()))
242
+
243
+ const _animatedWebpToMp4 = async (buffer, ffmpegPath) => {
244
+ const { default: sharp } = await import('sharp')
245
+ const meta = await sharp(buffer, { animated: true }).metadata()
246
+ const pages = meta.pages ?? 1, pageH = meta.pageHeight ?? Math.round(meta.height / pages)
247
+ const delays = meta.delay?.length === pages ? meta.delay : Array(pages).fill(100)
248
+ const uid = generateMessageIDV2()
249
+ const frameDir = join(tmpdir(), `webp-frames-${uid}`), concatFile = join(tmpdir(), `webp-concat-${uid}.txt`), outputPath = join(tmpdir(), `webp-out-${uid}.mp4`)
250
+ await fs.mkdir(frameDir, { recursive: true })
251
+ try {
252
+ const { data, info } = await sharp(buffer, { animated: true }).raw().toBuffer({ resolveWithObject: true })
253
+ const frameSize = info.width * pageH * info.channels
254
+ const framePaths = []
255
+ for (let i = 0; i < pages; i++) {
256
+ const fp = join(frameDir, `f${i}.png`)
257
+ await sharp(data.slice(i * frameSize, (i + 1) * frameSize), { raw: { width: info.width, height: pageH, channels: info.channels } }).png().toFile(fp)
258
+ framePaths.push(fp)
259
+ }
260
+ const lines = framePaths.flatMap((p, i) => [`file '${p}'`, `duration ${(delays[i] / 1000).toFixed(6)}`])
261
+ lines.push(`file '${framePaths[framePaths.length - 1]}'`)
262
+ await fs.writeFile(concatFile, lines.join('\n'))
263
+ await _spawnFfmpeg(ffmpegPath, ['-y', '-f', 'concat', '-safe', '0', '-i', concatFile, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '18', '-pix_fmt', 'yuv420p', '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-movflags', '+faststart', '-map_metadata', '-1', outputPath])
264
+ return await fs.readFile(outputPath)
265
+ } finally {
266
+ const files = await fs.readdir(frameDir).catch(() => [])
267
+ await Promise.all(files.map(f => fs.unlink(join(frameDir, f)).catch(() => { })))
268
+ await fs.rmdir(frameDir).catch(() => { })
269
+ await _cleanupFiles(concatFile, outputPath)
270
+ }
271
+ }
272
+
273
+ const _tmpConvert = async (buffer, inSuffix, outSuffix, ffmpegArgs, logger) => {
274
+ const inputPath = _trackTmp(join(tmpdir(), `in-${generateMessageIDV2()}${inSuffix}`))
275
+ const outputPath = _trackTmp(join(tmpdir(), `out-${generateMessageIDV2()}${outSuffix}`))
276
+ await fs.writeFile(inputPath, buffer)
277
+ try { await _spawnFfmpeg(await getFfmpegPath(), ['-y', '-i', inputPath, ...ffmpegArgs, outputPath]); return await fs.readFile(outputPath) }
278
+ finally { await _cleanupFiles(inputPath, outputPath); _untrackTmp(inputPath); _untrackTmp(outputPath) }
279
+ }
280
+
281
+ const convertToOpusBuffer = async (buffer, logger) => {
282
+ const detected = await fileTypeFromBuffer(buffer)
283
+ if (detected?.ext === 'opus') return buffer
284
+ return _tmpConvert(buffer, detected?.ext ? `.${detected.ext}` : '', '.ogg', ['-vn', '-c:a', 'libopus', '-b:a', '128k', '-ar', '48000', '-ac', '1', '-vbr', 'on', '-compression_level', '10', '-frame_duration', '20', '-application', 'audio', '-map_metadata', '-1'], logger)
285
+ }
286
+
287
+ const convertToMp4Buffer = async (buffer, logger) => {
288
+ try {
289
+ const { default: sharp } = await import('sharp')
290
+ const meta = await sharp(buffer, { animated: true }).metadata()
291
+ if (meta.format === 'webp' && (meta.pages ?? 1) > 1) return await _animatedWebpToMp4(buffer, await getFfmpegPath())
292
+ } catch { }
293
+ const detected = await fileTypeFromBuffer(buffer)
294
+ return _tmpConvert(buffer, detected?.ext ? `.${detected.ext}` : '', '.mp4', ['-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-pix_fmt', 'yuv420p', '-vf', 'scale=trunc(iw/2)*2:trunc(ih/2)*2', '-c:a', 'aac', '-b:a', '128k', '-movflags', '+faststart', '-map_metadata', '-1'], logger)
295
+ }
296
+
297
+ // ─── PREPARE / ENCRYPT STREAM ─────────────────────────────────────────────────
298
+
299
+ export const prepareStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts, convertVideo } = {}) => {
300
+ const { stream, type } = await getStream(media, opts)
301
+ let buffer = await toBuffer(stream)
302
+ if (mediaType === 'video' && convertVideo) {
303
+ try { buffer = await convertToMp4Buffer(buffer, logger) } catch (e) { logger?.error('failed to convert video:', e) }
304
+ }
305
+ let bodyPath, didSaveToTmpPath = false
306
+ try {
307
+ if (type === 'file') bodyPath = media.url
308
+ else if (saveOriginalFileIfRequired) { bodyPath = join(tmpdir(), mediaType + generateMessageIDV2()); await fs.writeFile(bodyPath, buffer); didSaveToTmpPath = true }
309
+ return { mediaKey: undefined, encWriteStream: buffer, fileLength: buffer.length, fileSha256: Crypto.createHash('sha256').update(buffer).digest(), fileEncSha256: undefined, bodyPath, didSaveToTmpPath }
310
+ } catch (error) {
311
+ if (didSaveToTmpPath && bodyPath) await fs.unlink(bodyPath).catch(() => { })
312
+ throw error
313
+ }
314
+ }
315
+
316
+ export const encryptedStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts, mediaKey: providedMediaKey, isPtt, forceOpus, convertVideo } = {}) => {
317
+ const { stream, type } = await getStream(media, opts)
318
+ let finalStream = stream, opusConverted = false
319
+ if (mediaType === 'audio' && (isPtt === true || forceOpus === true)) {
320
+ try { finalStream = toReadable(await convertToOpusBuffer(await toBuffer(stream), logger)); opusConverted = true }
321
+ catch { finalStream = (await getStream(media, opts)).stream }
322
+ }
323
+ if (mediaType === 'video' && convertVideo === true) finalStream = toReadable(await convertToMp4Buffer(await toBuffer(finalStream), logger))
324
+
325
+ const mediaKey = providedMediaKey || Crypto.randomBytes(32)
326
+ const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType)
327
+ const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
328
+ const hmac = Crypto.createHmac('sha256', macKey).update(iv)
329
+ const sha256Plain = Crypto.createHash('sha256'), sha256Enc = Crypto.createHash('sha256')
330
+ const encChunks = [], plainChunks = saveOriginalFileIfRequired ? [] : null
331
+ let fileLength = 0, encFilePath = null, originalFilePath = null
332
+
333
+ try {
334
+ for await (const data of finalStream) {
335
+ fileLength += data.length
336
+ if (type === 'remote' && opts?.maxContentLength && fileLength > opts.maxContentLength) throw new Boom('content length exceeded', { data: { media, type } })
337
+ plainChunks?.push(data)
338
+ sha256Plain.update(data)
339
+ const encrypted = aes.update(data)
340
+ sha256Enc.update(encrypted); hmac.update(encrypted); encChunks.push(encrypted)
341
+ }
342
+ const finalData = aes.final()
343
+ sha256Enc.update(finalData); hmac.update(finalData); encChunks.push(finalData)
344
+ const mac = hmac.digest().slice(0, 10)
345
+ sha256Enc.update(mac); encChunks.push(mac)
346
+ finalStream.destroy()
347
+
348
+ const encBuffer = Buffer.concat(encChunks)
349
+ const fileEncSha256 = sha256Enc.digest()
350
+ const fileSha256 = sha256Plain.digest()
351
+
352
+ if (plainChunks) {
353
+ try { originalFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-original'); await fs.writeFile(originalFilePath, Buffer.concat(plainChunks)) }
354
+ catch { originalFilePath = null }
355
+ }
356
+ let useMemory = false
357
+ try { encFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-enc'); await fs.writeFile(encFilePath, encBuffer) }
358
+ catch { encFilePath = null; useMemory = true }
359
+
360
+ const cleanup = async () => _cleanupFiles(encFilePath, originalFilePath)
361
+ return { mediaKey, bodyPath: originalFilePath, encFilePath, encBuffer: useMemory ? encBuffer : null, mac, fileEncSha256, fileSha256, fileLength, opusConverted, cleanup }
362
+ } catch (error) {
363
+ aes.destroy(); hmac.destroy(); sha256Plain.destroy(); sha256Enc.destroy(); finalStream.destroy()
364
+ await _cleanupFiles(encFilePath, originalFilePath)
365
+ throw error
366
+ }
367
+ }
368
+
369
+ // ─── DOWNLOAD ─────────────────────────────────────────────────────────────────
370
+
371
+ const DEF_HOST = 'mmg.whatsapp.net'
372
+ const AES_CHUNK_SIZE = 16
373
+ const toSmallestChunkSize = num => Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE
374
+
375
+ export const getUrlFromDirectPath = directPath => directPath ? (directPath.startsWith('http') ? directPath : `https://${DEF_HOST}${directPath.startsWith('/') ? '' : '/'}${directPath}`) : undefined
376
+
377
+ export const downloadContentFromMessage = async ({ mediaKey, directPath, url }, type, opts = {}) => {
378
+ const downloadUrl = url?.startsWith('https://mmg.whatsapp.net/') ? url : getUrlFromDirectPath(directPath)
379
+ if (!downloadUrl) throw new Boom('No valid media URL or directPath present', { statusCode: 400 })
380
+ return downloadEncryptedContent(downloadUrl, await getMediaKeys(mediaKey, type), opts)
381
+ }
382
+
383
+ export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, { startByte, endByte, options } = {}) => {
384
+ let bytesFetched = 0, startChunk = 0, firstBlockIsIV = false
385
+ if (startByte) {
386
+ const chunk = toSmallestChunkSize(startByte || 0)
387
+ if (chunk) { startChunk = chunk - AES_CHUNK_SIZE; bytesFetched = chunk; firstBlockIsIV = true }
388
+ }
389
+ const endChunk = endByte ? toSmallestChunkSize(endByte || 0) + AES_CHUNK_SIZE : undefined
390
+ const headers = { ...(options?.headers ? (Array.isArray(options.headers) ? Object.fromEntries(options.headers) : options.headers) : {}), Origin: DEFAULT_ORIGIN }
391
+ if (startChunk || endChunk) headers.Range = `bytes=${startChunk}-${endChunk || ''}`
392
+ const fetched = await getHttpStream(downloadUrl, { ...(options || {}), headers })
393
+ let remainingBytes = Buffer.from([]), aes
394
+ const pushBytes = (bytes, push) => {
395
+ if (startByte || endByte) {
396
+ const start = bytesFetched >= startByte ? undefined : Math.max(startByte - bytesFetched, 0)
397
+ const end = bytesFetched + bytes.length < endByte ? undefined : Math.max(endByte - bytesFetched, 0)
398
+ push(bytes.slice(start, end)); bytesFetched += bytes.length
399
+ } else push(bytes)
400
+ }
401
+ return fetched.pipe(new Transform({
402
+ transform(chunk, _, callback) {
403
+ let data = Buffer.concat([remainingBytes, chunk])
404
+ const decryptLength = toSmallestChunkSize(data.length)
405
+ remainingBytes = data.slice(decryptLength); data = data.slice(0, decryptLength)
406
+ if (!aes) {
407
+ let ivValue = iv
408
+ if (firstBlockIsIV) { ivValue = data.slice(0, AES_CHUNK_SIZE); data = data.slice(AES_CHUNK_SIZE) }
409
+ aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue)
410
+ if (endByte) aes.setAutoPadding(false)
411
+ }
412
+ try { pushBytes(aes.update(data), b => this.push(b)); callback() } catch (error) { callback(error) }
413
+ },
414
+ final(callback) { try { pushBytes(aes.final(), b => this.push(b)); callback() } catch (error) { callback(error) } }
415
+ }), { end: true })
416
+ }
417
+
418
+ // ─── UPLOAD ───────────────────────────────────────────────────────────────────
419
+
420
+ export const encodeBase64EncodedStringForUpload = b64 => encodeURIComponent(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''))
421
+
422
+ export const extensionForMediaMessage = message => {
423
+ const type = Object.keys(message)[0]
424
+ if (type === 'locationMessage' || type === 'liveLocationMessage' || type === 'productMessage') return '.jpeg'
425
+ return message[type].mimetype.split(';')[0]?.split('/')[1]
426
+ }
427
+
428
+ const httpsPost = (url, body, headers, signal) => new Promise((resolve, reject) => {
429
+ const urlObj = new URL(url)
430
+ const isHttps = urlObj.protocol === 'https:'
431
+ const buf = Buffer.isBuffer(body) ? body : null
432
+ const req = (isHttps ? https : http).request({ hostname: urlObj.hostname, port: urlObj.port || (isHttps ? 443 : 80), path: urlObj.pathname + urlObj.search, method: 'POST', family: 4, headers: { ...headers, ...(buf ? { 'Content-Length': buf.length } : {}) } }, res => {
433
+ const chunks = []
434
+ res.on('data', d => chunks.push(d))
435
+ res.on('end', () => { let json = null; try { json = JSON.parse(Buffer.concat(chunks).toString()) } catch { }; resolve({ status: res.statusCode, ok: res.statusCode >= 200 && res.statusCode < 300, json: async () => json }) })
436
+ res.on('error', reject)
437
+ })
438
+ req.on('error', reject)
439
+ if (signal) {
440
+ if (signal.aborted) { req.destroy(); return reject(new Error('Upload aborted')) }
441
+ const onAbort = () => { req.destroy(); reject(new Error('Upload aborted')) }
442
+ signal.addEventListener('abort', onAbort, { once: true })
443
+ req.on('close', () => signal.removeEventListener('abort', onAbort))
444
+ }
445
+ buf ? req.end(buf) : body.pipe(req)
446
+ })
447
+
448
+ export const getWAUploadToServer = ({ customUploadHosts, logger, options }, refreshMediaConn) => async (stream, { mediaType, fileEncSha256B64, newsletter, timeoutMs }) => {
449
+ const toUploadBody = async input => {
450
+ if (!input) throw new Boom('Upload input is null or undefined', { statusCode: 400 })
451
+ if (Buffer.isBuffer(input)) return input
452
+ if (typeof input === 'string') return fs.readFile(input)
453
+ if (typeof ReadableStream !== 'undefined' && input instanceof ReadableStream) return Readable.fromWeb(input)
454
+ if (typeof input.pipe === 'function' || typeof input[Symbol.asyncIterator] === 'function') return input
455
+ throw new Boom(`Unsupported upload input type: ${Object.prototype.toString.call(input)}`, { statusCode: 400 })
456
+ }
457
+ let reqBody
458
+ try { reqBody = await toUploadBody(stream) } catch (err) { logger?.error({ err: err.message }, 'failed to prepare upload body'); throw err }
459
+ fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
460
+ let media = MEDIA_PATH_MAP[mediaType]
461
+ if (newsletter) media = media?.replace('/mms/', '/newsletter/newsletter-')
462
+ if (!media) throw new Boom(`No media path found for type: ${mediaType}`, { statusCode: 400 })
463
+ let uploadInfo = await refreshMediaConn(false)
464
+ const hosts = [...(customUploadHosts ?? []), ...(uploadInfo.hosts ?? [])]
465
+ if (!hosts.length) throw new Boom('No upload hosts available', { statusCode: 503 })
466
+ let urls, lastError
467
+ for (const { hostname, maxContentLengthBytes } of hosts) {
468
+ for (let attempt = 1; attempt <= 2; attempt++) {
469
+ try {
470
+ if (attempt > 1) { uploadInfo = await refreshMediaConn(true); reqBody = await toUploadBody(stream) }
471
+ if (maxContentLengthBytes && Buffer.isBuffer(reqBody) && reqBody.length > maxContentLengthBytes) break
472
+ const auth = encodeURIComponent(uploadInfo.auth)
473
+ const url = `https://${hostname}${media}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
474
+ const controller = new AbortController()
475
+ const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null
476
+ let response
477
+ try { response = await httpsPost(url, reqBody, { ...(Array.isArray(options?.headers) ? Object.fromEntries(options.headers) : (options?.headers ?? {})), 'Content-Type': 'application/octet-stream', Origin: DEFAULT_ORIGIN }, controller.signal) }
478
+ finally { if (timer) clearTimeout(timer) }
479
+ const result = await response.json().catch(() => null)
480
+ if (result?.url || result?.directPath) { urls = { mediaUrl: result.url, directPath: result.direct_path, handle: result.handle }; break }
481
+ lastError = new Error(`${hostname} rejected upload (HTTP ${response.status}): ${JSON.stringify(result)}`)
482
+ } catch (err) { lastError = err; if (attempt < 2) await new Promise(r => setTimeout(r, 500 * attempt)) }
483
+ }
484
+ if (urls) break
485
+ }
486
+ if (!urls) throw new Boom(`Media upload failed on all hosts. Last error: ${lastError?.message ?? 'unknown'}`, { statusCode: 500, data: { lastError: lastError?.message } })
487
+ return urls
488
+ }
489
+
490
+ // ─── MEDIA RETRY ──────────────────────────────────────────────────────────────
491
+
492
+ const getMediaRetryKey = mediaKey => hkdf(mediaKey, 32, { info: 'WhatsApp Media Retry Notification' })
493
+
494
+ export const encryptMediaRetryRequest = async (key, mediaKey, meId) => {
495
+ const iv = Crypto.randomBytes(12)
496
+ const ciphertext = aesEncryptGCM(proto.ServerErrorReceipt.encode({ stanzaId: key.id }).finish(), await getMediaRetryKey(mediaKey), iv, Buffer.from(key.id))
497
+ return { tag: 'receipt', attrs: { id: key.id, to: jidNormalizedUser(meId), type: 'server-error' }, content: [{ tag: 'encrypt', attrs: {}, content: [{ tag: 'enc_p', attrs: {}, content: ciphertext }, { tag: 'enc_iv', attrs: {}, content: iv }] }, { tag: 'rmr', attrs: { jid: key.remoteJid, from_me: (!!key.fromMe).toString(), participant: key.participant } }] }
498
+ }
499
+
500
+ export const decodeMediaRetryNode = node => {
501
+ const rmrNode = getBinaryNodeChild(node, 'rmr')
502
+ const event = { key: { id: node.attrs.id, remoteJid: rmrNode.attrs.jid, fromMe: rmrNode.attrs.from_me === 'true', participant: rmrNode.attrs.participant } }
503
+ const errorNode = getBinaryNodeChild(node, 'error')
504
+ if (errorNode) { event.error = new Boom(`Failed to re-upload media (${+errorNode.attrs.code})`, { data: errorNode.attrs, statusCode: getStatusCodeForMediaRetry(+errorNode.attrs.code) }); return event }
505
+ const encNode = getBinaryNodeChild(node, 'encrypt')
506
+ const ciphertext = getBinaryNodeChildBuffer(encNode, 'enc_p')
507
+ const iv = getBinaryNodeChildBuffer(encNode, 'enc_iv')
508
+ event[ciphertext && iv ? 'media' : 'error'] = ciphertext && iv ? { ciphertext, iv } : new Boom('Failed to re-upload media (missing ciphertext)', { statusCode: 404 })
509
+ return event
510
+ }
511
+
512
+ export const decryptMediaRetryData = async ({ ciphertext, iv }, mediaKey, msgId) => proto.MediaRetryNotification.decode(aesDecryptGCM(ciphertext, await getMediaRetryKey(mediaKey), iv, Buffer.from(msgId)))
513
+
514
+ export const getStatusCodeForMediaRetry = code => MEDIA_RETRY_STATUS_MAP[code]
515
+ const MEDIA_RETRY_STATUS_MAP = {
516
+ [proto.MediaRetryNotification.ResultType.SUCCESS]: 200,
517
+ [proto.MediaRetryNotification.ResultType.DECRYPTION_ERROR]: 412,
518
+ [proto.MediaRetryNotification.ResultType.NOT_FOUND]: 404,
519
+ [proto.MediaRetryNotification.ResultType.GENERAL_ERROR]: 418
520
+ }