@nexustechpro/baileys 2.0.5 → 2.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,437 +1,494 @@
1
- import { Boom } from '@hapi/boom';
2
- import { exec } from 'child_process';
3
- import * as Crypto from 'crypto';
4
- import { once } from 'events';
5
- import { createReadStream, createWriteStream, promises as fs } from 'fs';
6
- import { tmpdir } from 'os';
7
- import { join } from 'path';
8
- import { Readable, Transform } from 'stream';
9
- import { URL } from 'url';
10
- import { proto } from '../../WAProto/index.js';
11
- import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP } from '../Defaults/index.js';
12
- import { getBinaryNodeChild, getBinaryNodeChildBuffer, jidNormalizedUser } from '../WABinary/index.js';
13
- import { aesDecryptGCM, aesEncryptGCM, hkdf } from './crypto.js';
14
- import { generateMessageIDV2 } from './generics.js';
15
-
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, promises as fs } from 'fs'
6
+ import { tmpdir } from 'os'
7
+ import { join } from 'path'
8
+ import { Readable, Transform } from 'stream'
9
+ import { URL } from 'url'
10
+ import { proto } from '../../WAProto/index.js'
11
+ import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP } from '../Defaults/index.js'
12
+ import { getBinaryNodeChild, getBinaryNodeChildBuffer, jidNormalizedUser } from '../WABinary/index.js'
13
+ import { aesDecryptGCM, aesEncryptGCM, hkdf } from './crypto.js'
14
+ import { generateMessageIDV2 } from './generics.js'
15
+
16
+ // ─── IMAGE PROCESSING ─────────────────────────────────────────────────────────
16
17
  export const getImageProcessingLibrary = async () => {
17
18
  const [jimp, sharp] = await Promise.all([
18
19
  import('jimp').catch(() => null),
19
20
  import('sharp').catch(() => null)
20
- ]);
21
- if (sharp) return { sharp };
22
- if (jimp) return { jimp };
23
- throw new Boom('No image processing library available');
24
- };
21
+ ])
22
+ if (sharp) return { sharp }
23
+ if (jimp) return { jimp }
24
+ throw new Boom('No image processing library available')
25
+ }
26
+
27
+ // ─── FFMPEG ───────────────────────────────────────────────────────────────────
28
+ let ffmpegPathResolved = null
29
+ const getFfmpegPath = async () => {
30
+ if (ffmpegPathResolved) return ffmpegPathResolved
31
+ try {
32
+ const { default: staticPath } = await import('ffmpeg-static')
33
+ if (staticPath) { ffmpegPathResolved = staticPath; return staticPath }
34
+ } catch { }
35
+ ffmpegPathResolved = 'ffmpeg'
36
+ return 'ffmpeg'
37
+ }
38
+
39
+ // ─── HKDF ─────────────────────────────────────────────────────────────────────
40
+ export const hkdfInfoKey = (type) => `WhatsApp ${MEDIA_HKDF_KEY_MAPPING[type]} Keys`
25
41
 
26
- export const hkdfInfoKey = (type) => `WhatsApp ${MEDIA_HKDF_KEY_MAPPING[type]} Keys`;
42
+ export const getMediaKeys = async (buffer, mediaType) => {
43
+ if (!buffer) throw new Boom('Cannot derive from empty media key')
44
+ if (typeof buffer === 'string') buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64')
45
+ const expandedMediaKey = await hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) })
46
+ return {
47
+ iv: expandedMediaKey.slice(0, 16),
48
+ cipherKey: expandedMediaKey.slice(16, 48),
49
+ macKey: expandedMediaKey.slice(48, 80)
50
+ }
51
+ }
27
52
 
53
+ // ─── RAW UPLOAD ───────────────────────────────────────────────────────────────
28
54
  export const getRawMediaUploadData = async (media, mediaType, logger) => {
29
- const { stream } = await getStream(media);
30
- const hasher = Crypto.createHash('sha256');
31
- const filePath = join(tmpdir(), mediaType + generateMessageIDV2());
32
- const fileWriteStream = createWriteStream(filePath);
33
- let fileLength = 0;
55
+ const { stream } = await getStream(media)
56
+ const hasher = Crypto.createHash('sha256')
57
+ const filePath = join(tmpdir(), mediaType + generateMessageIDV2())
58
+ const fileWriteStream = createWriteStream(filePath)
59
+ let fileLength = 0
34
60
  try {
35
61
  for await (const data of stream) {
36
- fileLength += data.length;
37
- hasher.update(data);
38
- if (!fileWriteStream.write(data)) await once(fileWriteStream, 'drain');
62
+ fileLength += data.length
63
+ hasher.update(data)
64
+ if (!fileWriteStream.write(data)) await once(fileWriteStream, 'drain')
39
65
  }
40
- fileWriteStream.end();
41
- await once(fileWriteStream, 'finish');
42
- stream.destroy();
43
- logger?.debug('hashed data for raw upload');
44
- return { filePath, fileSha256: hasher.digest(), fileLength };
66
+ fileWriteStream.end()
67
+ await once(fileWriteStream, 'finish')
68
+ stream.destroy()
69
+ logger?.debug('hashed data for raw upload')
70
+ return { filePath, fileSha256: hasher.digest(), fileLength }
45
71
  } catch (error) {
46
- fileWriteStream.destroy();
47
- stream.destroy();
48
- try { await fs.unlink(filePath); } catch { }
49
- throw error;
72
+ fileWriteStream.destroy()
73
+ stream.destroy()
74
+ try { await fs.unlink(filePath) } catch { }
75
+ throw error
50
76
  }
51
- };
52
-
53
- export async function getMediaKeys(buffer, mediaType) {
54
- if (!buffer) throw new Boom('Cannot derive from empty media key');
55
- if (typeof buffer === 'string') buffer = Buffer.from(buffer.replace('data:;base64,', ''), 'base64');
56
- const expandedMediaKey = hkdf(buffer, 112, { info: hkdfInfoKey(mediaType) });
57
- return {
58
- iv: expandedMediaKey.slice(0, 16),
59
- cipherKey: expandedMediaKey.slice(16, 48),
60
- macKey: expandedMediaKey.slice(48, 80)
61
- };
62
77
  }
63
78
 
64
- const extractVideoThumb = (path, destPath, time, size) => new Promise((resolve, reject) => {
65
- exec(`ffmpeg -ss ${time} -i ${path} -y -vf scale=${size.width}:-1 -vframes 1 -f image2 ${destPath}`, err => err ? reject(err) : resolve());
66
- });
79
+ // ─── THUMBNAILS ───────────────────────────────────────────────────────────────
80
+ const extractVideoThumb = async (path, destPath, time, size) => {
81
+ const ffmpegPath = await getFfmpegPath()
82
+ return new Promise((resolve, reject) => {
83
+ const ff = spawn(ffmpegPath, ['-ss', time, '-i', path, '-y', '-vf', `scale=${size.width}:-1`, '-vframes', '1', '-f', 'image2', destPath])
84
+ ff.on('close', code => code === 0 ? resolve() : reject(new Error(`FFmpeg thumb exited with code ${code}`)))
85
+ ff.on('error', reject)
86
+ })
87
+ }
67
88
 
