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