68
89
  export const extractImageThumb = async (bufferOrFilePath, width = 32) => {
69
- if (bufferOrFilePath instanceof Readable) bufferOrFilePath = await toBuffer(bufferOrFilePath);
70
- const lib = await getImageProcessingLibrary();
90
+ if (bufferOrFilePath instanceof Readable) bufferOrFilePath = await toBuffer(bufferOrFilePath)
91
+ const lib = await getImageProcessingLibrary()
71
92
  if ('sharp' in lib && typeof lib.sharp?.default === 'function') {
72
- const img = lib.sharp.default(bufferOrFilePath);
73
- const dimensions = await img.metadata();
74
- const buffer = await img.resize(width).jpeg({ quality: 95 }).toBuffer();
75
- return { buffer, original: { width: dimensions.width, height: dimensions.height } };
76
- } else if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
77
- const jimp = await lib.jimp.Jimp.read(bufferOrFilePath);
78
- const buffer = await jimp.resize({ w: width, mode: lib.jimp.ResizeStrategy.BILINEAR }).getBuffer('image/jpeg', { quality: 95 });
79
- return { buffer, original: { width: jimp.width, height: jimp.height } };
93
+ const img = lib.sharp.default(bufferOrFilePath)
94
+ const dimensions = await img.metadata()
95
+ const buffer = await img.resize(width).jpeg({ quality: 95 }).toBuffer()
96
+ return { buffer, original: { width: dimensions.width, height: dimensions.height } }
97
+ }
98
+ if ('jimp' in lib && typeof lib.jimp?.Jimp === 'object') {
99
+ const jimp = await lib.jimp.Jimp.read(bufferOrFilePath)
100
+ const buffer = await jimp.resize({ w: width, mode: lib.jimp.ResizeStrategy.BILINEAR }).getBuffer('image/jpeg', { quality: 95 })
101
+ return { buffer, original: { width: jimp.width, height: jimp.height } }
102
+ }
103
+ throw new Boom('No image processing library available')
104
+ }
105
+
106
+ export async function generateThumbnail(file, mediaType, options) {
107
+ let thumbnail, originalImageDimensions
108
+ if (mediaType === 'image') {
109
+ const { buffer, original } = await extractImageThumb(file)
110
+ thumbnail = buffer.toString('base64')
111
+ if (original.width && original.height) originalImageDimensions = original
112
+ } else if (mediaType === 'video') {
113
+ const imgFilename = join(tmpdir(), generateMessageIDV2() + '.jpg')
114
+ try {
115
+ await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 })
116
+ thumbnail = (await fs.readFile(imgFilename)).toString('base64')
117
+ await fs.unlink(imgFilename)
118
+ } catch (err) {
119
+ options.logger?.debug('could not generate video thumb: ' + err)
120
+ }
80
121
  }
81
- throw new Boom('No image processing library available');
82
- };
122
+ return { thumbnail, originalImageDimensions }
123
+ }
83
124
 
84
- export const encodeBase64EncodedStringForUpload = (b64) => encodeURIComponent(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''));
125
+ // ─── PROFILE PICTURE ──────────────────────────────────────────────────────────
126
+ export const encodeBase64EncodedStringForUpload = (b64) => encodeURIComponent(b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''))
85
127
 
86
128
  export const generateProfilePicture = async (mediaUpload) => {
87
- let bufferOrFilePath = Buffer.isBuffer(mediaUpload) ? mediaUpload : 'url' in mediaUpload ? mediaUpload.url.toString() : await toBuffer(mediaUpload.stream);
88
- const lib = await getImageProcessingLibrary();
129
+ const bufferOrFilePath = Buffer.isBuffer(mediaUpload) ? mediaUpload : 'url' in mediaUpload ? mediaUpload.url.toString() : await toBuffer(mediaUpload.stream)
130
+ const lib = await getImageProcessingLibrary()
89
131
  if ('sharp' in lib && typeof lib.sharp?.default === 'function') {
90
- const img = await lib.sharp.default(bufferOrFilePath).resize(720, 720, { fit: 'inside' }).jpeg({ quality: 50 }).toBuffer();
91
- return { img };
92
- } else if ('jimp' in lib && typeof lib.jimp?.read === 'function') {
93
- const { read, MIME_JPEG } = lib.jimp;
94
- const image = await read(bufferOrFilePath);
95
- const min = image.getWidth(), max = image.getHeight();
96
- const img = await image.crop(0, 0, min, max).scaleToFit(720, 720).getBufferAsync(MIME_JPEG);
97
- return { img };
132
+ const img = await lib.sharp.default(bufferOrFilePath).resize(720, 720, { fit: 'inside' }).jpeg({ quality: 50 }).toBuffer()
133
+ return { img }
134
+ }
135
+ if ('jimp' in lib && typeof lib.jimp?.read === 'function') {
136
+ const { read, MIME_JPEG } = lib.jimp
137
+ const image = await read(bufferOrFilePath)
138
+ const img = await image.crop(0, 0, image.getWidth(), image.getHeight()).scaleToFit(720, 720).getBufferAsync(MIME_JPEG)
139
+ return { img }
98
140
  }
99
- throw new Boom('No image processing library available');
100
- };
141
+ throw new Boom('No image processing library available')
142
+ }
101
143
 
144
+ // ─── AUDIO ────────────────────────────────────────────────────────────────────
102
145
  export const mediaMessageSHA256B64 = (message) => {
103
- const media = Object.values(message)[0];
104
- return media?.fileSha256 && Buffer.from(media.fileSha256).toString('base64');
105
- };
146
+ const media = Object.values(message)[0]
147
+ return media?.fileSha256 && Buffer.from(media.fileSha256).toString('base64')
148
+ }
106
149
 
107
150
  export async function getAudioDuration(buffer) {
108
- const musicMetadata = await import('music-metadata');
109
- if (Buffer.isBuffer(buffer)) return (await musicMetadata.parseBuffer(buffer, undefined, { duration: true })).format.duration;
110
- if (typeof buffer === 'string') return (await musicMetadata.parseFile(buffer, { duration: true })).format.duration;
111
- return (await musicMetadata.parseStream(buffer, undefined, { duration: true })).format.duration;
151
+ const musicMetadata = await import('music-metadata')
152
+ if (Buffer.isBuffer(buffer)) return (await musicMetadata.parseBuffer(buffer, undefined, { duration: true })).format.duration
153
+ if (typeof buffer === 'string') return (await musicMetadata.parseFile(buffer, { duration: true })).format.duration
154
+ return (await musicMetadata.parseStream(buffer, undefined, { duration: true })).format.duration
112
155
  }
113
156
 
114
157
  export async function getAudioWaveform(buffer, logger) {
158
+ const bars = 64
159
+ 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])
115
160
  try {
116
- const { default: decoder } = await import('audio-decode');
117
- let audioData = Buffer.isBuffer(buffer) ? buffer : typeof buffer === 'string' ? await toBuffer(createReadStream(buffer)) : await toBuffer(buffer);
118
- const audioBuffer = await decoder(audioData);
119
- const rawData = audioBuffer.getChannelData(0);
120
- const samples = 64, blockSize = Math.floor(rawData.length / samples);
121
- const filteredData = [];
122
- for (let i = 0; i < samples; i++) {
123
- let sum = 0;
124
- for (let j = 0; j < blockSize; j++) sum += Math.abs(rawData[i * blockSize + j]);
125
- filteredData.push(sum / blockSize);
161
+ // prefer fluent-ffmpeg for broad format support (mp3, m4a, ogg, opus, wav, etc.)
162
+ // falls back to audio-decode for lightweight envs without ffmpeg
163
+ let rawPCM = null
164
+ try {
165
+ const ffmpegModule = await import('fluent-ffmpeg')
166
+ const ff = ffmpegModule.default || ffmpegModule
167
+ const ffmpegPath = await getFfmpegPath()
168
+ let input
169
+ if (Buffer.isBuffer(buffer) || typeof buffer === 'string') {
170
+ input = buffer
171
+ } else {
172
+ input = await toBuffer(buffer)
173
+ }
174
+ rawPCM = await new Promise((resolve, reject) => {
175
+ const chunks = []
176
+ ff(input)
177
+ .setFfmpegPath(ffmpegPath)
178
+ .audioChannels(1)
179
+ .audioFrequency(16000)
180
+ .format('s16le')
181
+ .on('error', reject)
182
+ .on('end', () => resolve(Buffer.concat(chunks)))
183
+ .pipe()
184
+ .on('data', chunk => chunks.push(chunk))
185
+ })
186
+ if (!rawPCM?.length) throw new Error('empty PCM output')
187
+ const samples = Math.floor(rawPCM.length / 2)
188
+ const amplitudes = new Array(samples)
189
+ for (let i = 0; i < samples; i++) amplitudes[i] = Math.abs(rawPCM.readInt16LE(i * 2)) / 32768
190
+ const blockSize = Math.max(1, Math.floor(amplitudes.length / bars))
191
+ const avg = Array.from({ length: bars }, (_, i) => {
192
+ const start = i * blockSize
193
+ const end = i === bars - 1 ? amplitudes.length : Math.min(start + blockSize, amplitudes.length)
194
+ const block = amplitudes.slice(start, end)
195
+ return block.length ? block.reduce((a, b) => a + b, 0) / block.length : 0
196
+ })
197
+ const max = Math.max(...avg, 0.0001)
198
+ return new Uint8Array(avg.map(v => Math.max(0, Math.min(100, Math.round((v / max) * 100)))))
199
+ } catch {
200
+ // fluent-ffmpeg unavailable or failed — try audio-decode
201
+ const { default: decoder } = await import('audio-decode')
202
+ let audioData = Buffer.isBuffer(buffer) ? buffer : typeof buffer === 'string' ? await toBuffer(createReadStream(buffer)) : await toBuffer(buffer)
203
+ const audioBuffer = await decoder(audioData)
204
+ const rawData = audioBuffer.getChannelData(0)
205
+ const blockSize = Math.floor(rawData.length / bars)
206
+ const filteredData = Array.from({ length: bars }, (_, i) => {
207
+ let sum = 0
208
+ for (let j = 0; j < blockSize; j++) sum += Math.abs(rawData[i * blockSize + j])
209
+ return sum / blockSize
210
+ })
211
+ const multiplier = Math.pow(Math.max(...filteredData), -1)
212
+ return new Uint8Array(filteredData.map(n => Math.floor(100 * n * multiplier)))
126
213
  }
127
- const multiplier = Math.pow(Math.max(...filteredData), -1);
128
- return new Uint8Array(filteredData.map(n => Math.floor(100 * n * multiplier)));
129
214
  } catch (e) {
130
- logger?.debug('Failed to generate waveform: ' + e);
131
- return 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]);
215
+ logger?.debug({ trace: e?.stack || e }, 'failed to generate waveform, using fallback')
216
+ return fallback
217
+ }
218
+ }
219
+
220
+ // ─── FFMPEG CONVERTERS ────────────────────────────────────────────────────────
221
+ const convertToOpusBuffer = async (buffer, logger) => {
222
+ const ffmpegPath = await getFfmpegPath()
223
+ const inputPath = join(tmpdir(), 'opus-in-' + generateMessageIDV2())
224
+ await fs.writeFile(inputPath, buffer)
225
+ try {
226
+ return await new Promise((resolve, reject) => {
227
+ const ff = spawn(ffmpegPath, ['-y', '-i', inputPath, '-c:a', 'libopus', '-b:a', '64k', '-vbr', 'on', '-compression_level', '10', '-frame_duration', '20', '-application', 'voip', '-f', 'ogg', 'pipe:1'], { stdio: ['ignore', 'pipe', 'pipe'] })
228
+ const chunks = []
229
+ ff.stdout.on('data', chunk => chunks.push(chunk))
230
+ ff.stderr.on('data', () => { })
231
+ ff.on('close', code => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`FFmpeg Opus exited with code ${code}`)))
232
+ ff.on('error', reject)
233
+ })
234
+ } finally {
235
+ try { await fs.unlink(inputPath) } catch { }
132
236
  }
133
237
  }
134
238
 
135
- const convertToOpusBuffer = (buffer, logger) => new Promise((resolve, reject) => {
136
- const ffmpeg = exec('ffmpeg -i pipe:0 -c:a libopus -b:a 64k -vbr on -compression_level 10 -frame_duration 20 -application voip -f ogg pipe:1');
137
- const chunks = [];
138
- ffmpeg.stdin.write(buffer);
139
- ffmpeg.stdin.end();
140
- ffmpeg.stdout.on('data', chunk => chunks.push(chunk));
141
- ffmpeg.stderr.on('data', () => { });
142
- ffmpeg.on('close', code => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`FFmpeg Opus conversion exited with code ${code}`)));
143
- ffmpeg.on('error', reject);
144
- });
145
-
146
- const convertToMp4Buffer = (buffer, logger) => new Promise((resolve, reject) => {
147
- const ffmpeg = exec('ffmpeg -i pipe:0 -c:v libx264 -preset veryfast -crf 23 -c:a aac -b:a 128k -movflags faststart -f mp4 pipe:1');
148
- const chunks = [];
149
- ffmpeg.stdin.write(buffer);
150
- ffmpeg.stdin.end();
151
- ffmpeg.stdout.on('data', chunk => chunks.push(chunk));
152
- ffmpeg.stderr.on('data', () => { });
153
- ffmpeg.on('close', code => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`FFmpeg MP4 conversion exited with code ${code}`)));
154
- ffmpeg.on('error', reject);
155
- });
239
+ const convertToMp4Buffer = async (buffer, logger) => {
240
+ const ffmpegPath = await getFfmpegPath()
241
+ return new Promise((resolve, reject) => {
242
+ const ff = spawn(ffmpegPath, ['-i', 'pipe:0', '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '23', '-c:a', 'aac', '-b:a', '128k', '-movflags', 'faststart', '-f', 'mp4', 'pipe:1'], { stdio: ['pipe', 'pipe', 'pipe'] })
243
+ const chunks = []
244
+ ff.stdin.write(buffer)
245
+ ff.stdin.end()
246
+ ff.stdout.on('data', chunk => chunks.push(chunk))
247
+ ff.stderr.on('data', () => { })
248
+ ff.on('close', code => code === 0 ? resolve(Buffer.concat(chunks)) : reject(new Error(`FFmpeg MP4 exited with code ${code}`)))
249
+ ff.on('error', reject)
250
+ })
251
+ }
156
252
 
253
+ // ─── STREAM UTILS ─────────────────────────────────────────────────────────────
157
254
  export const toReadable = (buffer) => {
158
- const readable = new Readable({ read: () => { } });
159
- readable.push(buffer);
160
- readable.push(null);
161
- return readable;
162
- };
255
+ const readable = new Readable({ read: () => { } })
256
+ readable.push(buffer)
257
+ readable.push(null)
258
+ return readable
259
+ }
163
260
 
164
261
  export const toBuffer = async (stream) => {
165
- const chunks = [];
166
- for await (const chunk of stream) chunks.push(chunk);
167
- stream.destroy();
168
- return Buffer.concat(chunks);
169
- };
262
+ const chunks = []
263
+ for await (const chunk of stream) chunks.push(chunk)
264
+ stream.destroy()
265
+ return Buffer.concat(chunks)
266
+ }
170
267
 
171
268
  export const getStream = async (item, opts) => {
172
- if (!item) throw new Boom('Item is required for getStream', { statusCode: 400 });
173
- if (Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' };
174
- if (item?.stream?.pipe) return { stream: item.stream, type: 'readable' };
175
- if (item?.pipe) return { stream: item, type: 'readable' };
269
+ if (!item) throw new Boom('Item is required for getStream', { statusCode: 400 })
270
+ if (Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' }
271
+ if (item?.stream?.pipe) return { stream: item.stream, type: 'readable' }
272
+ if (item?.pipe) return { stream: item, type: 'readable' }
176
273
  if (item && typeof item === 'object' && 'url' in item) {
177
- const urlStr = item.url.toString();
178
- if (Buffer.isBuffer(item.url)) return { stream: toReadable(item.url), type: 'buffer' };
179
- if (urlStr.startsWith('data:')) return { stream: toReadable(Buffer.from(urlStr.split(',')[1], 'base64')), type: 'buffer' };
180
- if (urlStr.startsWith('http')) return { stream: await getHttpStream(item.url, opts), type: 'remote' };
181
- return { stream: createReadStream(item.url), type: 'file' };
274
+ const urlStr = item.url.toString()
275
+ if (Buffer.isBuffer(item.url)) return { stream: toReadable(item.url), type: 'buffer' }
276
+ if (urlStr.startsWith('data:')) return { stream: toReadable(Buffer.from(urlStr.split(',')[1], 'base64')), type: 'buffer' }
277
+ if (urlStr.startsWith('http')) return { stream: await getHttpStream(item.url, opts), type: 'remote' }
278
+ return { stream: createReadStream(item.url), type: 'file' }
182
279
  }
183
280
  if (typeof item === 'string') {
184
- if (item.startsWith('data:')) return { stream: toReadable(Buffer.from(item.split(',')[1], 'base64')), type: 'buffer' };
185
- if (item.startsWith('http')) return { stream: await getHttpStream(item, opts), type: 'remote' };
186
- return { stream: createReadStream(item), type: 'file' };
281
+ if (item.startsWith('data:')) return { stream: toReadable(Buffer.from(item.split(',')[1], 'base64')), type: 'buffer' }
282
+ if (item.startsWith('http')) return { stream: await getHttpStream(item, opts), type: 'remote' }
283
+ return { stream: createReadStream(item), type: 'file' }
187
284
  }
188
- throw new Boom(`Invalid input type for getStream: ${typeof item}`, { statusCode: 400 });
189
- };
190
-
191
- export async function generateThumbnail(file, mediaType, options) {
192
- let thumbnail, originalImageDimensions;
193
- if (mediaType === 'image') {
194
- const { buffer, original } = await extractImageThumb(file);
195
- thumbnail = buffer.toString('base64');
196
- if (original.width && original.height) originalImageDimensions = original;
197
- } else if (mediaType === 'video') {
198
- const imgFilename = join(tmpdir(), generateMessageIDV2() + '.jpg');
199
- try {
200
- await extractVideoThumb(file, imgFilename, '00:00:00', { width: 32, height: 32 });
201
- thumbnail = (await fs.readFile(imgFilename)).toString('base64');
202
- await fs.unlink(imgFilename);
203
- } catch (err) {
204
- options.logger?.debug('could not generate video thumb: ' + err);
205
- }
206
- }
207
- return { thumbnail, originalImageDimensions };
285
+ throw new Boom(`Invalid input type for getStream: ${typeof item}`, { statusCode: 400 })
208
286
  }
209
287
 
210
288
  export const getHttpStream = async (url, options = {}) => {
211
- const response = await fetch(url.toString(), { dispatcher: options.dispatcher, method: 'GET', headers: options.headers });
212
- if (!response.ok) throw new Boom(`Failed to fetch stream from ${url}`, { statusCode: response.status, data: { url } });
213
- const body = response.body;
214
- if (body && typeof body === 'object' && 'pipeTo' in body && typeof body.pipeTo === 'function') return Readable.fromWeb(body);
215
- if (body && typeof body.pipe === 'function' && typeof body.read === 'function') return body;
216
- throw new Error('Response body is not a readable stream');
217
- };
289
+ const response = await fetch(url.toString(), { dispatcher: options.dispatcher, method: 'GET', headers: options.headers })
290
+ if (!response.ok) throw new Boom(`Failed to fetch stream from ${url}`, { statusCode: response.status, data: { url } })
291
+ const body = response.body
292
+ if (body && typeof body === 'object' && 'pipeTo' in body && typeof body.pipeTo === 'function') return Readable.fromWeb(body)
293
+ if (body && typeof body.pipe === 'function' && typeof body.read === 'function') return body
294
+ throw new Error('Response body is not a readable stream')
295
+ }
218
296
 
297
+ // ─── ENCRYPT / PREPARE STREAM ─────────────────────────────────────────────────
219
298
  export const prepareStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts, convertVideo } = {}) => {
220
- const { stream, type } = await getStream(media, opts);
221
- logger?.debug('fetched media stream');
222
- let buffer = await toBuffer(stream);
299
+ const { stream, type } = await getStream(media, opts)
300
+ logger?.debug('fetched media stream')
301
+ let buffer = await toBuffer(stream)
223
302
  if (mediaType === 'video' && convertVideo) {
224
- try { buffer = await convertToMp4Buffer(buffer, logger); logger?.debug('converted video to mp4 for newsletter'); }
225
- catch (e) { logger?.error('failed to convert video for newsletter:', e); }
303
+ try { buffer = await convertToMp4Buffer(buffer, logger); logger?.debug('converted video to mp4') }
304
+ catch (e) { logger?.error('failed to convert video:', e) }
226
305
  }
227
- let bodyPath, didSaveToTmpPath = false;
306
+ let bodyPath, didSaveToTmpPath = false
228
307
  try {
229
- if (type === 'file') bodyPath = media.url;
308
+ if (type === 'file') bodyPath = media.url
230
309
  else if (saveOriginalFileIfRequired) {
231
- bodyPath = join(tmpdir(), mediaType + generateMessageIDV2());
232
- await fs.writeFile(bodyPath, buffer);
233
- didSaveToTmpPath = true;
310
+ bodyPath = join(tmpdir(), mediaType + generateMessageIDV2())
311
+ await fs.writeFile(bodyPath, buffer)
312
+ didSaveToTmpPath = true
234
313
  }
235
- return { mediaKey: undefined, encWriteStream: buffer, fileLength: buffer.length, fileSha256: Crypto.createHash('sha256').update(buffer).digest(), fileEncSha256: undefined, bodyPath, didSaveToTmpPath };
314
+ return { mediaKey: undefined, encWriteStream: buffer, fileLength: buffer.length, fileSha256: Crypto.createHash('sha256').update(buffer).digest(), fileEncSha256: undefined, bodyPath, didSaveToTmpPath }
236
315
  } catch (error) {
237
- if (didSaveToTmpPath && bodyPath) try { await fs.unlink(bodyPath); } catch { }
238
- throw error;
316
+ if (didSaveToTmpPath && bodyPath) try { await fs.unlink(bodyPath) } catch { }
317
+ throw error
239
318
  }
240
- };
319
+ }
241
320
 
242
321
  export const encryptedStream = async (media, mediaType, { logger, saveOriginalFileIfRequired, opts, mediaKey: providedMediaKey, isPtt, forceOpus, convertVideo } = {}) => {
243
- const { stream, type } = await getStream(media, opts);
244
- let finalStream = stream, opusConverted = false;
245
-
322
+ const { stream, type } = await getStream(media, opts)
323
+ let finalStream = stream, opusConverted = false
246
324
  if (mediaType === 'audio' && (isPtt === true || forceOpus === true)) {
247
325
  try {
248
- finalStream = toReadable(await convertToOpusBuffer(await toBuffer(stream), logger));
249
- opusConverted = true;
250
- logger?.debug('converted audio to Opus');
326
+ finalStream = toReadable(await convertToOpusBuffer(await toBuffer(stream), logger))
327
+ opusConverted = true
328
+ logger?.debug('converted audio to Opus')
251
329
  } catch (error) {
252
- logger?.error('failed to convert audio to Opus, using original');
253
- finalStream = (await getStream(media, opts)).stream;
330
+ logger?.error('failed to convert audio to Opus, using original')
331
+ finalStream = (await getStream(media, opts)).stream
254
332
  }
255
333
  }
256
-
257
334
  if (mediaType === 'video' && convertVideo === true) {
258
335
  try {
259
- finalStream = toReadable(await convertToMp4Buffer(await toBuffer(finalStream), logger));
260
- logger?.debug('converted video to mp4');
336
+ finalStream = toReadable(await convertToMp4Buffer(await toBuffer(finalStream), logger))
337
+ logger?.debug('converted video to mp4')
261
338
  } catch (error) {
262
- logger?.error('failed to convert video to mp4, using original');
263
- finalStream = (await getStream(media, opts)).stream;
339
+ logger?.error('failed to convert video to mp4, using original')
340
+ finalStream = (await getStream(media, opts)).stream
264
341
  }
265
342
  }
266
-
267
- const mediaKey = providedMediaKey || Crypto.randomBytes(32);
268
- const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType);
269
- const encFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-enc');
270
- const encFileWriteStream = createWriteStream(encFilePath);
271
- let originalFileStream, originalFilePath;
272
-
343
+ const mediaKey = providedMediaKey || Crypto.randomBytes(32)
344
+ const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType)
345
+ const encFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-enc')
346
+ const encFileWriteStream = createWriteStream(encFilePath)
347
+ let originalFileStream, originalFilePath
273
348
  if (saveOriginalFileIfRequired) {
274
- originalFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-original');
275
- originalFileStream = createWriteStream(originalFilePath);
349
+ originalFilePath = join(tmpdir(), mediaType + generateMessageIDV2() + '-original')
350
+ originalFileStream = createWriteStream(originalFilePath)
276
351
  }
277
-
278
- let fileLength = 0;
279
- const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv);
280
- const hmac = Crypto.createHmac('sha256', macKey).update(iv);
281
- const sha256Plain = Crypto.createHash('sha256');
282
- const sha256Enc = Crypto.createHash('sha256');
283
-
352
+ let fileLength = 0
353
+ const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
354
+ const hmac = Crypto.createHmac('sha256', macKey).update(iv)
355
+ const sha256Plain = Crypto.createHash('sha256')
356
+ const sha256Enc = Crypto.createHash('sha256')
284
357
  try {
285
358
  for await (const data of finalStream) {
286
- fileLength += data.length;
287
- if (type === 'remote' && opts?.maxContentLength && fileLength > opts.maxContentLength) throw new Boom('content length exceeded', { data: { media, type } });
288
- if (originalFileStream && !originalFileStream.write(data)) await once(originalFileStream, 'drain');
289
- sha256Plain.update(data);
290
- const encrypted = aes.update(data);
291
- sha256Enc.update(encrypted);
292
- hmac.update(encrypted);
293
- encFileWriteStream.write(encrypted);
359
+ fileLength += data.length
360
+ if (type === 'remote' && opts?.maxContentLength && fileLength > opts.maxContentLength) throw new Boom('content length exceeded', { data: { media, type } })
361
+ if (originalFileStream && !originalFileStream.write(data)) await once(originalFileStream, 'drain')
362
+ sha256Plain.update(data)
363
+ const encrypted = aes.update(data)
364
+ sha256Enc.update(encrypted)
365
+ hmac.update(encrypted)
366
+ encFileWriteStream.write(encrypted)
294
367
  }
295
- const finalData = aes.final();
296
- sha256Enc.update(finalData);
297
- hmac.update(finalData);
298
- encFileWriteStream.write(finalData);
299
- const mac = hmac.digest().slice(0, 10);
300
- sha256Enc.update(mac);
301
- encFileWriteStream.write(mac);
302
- encFileWriteStream.end();
303
- originalFileStream?.end?.();
304
- finalStream.destroy();
305
- logger?.debug('encrypted data successfully');
306
- return { mediaKey, bodyPath: originalFilePath, encFilePath, mac, fileEncSha256: sha256Enc.digest(), fileSha256: sha256Plain.digest(), fileLength, opusConverted };
368
+ const finalData = aes.final()
369
+ sha256Enc.update(finalData)
370
+ hmac.update(finalData)
371
+ encFileWriteStream.write(finalData)
372
+ const mac = hmac.digest().slice(0, 10)
373
+ sha256Enc.update(mac)
374
+ encFileWriteStream.write(mac)
375
+ encFileWriteStream.end()
376
+ originalFileStream?.end?.()
377
+ finalStream.destroy()
378
+ logger?.debug('encrypted data successfully')
379
+ return { mediaKey, bodyPath: originalFilePath, encFilePath, mac, fileEncSha256: sha256Enc.digest(), fileSha256: sha256Plain.digest(), fileLength, opusConverted }
307
380
  } catch (error) {
308
- encFileWriteStream.destroy();
309
- originalFileStream?.destroy?.();
310
- aes.destroy();
311
- hmac.destroy();
312
- sha256Plain.destroy();
313
- sha256Enc.destroy();
314
- finalStream.destroy();
315
- try { await fs.unlink(encFilePath); if (originalFilePath) await fs.unlink(originalFilePath); } catch (err) { logger?.error({ err }, 'failed deleting tmp files'); }
316
- throw error;
381
+ encFileWriteStream.destroy()
382
+ originalFileStream?.destroy?.()
383
+ aes.destroy()
384
+ hmac.destroy()
385
+ sha256Plain.destroy()
386
+ sha256Enc.destroy()
387
+ finalStream.destroy()
388
+ try { await fs.unlink(encFilePath); if (originalFilePath) await fs.unlink(originalFilePath) } catch (err) { logger?.error({ err }, 'failed deleting tmp files') }
389
+ throw error
317
390
  }
318
- };
391
+ }
319
392
 
320
- const DEF_HOST = 'mmg.whatsapp.net';
321
- const AES_CHUNK_SIZE = 16;
322
- const toSmallestChunkSize = (num) => Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE;
393
+ // ─── DOWNLOAD ─────────────────────────────────────────────────────────────────
394
+ const DEF_HOST = 'mmg.whatsapp.net'
395
+ const AES_CHUNK_SIZE = 16
396
+ const toSmallestChunkSize = (num) => Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE
323
397
 
324
- export const getUrlFromDirectPath = (directPath) => `https://${DEF_HOST}${directPath}`;
398
+ export const getUrlFromDirectPath = (directPath) => `https://${DEF_HOST}${directPath}`
325
399
 
326
400
  export const downloadContentFromMessage = async ({ mediaKey, directPath, url }, type, opts = {}) => {
327
- const isValidMediaUrl = url?.startsWith('https://mmg.whatsapp.net/');
328
- const downloadUrl = isValidMediaUrl ? url : getUrlFromDirectPath(directPath);
329
- if (!downloadUrl) throw new Boom('No valid media URL or directPath present', { statusCode: 400 });
330
- return downloadEncryptedContent(downloadUrl, await getMediaKeys(mediaKey, type), opts);
331
- };
401
+ const isValidMediaUrl = url?.startsWith('https://mmg.whatsapp.net/')
402
+ const downloadUrl = isValidMediaUrl ? url : getUrlFromDirectPath(directPath)
403
+ if (!downloadUrl) throw new Boom('No valid media URL or directPath present', { statusCode: 400 })
404
+ return downloadEncryptedContent(downloadUrl, await getMediaKeys(mediaKey, type), opts)
405
+ }
332
406
 
333
407
  export const downloadEncryptedContent = async (downloadUrl, { cipherKey, iv }, { startByte, endByte, options } = {}) => {
334
- let bytesFetched = 0, startChunk = 0, firstBlockIsIV = false;
408
+ let bytesFetched = 0, startChunk = 0, firstBlockIsIV = false
335
409
  if (startByte) {
336
- const chunk = toSmallestChunkSize(startByte || 0);
337
- if (chunk) { startChunk = chunk - AES_CHUNK_SIZE; bytesFetched = chunk; firstBlockIsIV = true; }
410
+ const chunk = toSmallestChunkSize(startByte || 0)
411
+ if (chunk) { startChunk = chunk - AES_CHUNK_SIZE; bytesFetched = chunk; firstBlockIsIV = true }
338
412
  }
339
- const endChunk = endByte ? toSmallestChunkSize(endByte || 0) + AES_CHUNK_SIZE : undefined;
340
- const headers = { ...(options?.headers ? (Array.isArray(options.headers) ? Object.fromEntries(options.headers) : options.headers) : {}), Origin: DEFAULT_ORIGIN };
341
- if (startChunk || endChunk) headers.Range = `bytes=${startChunk}-${endChunk || ''}`;
342
-
343
- const fetched = await getHttpStream(downloadUrl, { ...(options || {}), headers });
344
- let remainingBytes = Buffer.from([]), aes;
345
-
413
+ const endChunk = endByte ? toSmallestChunkSize(endByte || 0) + AES_CHUNK_SIZE : undefined
414
+ const headers = { ...(options?.headers ? (Array.isArray(options.headers) ? Object.fromEntries(options.headers) : options.headers) : {}), Origin: DEFAULT_ORIGIN }
415
+ if (startChunk || endChunk) headers.Range = `bytes=${startChunk}-${endChunk || ''}`
416
+ const fetched = await getHttpStream(downloadUrl, { ...(options || {}), headers })
417
+ let remainingBytes = Buffer.from([]), aes
346
418
  const pushBytes = (bytes, push) => {
347
419
  if (startByte || endByte) {
348
- const start = bytesFetched >= startByte ? undefined : Math.max(startByte - bytesFetched, 0);
349
- const end = bytesFetched + bytes.length < endByte ? undefined : Math.max(endByte - bytesFetched, 0);
350
- push(bytes.slice(start, end));
351
- bytesFetched += bytes.length;
420
+ const start = bytesFetched >= startByte ? undefined : Math.max(startByte - bytesFetched, 0)
421
+ const end = bytesFetched + bytes.length < endByte ? undefined : Math.max(endByte - bytesFetched, 0)
422
+ push(bytes.slice(start, end))
423
+ bytesFetched += bytes.length
352
424
  } else {
353
- push(bytes);
425
+ push(bytes)
354
426
  }
355
- };
356
-
427
+ }
357
428
  const output = new Transform({
358
429
  transform(chunk, _, callback) {
359
- let data = Buffer.concat([remainingBytes, chunk]);
360
- const decryptLength = toSmallestChunkSize(data.length);
361
- remainingBytes = data.slice(decryptLength);
362
- data = data.slice(0, decryptLength);
430
+ let data = Buffer.concat([remainingBytes, chunk])
431
+ const decryptLength = toSmallestChunkSize(data.length)
432
+ remainingBytes = data.slice(decryptLength)
433
+ data = data.slice(0, decryptLength)
363
434
  if (!aes) {
364
- let ivValue = iv;
365
- if (firstBlockIsIV) { ivValue = data.slice(0, AES_CHUNK_SIZE); data = data.slice(AES_CHUNK_SIZE); }
366
- aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue);
367
- if (endByte) aes.setAutoPadding(false);
435
+ let ivValue = iv
436
+ if (firstBlockIsIV) { ivValue = data.slice(0, AES_CHUNK_SIZE); data = data.slice(AES_CHUNK_SIZE) }
437
+ aes = Crypto.createDecipheriv('aes-256-cbc', cipherKey, ivValue)
438
+ if (endByte) aes.setAutoPadding(false)
368
439
  }
369
- try { pushBytes(aes.update(data), b => this.push(b)); callback(); } catch (error) { callback(error); }
440
+ try { pushBytes(aes.update(data), b => this.push(b)); callback() } catch (error) { callback(error) }
370
441
  },
371
442
  final(callback) {
372
- try { pushBytes(aes.final(), b => this.push(b)); callback(); } catch (error) { callback(error); }
443
+ try { pushBytes(aes.final(), b => this.push(b)); callback() } catch (error) { callback(error) }
373
444
  }
374
- });
375
- return fetched.pipe(output, { end: true });
376
- };
445
+ })
446
+ return fetched.pipe(output, { end: true })
447
+ }
377
448
 
449
+ // ─── UPLOAD ───────────────────────────────────────────────────────────────────
378
450
  export function extensionForMediaMessage(message) {
379
- const getExtension = (mimetype) => mimetype.split(';')[0]?.split('/')[1];
380
- const type = Object.keys(message)[0];
381
- if (type === 'locationMessage' || type === 'liveLocationMessage' || type === 'productMessage') return '.jpeg';
382
- return getExtension(message[type].mimetype);
451
+ const getExtension = (mimetype) => mimetype.split(';')[0]?.split('/')[1]
452
+ const type = Object.keys(message)[0]
453
+ if (type === 'locationMessage' || type === 'liveLocationMessage' || type === 'productMessage') return '.jpeg'
454
+ return getExtension(message[type].mimetype)
383
455
  }
384
456
 
385
457
  export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, options }, refreshMediaConn) => {
386
458
  return async (stream, { mediaType, fileEncSha256B64, newsletter, timeoutMs }) => {
387
- // Accepts Buffer, file path, Node stream, Web ReadableStream, or async iterable.
388
- // File paths are streamed directly from disk — no RAM cost for large files.
389
459
  const toUploadBody = async (input) => {
390
- if (!input) throw new Boom('Upload input is null or undefined', { statusCode: 400 });
391
- if (Buffer.isBuffer(input)) return input;
392
- if (typeof input === 'string') return createReadStream(input);
393
- if (typeof ReadableStream !== 'undefined' && input instanceof ReadableStream) return Readable.fromWeb(input);
394
- if (typeof input.pipe === 'function' || typeof input[Symbol.asyncIterator] === 'function') return input;
395
- throw new Boom(`Unsupported upload input type: ${Object.prototype.toString.call(input)}`, { statusCode: 400 });
396
- };
397
-
398
- let reqBody;
399
- try { reqBody = await toUploadBody(stream); }
400
- catch (err) { logger?.error({ err: err.message }, 'failed to prepare upload body'); throw err; }
401
-
402
- fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64);
403
-
404
- let media = MEDIA_PATH_MAP[mediaType];
405
- if (newsletter) media = media?.replace('/mms/', '/newsletter/newsletter-');
406
- if (!media) throw new Boom(`No media path found for type: ${mediaType}`, { statusCode: 400 });
407
-
408
- // Force-refresh auth upfront to avoid stale token failures
409
- let uploadInfo = await refreshMediaConn(true);
410
- const hosts = [...(customUploadHosts ?? []), ...(uploadInfo.hosts ?? [])];
411
- if (!hosts.length) throw new Boom('No upload hosts available', { statusCode: 503 });
412
-
413
- const MAX_RETRIES = 2;
414
- let urls, lastError;
415
-
460
+ if (!input) throw new Boom('Upload input is null or undefined', { statusCode: 400 })
461
+ if (Buffer.isBuffer(input)) return input
462
+ if (typeof input === 'string') return createReadStream(input)
463
+ if (typeof ReadableStream !== 'undefined' && input instanceof ReadableStream) return Readable.fromWeb(input)
464
+ if (typeof input.pipe === 'function' || typeof input[Symbol.asyncIterator] === 'function') return input
465
+ throw new Boom(`Unsupported upload input type: ${Object.prototype.toString.call(input)}`, { statusCode: 400 })
466
+ }
467
+ let reqBody
468
+ try { reqBody = await toUploadBody(stream) }
469
+ catch (err) { logger?.error({ err: err.message }, 'failed to prepare upload body'); throw err }
470
+ fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64)
471
+ let media = MEDIA_PATH_MAP[mediaType]
472
+ if (newsletter) media = media?.replace('/mms/', '/newsletter/newsletter-')
473
+ if (!media) throw new Boom(`No media path found for type: ${mediaType}`, { statusCode: 400 })
474
+ let uploadInfo = await refreshMediaConn(false)
475
+ const hosts = [...(customUploadHosts ?? []), ...(uploadInfo.hosts ?? [])]
476
+ if (!hosts.length) throw new Boom('No upload hosts available', { statusCode: 503 })
477
+ const MAX_RETRIES = 2
478
+ let urls, lastError
416
479
  for (const { hostname, maxContentLengthBytes } of hosts) {
417
480
  for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
418
481
  try {
419
- if (attempt > 1) {
420
- uploadInfo = await refreshMediaConn(true);
421
- reqBody = await toUploadBody(stream);
422
- }
423
-
482
+ if (attempt > 1) { uploadInfo = await refreshMediaConn(true); reqBody = await toUploadBody(stream) }
424
483
  if (maxContentLengthBytes && Buffer.isBuffer(reqBody) && reqBody.length > maxContentLengthBytes) {
425
- logger?.warn({ hostname, maxContentLengthBytes }, 'body too large for host, skipping');
426
- break;
484
+ logger?.warn({ hostname, maxContentLengthBytes }, 'body too large for host, skipping')
485
+ break
427
486
  }
428
-
429
- const auth = encodeURIComponent(uploadInfo.auth);
430
- const url = `https://${hostname}${media}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`;
431
- const controller = new AbortController();
432
- const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null;
433
-
434
- let response;
487
+ const auth = encodeURIComponent(uploadInfo.auth)
488
+ const url = `https://${hostname}${media}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
489
+ const controller = new AbortController()
490
+ const timer = timeoutMs ? setTimeout(() => controller.abort(), timeoutMs) : null
491
+ let response
435
492
  try {
436
493
  response = await fetch(url, {
437
494
  dispatcher: fetchAgent,
@@ -444,92 +501,79 @@ export const getWAUploadToServer = ({ customUploadHosts, fetchAgent, logger, opt
444
501
  },
445
502
  duplex: 'half',
446
503
  signal: controller.signal
447
- });
504
+ })
448
505
  } finally {
449
- if (timer) clearTimeout(timer);
506
+ if (timer) clearTimeout(timer)
450
507
  }
451
-
452
- let result;
453
- try { result = await response.json(); } catch { result = null; }
454
-
508
+ let result
509
+ try { result = await response.json() } catch { result = null }
455
510
  if (result?.url || result?.directPath) {
456
- urls = { mediaUrl: result.url, directPath: result.direct_path, handle: result.handle };
457
- break;
511
+ urls = { mediaUrl: result.url, directPath: result.direct_path, handle: result.handle }
512
+ break
458
513
  }
459
-
460
- lastError = new Error(`${hostname} rejected upload (HTTP ${response.status}): ${JSON.stringify(result)}`);
461
- logger?.warn({ hostname, attempt, status: response.status, result }, 'upload rejected');
462
-
514
+ lastError = new Error(`${hostname} rejected upload (HTTP ${response.status}): ${JSON.stringify(result)}`)
515
+ logger?.warn({ hostname, attempt, status: response.status, result }, 'upload rejected')
463
516
  } catch (err) {
464
- lastError = err;
465
- logger?.warn({ hostname, attempt, err: err.message, timedOut: err.name === 'AbortError' }, 'upload attempt failed');
466
- if (attempt < MAX_RETRIES) await new Promise(r => setTimeout(r, 500 * attempt));
517
+ lastError = err
518
+ logger?.warn({ hostname, attempt, err: err.message, timedOut: err.name === 'AbortError' }, 'upload attempt failed')
519
+ if (attempt < MAX_RETRIES) await new Promise(r => setTimeout(r, 500 * attempt))
467
520
  }
468
521
  }
469
- if (urls) break;
522
+ if (urls) break
470
523
  }
471
-
472
524
  if (!urls) {
473
- const msg = `Media upload failed on all hosts. Last error: ${lastError?.message ?? 'unknown'}`;
474
- logger?.error({ hosts: hosts.map(h => h.hostname), lastError: lastError?.message }, msg);
475
- throw new Boom(msg, { statusCode: 500, data: { lastError: lastError?.message } });
525
+ const msg = `Media upload failed on all hosts. Last error: ${lastError?.message ?? 'unknown'}`
526
+ logger?.error({ hosts: hosts.map(h => h.hostname), lastError: lastError?.message }, msg)
527
+ throw new Boom(msg, { statusCode: 500, data: { lastError: lastError?.message } })
476
528
  }
529
+ return urls
530
+ }
531
+ }
477
532
 
478
- return urls;
479
- };
480
- };
481
-
482
- const getMediaRetryKey = (mediaKey) => hkdf(mediaKey, 32, { info: 'WhatsApp Media Retry Notification' });
533
+ // ─── MEDIA RETRY ──────────────────────────────────────────────────────────────
534
+ const getMediaRetryKey = (mediaKey) => hkdf(mediaKey, 32, { info: 'WhatsApp Media Retry Notification' })
483
535
 
484
536
  export const encryptMediaRetryRequest = async (key, mediaKey, meId) => {
485
- const recp = { stanzaId: key.id };
486
- const recpBuffer = proto.ServerErrorReceipt.encode(recp).finish();
487
- const iv = Crypto.randomBytes(12);
488
- const retryKey = await getMediaRetryKey(mediaKey);
489
- const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id));
537
+ const recpBuffer = proto.ServerErrorReceipt.encode({ stanzaId: key.id }).finish()
538
+ const iv = Crypto.randomBytes(12)
539
+ const retryKey = await getMediaRetryKey(mediaKey)
540
+ const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id))
490
541
  return {
491
542
  tag: 'receipt',
492
543
  attrs: { id: key.id, to: jidNormalizedUser(meId), type: 'server-error' },
493
544
  content: [
494
- {
495
- tag: 'encrypt', attrs: {}, content: [
496
- { tag: 'enc_p', attrs: {}, content: ciphertext },
497
- { tag: 'enc_iv', attrs: {}, content: iv }
498
- ]
499
- },
545
+ { tag: 'encrypt', attrs: {}, content: [{ tag: 'enc_p', attrs: {}, content: ciphertext }, { tag: 'enc_iv', attrs: {}, content: iv }] },
500
546
  { tag: 'rmr', attrs: { jid: key.remoteJid, from_me: (!!key.fromMe).toString(), participant: key.participant } }
501
547
  ]
502
- };
503
- };
548
+ }
549
+ }
504
550
 
505
551
  export const decodeMediaRetryNode = (node) => {
506
- const rmrNode = getBinaryNodeChild(node, 'rmr');
507
- const event = {
508
- key: { id: node.attrs.id, remoteJid: rmrNode.attrs.jid, fromMe: rmrNode.attrs.from_me === 'true', participant: rmrNode.attrs.participant }
509
- };
510
- const errorNode = getBinaryNodeChild(node, 'error');
552
+ const rmrNode = getBinaryNodeChild(node, 'rmr')
553
+ const event = { key: { id: node.attrs.id, remoteJid: rmrNode.attrs.jid, fromMe: rmrNode.attrs.from_me === 'true', participant: rmrNode.attrs.participant } }
554
+ const errorNode = getBinaryNodeChild(node, 'error')
511
555
  if (errorNode) {
512
- event.error = new Boom(`Failed to re-upload media (${+errorNode.attrs.code})`, { data: errorNode.attrs, statusCode: getStatusCodeForMediaRetry(+errorNode.attrs.code) });
556
+ event.error = new Boom(`Failed to re-upload media (${+errorNode.attrs.code})`, { data: errorNode.attrs, statusCode: getStatusCodeForMediaRetry(+errorNode.attrs.code) })
513
557
  } else {
514
- const encryptedInfoNode = getBinaryNodeChild(node, 'encrypt');
515
- const ciphertext = getBinaryNodeChildBuffer(encryptedInfoNode, 'enc_p');
516
- const iv = getBinaryNodeChildBuffer(encryptedInfoNode, 'enc_iv');
517
- if (ciphertext && iv) event.media = { ciphertext, iv };
518
- else event.error = new Boom('Failed to re-upload media (missing ciphertext)', { statusCode: 404 });
558
+ const encNode = getBinaryNodeChild(node, 'encrypt')
559
+ const ciphertext = getBinaryNodeChildBuffer(encNode, 'enc_p')
560
+ const iv = getBinaryNodeChildBuffer(encNode, 'enc_iv')
561
+ if (ciphertext && iv) event.media = { ciphertext, iv }
562
+ else event.error = new Boom('Failed to re-upload media (missing ciphertext)', { statusCode: 404 })
519
563
  }
520
- return event;
521
- };
564
+ return event
565
+ }
522
566
 
523
567
  export const decryptMediaRetryData = async ({ ciphertext, iv }, mediaKey, msgId) => {
524
- const plaintext = aesDecryptGCM(ciphertext, await getMediaRetryKey(mediaKey), iv, Buffer.from(msgId));
525
- return proto.MediaRetryNotification.decode(plaintext);
526
- };
568
+ const plaintext = aesDecryptGCM(ciphertext, await getMediaRetryKey(mediaKey), iv, Buffer.from(msgId))
569
+ return proto.MediaRetryNotification.decode(plaintext)
570
+ }
527
571
 
528
- export const getStatusCodeForMediaRetry = (code) => MEDIA_RETRY_STATUS_MAP[code];
572
+ export const getStatusCodeForMediaRetry = (code) => MEDIA_RETRY_STATUS_MAP[code]
529
573
 
530
574
  const MEDIA_RETRY_STATUS_MAP = {
531
575
  [proto.MediaRetryNotification.ResultType.SUCCESS]: 200,
532
576
  [proto.MediaRetryNotification.ResultType.DECRYPTION_ERROR]: 412,
533
577
  [proto.MediaRetryNotification.ResultType.NOT_FOUND]: 404,
534
578
  [proto.MediaRetryNotification.ResultType.GENERAL_ERROR]: 418
535
- };
579
+ }