mirror of
https://github.com/FranP-code/Baileys.git
synced 2025-10-13 00:32:22 +00:00
Stream uploads + downloads + allow for remote url uploads
- Switch to using got - Use encryption/decryption streams for speed & lesser memory consumption - Allow for stream based download & simultaneous upload of media
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import fs from 'fs'
|
||||
import { decryptWA } from './WAConnection/WAConnection'
|
||||
import { decryptWA } from './WAConnection'
|
||||
import Decoder from './Binary/Decoder'
|
||||
|
||||
interface BrowserMessagesInfo {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection/WAConnection'
|
||||
import { WAConnection, MessageOptions, MessageType, unixTimestampSeconds, toNumber, GET_MESSAGE_ID, waMessageKey } from '../WAConnection'
|
||||
import * as assert from 'assert'
|
||||
import {promises as fs} from 'fs'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as assert from 'assert'
|
||||
import {WAConnection} from '../WAConnection/WAConnection'
|
||||
import {WAConnection} from '../WAConnection'
|
||||
import { AuthenticationCredentialsBase64, BaileysError, ReconnectMode, DisconnectReason, WAChat, WAContact } from '../WAConnection/Constants'
|
||||
import { delay } from '../WAConnection/Utils'
|
||||
import { assertChatDBIntegrity, makeConnection, testJid } from './Common'
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MessageType, GroupSettingChange, delay, ChatModification, whatsappID } from '../WAConnection/WAConnection'
|
||||
import { MessageType, GroupSettingChange, delay, ChatModification, whatsappID } from '../WAConnection'
|
||||
import * as assert from 'assert'
|
||||
import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common'
|
||||
|
||||
|
||||
43
src/Tests/Tests.Media.ts
Normal file
43
src/Tests/Tests.Media.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { deepStrictEqual, strictEqual } from 'assert'
|
||||
import { createWriteStream } from 'fs'
|
||||
import { readFile } from 'fs/promises'
|
||||
import { proto } from '../../WAMessage/WAMessage'
|
||||
import { MessageType } from '../WAConnection'
|
||||
import { aesEncrypWithIV, decryptMediaMessageBuffer, encryptedStream, getMediaKeys, getStream, hmacSign, sha256 } from '../WAConnection/Utils'
|
||||
import { WAConnectionTest } from './Common'
|
||||
|
||||
describe('Media Download Tests', () => {
|
||||
|
||||
it('should encrypt media streams correctly', async function() {
|
||||
const url = './Media/meme.jpeg'
|
||||
const streamValues = await encryptedStream({ url }, MessageType.image)
|
||||
|
||||
const buffer = await readFile(url)
|
||||
const mediaKeys = getMediaKeys(streamValues.mediaKey, MessageType.image)
|
||||
|
||||
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
||||
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
||||
const fileSha256 = sha256(buffer)
|
||||
const fileEncSha256 = sha256(body)
|
||||
|
||||
deepStrictEqual(streamValues.fileSha256, fileSha256)
|
||||
strictEqual(streamValues.fileLength, buffer.length)
|
||||
deepStrictEqual(streamValues.mac, mac)
|
||||
deepStrictEqual(await readFile(streamValues.encBodyPath), body)
|
||||
deepStrictEqual(streamValues.fileEncSha256, fileEncSha256)
|
||||
|
||||
})
|
||||
})
|
||||
/*
|
||||
WAConnectionTest('Media Upload', conn => {
|
||||
|
||||
it('should upload the same file', async () => {
|
||||
const FILES = [
|
||||
{ url: './Media/meme.jpeg', type: MessageType.image },
|
||||
{ url: './Media/ma_gif.mp4', type: MessageType.video },
|
||||
{ url: './Media/sonata.mp3', type: MessageType.audio },
|
||||
]
|
||||
})
|
||||
|
||||
})*/
|
||||
@@ -1,7 +1,8 @@
|
||||
import { MessageType, Mimetype, delay, promiseTimeout, WA_MESSAGE_STATUS_TYPE, WAMessageStatusUpdate, generateMessageID, WAMessage } from '../WAConnection/WAConnection'
|
||||
import {promises as fs} from 'fs'
|
||||
import { MessageType, Mimetype, delay, promiseTimeout, WA_MESSAGE_STATUS_TYPE, generateMessageID, WAMessage } from '../WAConnection'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as assert from 'assert'
|
||||
import { WAConnectionTest, testJid, sendAndRetreiveMessage, assertChatDBIntegrity } from './Common'
|
||||
import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common'
|
||||
import { resolve } from 'path'
|
||||
|
||||
WAConnectionTest('Messages', conn => {
|
||||
|
||||
@@ -61,8 +62,7 @@ WAConnectionTest('Messages', conn => {
|
||||
}
|
||||
})
|
||||
it('should send a gif', async () => {
|
||||
const content = await fs.readFile('./Media/ma_gif.mp4')
|
||||
const message = await sendAndRetreiveMessage(conn, content, MessageType.video, { mimetype: Mimetype.gif })
|
||||
const message = await sendAndRetreiveMessage(conn, { url: './Media/ma_gif.mp4' }, MessageType.video, { mimetype: Mimetype.gif })
|
||||
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_vid')
|
||||
})
|
||||
@@ -81,9 +81,18 @@ WAConnectionTest('Messages', conn => {
|
||||
assert.strictEqual (message.message?.audioMessage?.ptt, true)
|
||||
await conn.downloadAndSaveMediaMessage(message,'./Media/received_aud')
|
||||
})
|
||||
it('should send an image', async () => {
|
||||
const content = await fs.readFile('./Media/meme.jpeg')
|
||||
const message = await sendAndRetreiveMessage(conn, content, MessageType.image)
|
||||
it('should send a jpeg image', async () => {
|
||||
const message = await sendAndRetreiveMessage(conn, { url: './Media/meme.jpeg' }, MessageType.image)
|
||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
||||
const msg = await conn.downloadMediaMessage(message)
|
||||
assert.deepStrictEqual(msg, await fs.readFile('./Media/meme.jpeg'))
|
||||
})
|
||||
it('should send a remote jpeg image', async () => {
|
||||
const message = await sendAndRetreiveMessage(
|
||||
conn,
|
||||
{ url: 'https://www.memestemplates.com/wp-content/uploads/2020/05/tom-with-phone.jpg' },
|
||||
MessageType.image
|
||||
)
|
||||
assert.ok (message.message?.imageMessage?.jpegThumbnail)
|
||||
await conn.downloadMediaMessage(message)
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Presence, ChatModification, delay, newMessagesDB, WA_DEFAULT_EPHEMERAL, MessageType, WAMessage } from '../WAConnection/WAConnection'
|
||||
import { Presence, ChatModification, delay, newMessagesDB, WA_DEFAULT_EPHEMERAL, MessageType, WAMessage } from '../WAConnection'
|
||||
import { promises as fs } from 'fs'
|
||||
import * as assert from 'assert'
|
||||
import fetch from 'node-fetch'
|
||||
import got from 'got'
|
||||
import { WAConnectionTest, testJid, sendAndRetreiveMessage } from './Common'
|
||||
|
||||
WAConnectionTest('Misc', conn => {
|
||||
@@ -79,15 +79,14 @@ WAConnectionTest('Misc', conn => {
|
||||
await delay (5000)
|
||||
|
||||
const ppUrl = await conn.getProfilePicture(conn.user.jid)
|
||||
const fetched = await fetch(ppUrl)
|
||||
const buff = await fetched.buffer ()
|
||||
const {rawBody: oldPP} = await got(ppUrl)
|
||||
|
||||
const newPP = await fs.readFile ('./Media/cat.jpeg')
|
||||
const response = await conn.updateProfilePicture (conn.user.jid, newPP)
|
||||
const newPP = await fs.readFile('./Media/cat.jpeg')
|
||||
await conn.updateProfilePicture(conn.user.jid, newPP)
|
||||
|
||||
await delay (10000)
|
||||
|
||||
await conn.updateProfilePicture (conn.user.jid, buff) // revert back
|
||||
await conn.updateProfilePicture (conn.user.jid, oldPP) // revert back
|
||||
})
|
||||
it('should return the profile picture', async () => {
|
||||
const response = await conn.getProfilePicture(testJid)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as fs from 'fs'
|
||||
import WS from 'ws'
|
||||
import * as fs from 'fs'
|
||||
import * as Utils from './Utils'
|
||||
import Encoder from '../Binary/Encoder'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import fetch, { RequestRedirect } from 'node-fetch'
|
||||
import got, { Method } from 'got'
|
||||
import {
|
||||
AuthenticationCredentials,
|
||||
WAUser,
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
} from './Constants'
|
||||
import { EventEmitter } from 'events'
|
||||
import KeyedDB from '@adiwajshing/keyed-db'
|
||||
import { STATUS_CODES, Agent } from 'http'
|
||||
import { STATUS_CODES } from 'http'
|
||||
import { Agent } from 'https'
|
||||
import pino from 'pino'
|
||||
|
||||
const logger = pino({ prettyPrint: { levelFirst: true, ignore: 'hostname', translateTime: true }, prettifier: require('pino-pretty') })
|
||||
@@ -458,13 +459,20 @@ export class WAConnection extends EventEmitter {
|
||||
/**
|
||||
* Does a fetch request with the configuration of the connection
|
||||
*/
|
||||
protected fetchRequest = (endpoint: string, method: string = 'GET', body?: any, agent?: Agent, headers?: {[k: string]: string}, redirect: RequestRedirect = 'follow') => (
|
||||
fetch(endpoint, {
|
||||
protected fetchRequest = (
|
||||
endpoint: string,
|
||||
method: Method = 'GET',
|
||||
body?: any,
|
||||
agent?: Agent,
|
||||
headers?: {[k: string]: string},
|
||||
followRedirect = true
|
||||
) => (
|
||||
got(endpoint, {
|
||||
method,
|
||||
body,
|
||||
redirect,
|
||||
followRedirect,
|
||||
headers: { Origin: DEFAULT_ORIGIN, ...(headers || {}) },
|
||||
agent: agent || this.connectOptions.fetchAgent
|
||||
agent: { https: agent || this.connectOptions.fetchAgent }
|
||||
})
|
||||
)
|
||||
generateMessageTag (longTag: boolean = false) {
|
||||
|
||||
@@ -33,10 +33,10 @@ export class WAConnection extends Base {
|
||||
isOnWhatsAppNoConn = async (str: string) => {
|
||||
let phone = str.split('@')[0]
|
||||
const url = `https://wa.me/${phone}`
|
||||
const response = await this.fetchRequest(url, 'GET', undefined, undefined, undefined, 'manual')
|
||||
const loc = response.headers.get('Location')
|
||||
const response = await this.fetchRequest(url, 'GET', undefined, undefined, undefined, false)
|
||||
const loc = response.headers['Location'] as string
|
||||
if (!loc) {
|
||||
this.logger.warn({ url, status: response.status }, 'did not get location from request')
|
||||
this.logger.warn({ url, status: response.statusCode }, 'did not get location from request')
|
||||
return
|
||||
}
|
||||
const locUrl = new URL('', loc)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {WAConnection as Base} from './5.User'
|
||||
import {promises as fs} from 'fs'
|
||||
import {createReadStream, promises as fs} from 'fs'
|
||||
import {
|
||||
MessageOptions,
|
||||
MessageType,
|
||||
@@ -9,10 +9,11 @@ import {
|
||||
WALocationMessage,
|
||||
WAContactMessage,
|
||||
WATextMessage,
|
||||
WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, WA_MESSAGE_STATUS_TYPE, WAMessageProto, MediaConnInfo, MessageTypeProto, URL_REGEX, WAUrlInfo, WA_DEFAULT_EPHEMERAL
|
||||
WAMessageContent, WAMetric, WAFlag, WAMessage, BaileysError, WA_MESSAGE_STATUS_TYPE, WAMessageProto, MediaConnInfo, MessageTypeProto, URL_REGEX, WAUrlInfo, WA_DEFAULT_EPHEMERAL, WAMediaUpload
|
||||
} from './Constants'
|
||||
import { generateMessageID, sha256, hmacSign, aesEncrypWithIV, randomBytes, generateThumbnail, getMediaKeys, decodeMediaMessageBuffer, extensionForMediaMessage, whatsappID, unixTimestampSeconds, getAudioDuration, newMessagesDB } from './Utils'
|
||||
import { generateMessageID, extensionForMediaMessage, whatsappID, unixTimestampSeconds, getAudioDuration, newMessagesDB, encryptedStream, decryptMediaMessageBuffer, generateThumbnail } from './Utils'
|
||||
import { Mutex } from './Mutex'
|
||||
import { Readable } from 'stream'
|
||||
|
||||
export class WAConnection extends Base {
|
||||
/**
|
||||
@@ -116,9 +117,7 @@ export class WAConnection extends Base {
|
||||
return WAMessageProto.Message.fromObject(content)
|
||||
}
|
||||
/** Prepare a media message for sending */
|
||||
async prepareMessageMedia(buffer: Buffer, mediaType: MessageType, options: MessageOptions = {}) {
|
||||
await this.waitForConnection ()
|
||||
|
||||
async prepareMessageMedia(media: WAMediaUpload, mediaType: MessageType, options: MessageOptions = {}) {
|
||||
if (mediaType === MessageType.document && !options.mimetype) {
|
||||
throw new Error('mimetype required to send a document')
|
||||
}
|
||||
@@ -133,33 +132,31 @@ export class WAConnection extends Base {
|
||||
isGIF = true
|
||||
options.mimetype = MimetypeMap[MessageType.video]
|
||||
}
|
||||
// generate a media key
|
||||
const mediaKey = randomBytes(32)
|
||||
const mediaKeys = getMediaKeys(mediaKey, mediaType)
|
||||
const enc = aesEncrypWithIV(buffer, mediaKeys.cipherKey, mediaKeys.iv)
|
||||
const mac = hmacSign(Buffer.concat([mediaKeys.iv, enc]), mediaKeys.macKey).slice(0, 10)
|
||||
const body = Buffer.concat([enc, mac]) // body is enc + mac
|
||||
const fileSha256 = sha256(buffer)
|
||||
const fileEncSha256 = sha256(body)
|
||||
const {
|
||||
mediaKey,
|
||||
encBodyPath,
|
||||
bodyPath,
|
||||
fileEncSha256,
|
||||
fileSha256,
|
||||
fileLength
|
||||
} = await encryptedStream(media, mediaType)
|
||||
// url safe Base64 encode the SHA256 hash of the body
|
||||
const fileEncSha256B64 = encodeURIComponent(
|
||||
fileEncSha256
|
||||
.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/\=+$/, '')
|
||||
)
|
||||
await generateThumbnail(buffer, mediaType, options)
|
||||
const fileEncSha256B64 = encodeURIComponent(
|
||||
fileEncSha256.toString('base64')
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/\=+$/, '')
|
||||
)
|
||||
await generateThumbnail(bodyPath, mediaType, options)
|
||||
if (mediaType === MessageType.audio && !options.duration) {
|
||||
try {
|
||||
options.duration = await getAudioDuration (buffer)
|
||||
options.duration = await getAudioDuration(bodyPath)
|
||||
} catch (error) {
|
||||
this.logger.debug ({ error }, 'failed to obtain audio duration: ' + error.message)
|
||||
}
|
||||
}
|
||||
|
||||
// send a query JSON to obtain the url & auth token to upload our media
|
||||
let json = await this.refreshMediaConn (options.forceNewMediaOptions)
|
||||
let json = await this.refreshMediaConn(options.forceNewMediaOptions)
|
||||
|
||||
let mediaUrl: string
|
||||
for (let host of json.hosts) {
|
||||
@@ -167,8 +164,14 @@ export class WAConnection extends Base {
|
||||
const url = `https://${host.hostname}${MediaPathMap[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`
|
||||
|
||||
try {
|
||||
const urlFetch = await this.fetchRequest(url, 'POST', body, options.uploadAgent, { 'Content-Type': 'application/octet-stream' })
|
||||
const result = await urlFetch.json()
|
||||
const {body: responseText} = await this.fetchRequest(
|
||||
url,
|
||||
'POST',
|
||||
createReadStream(encBodyPath),
|
||||
options.uploadAgent,
|
||||
{ 'Content-Type': 'application/octet-stream' }
|
||||
)
|
||||
const result = JSON.parse(responseText)
|
||||
mediaUrl = result?.url
|
||||
|
||||
if (mediaUrl) break
|
||||
@@ -178,7 +181,7 @@ export class WAConnection extends Base {
|
||||
}
|
||||
} catch (error) {
|
||||
const isLast = host.hostname === json.hosts[json.hosts.length-1].hostname
|
||||
this.logger.error (`Error in uploading to ${host.hostname}${isLast ? '' : ', retrying...'}`)
|
||||
this.logger.error (`Error in uploading to ${host.hostname} (${error}) ${isLast ? '' : ', retrying...'}`)
|
||||
}
|
||||
}
|
||||
if (!mediaUrl) throw new Error('Media upload failed on all hosts')
|
||||
@@ -191,7 +194,7 @@ export class WAConnection extends Base {
|
||||
mimetype: options.mimetype,
|
||||
fileEncSha256: fileEncSha256,
|
||||
fileSha256: fileSha256,
|
||||
fileLength: buffer.length,
|
||||
fileLength: fileLength,
|
||||
seconds: options.duration,
|
||||
fileName: options.filename || 'file',
|
||||
gifPlayback: isGIF || undefined,
|
||||
@@ -317,24 +320,39 @@ export class WAConnection extends Base {
|
||||
})
|
||||
Object.keys (response[1]).forEach (key => content[key] = response[1][key]) // update message
|
||||
}
|
||||
async downloadMediaMessage (message: WAMessage): Promise<Buffer>
|
||||
async downloadMediaMessage (message: WAMessage, type: 'buffer'): Promise<Buffer>
|
||||
async downloadMediaMessage (message: WAMessage, type: 'stream'): Promise<Readable>
|
||||
/**
|
||||
* Securely downloads the media from the message.
|
||||
* Renews the download url automatically, if necessary.
|
||||
*/
|
||||
@Mutex (message => message?.key?.id)
|
||||
async downloadMediaMessage (message: WAMessage) {
|
||||
async downloadMediaMessage (message: WAMessage, type: 'buffer' | 'stream' = 'buffer') {
|
||||
let mContent = message.message?.ephemeralMessage?.message || message.message
|
||||
if (!mContent) throw new BaileysError('No message present', { status: 400 })
|
||||
|
||||
const downloadMediaMessage = async () => {
|
||||
const stream = await decryptMediaMessageBuffer(mContent)
|
||||
if(type === 'buffer') {
|
||||
let buffer = Buffer.from([])
|
||||
for await(const chunk of stream) {
|
||||
buffer = Buffer.concat([buffer, chunk])
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
return stream
|
||||
}
|
||||
|
||||
try {
|
||||
const buff = await decodeMediaMessageBuffer (mContent, this.fetchRequest)
|
||||
const buff = await downloadMediaMessage()
|
||||
return buff
|
||||
} catch (error) {
|
||||
if (error instanceof BaileysError && error.status === 404) { // media needs to be updated
|
||||
this.logger.info (`updating media of message: ${message.key.id}`)
|
||||
await this.updateMediaMessage (message)
|
||||
mContent = message.message?.ephemeralMessage?.message || message.message
|
||||
const buff = await decodeMediaMessageBuffer (mContent, this.fetchRequest)
|
||||
const buff = await downloadMediaMessage()
|
||||
return buff
|
||||
}
|
||||
throw error
|
||||
@@ -348,10 +366,11 @@ export class WAConnection extends Base {
|
||||
* @param attachExtension should the parsed extension be applied automatically to the file
|
||||
*/
|
||||
async downloadAndSaveMediaMessage (message: WAMessage, filename: string, attachExtension: boolean=true) {
|
||||
const buffer = await this.downloadMediaMessage (message)
|
||||
const extension = extensionForMediaMessage (message.message)
|
||||
const trueFileName = attachExtension ? (filename + '.' + extension) : filename
|
||||
await fs.writeFile (trueFileName, buffer)
|
||||
const buffer = await this.downloadMediaMessage(message)
|
||||
|
||||
await fs.writeFile(trueFileName, buffer)
|
||||
return trueFileName
|
||||
}
|
||||
/** Query a string to check if it has a url, if it does, return required extended text message */
|
||||
|
||||
@@ -2,6 +2,7 @@ import { WA } from '../Binary/Constants'
|
||||
import { proto } from '../../WAMessage/WAMessage'
|
||||
import { Agent } from 'https'
|
||||
import KeyedDB from '@adiwajshing/keyed-db'
|
||||
import { URL } from 'url'
|
||||
|
||||
export const WS_URL = 'wss://web.whatsapp.com/ws'
|
||||
export const DEFAULT_ORIGIN = 'https://web.whatsapp.com'
|
||||
@@ -70,6 +71,9 @@ export interface WAQuery {
|
||||
startDebouncedTimeout?: boolean
|
||||
maxRetries?: number
|
||||
}
|
||||
|
||||
export type WAMediaUpload = Buffer | { url: URL | string }
|
||||
|
||||
export enum ReconnectMode {
|
||||
/** does not reconnect */
|
||||
off = 0,
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import * as Crypto from 'crypto'
|
||||
import { Readable, Transform } from 'stream'
|
||||
import HKDF from 'futoin-hkdf'
|
||||
import Jimp from 'jimp'
|
||||
import {promises as fs} from 'fs'
|
||||
import {createReadStream, createWriteStream, promises as fs, WriteStream} from 'fs'
|
||||
import { exec } from 'child_process'
|
||||
import {platform, release} from 'os'
|
||||
import {platform, release, tmpdir} from 'os'
|
||||
import HttpsProxyAgent from 'https-proxy-agent'
|
||||
import { URL } from 'url'
|
||||
import { Agent } from 'https'
|
||||
import Decoder from '../Binary/Decoder'
|
||||
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto, TimedOutError, CancelledError, WAGenericMediaMessage, WAMessage, WAMessageKey } from './Constants'
|
||||
import { MessageType, HKDFInfoKeys, MessageOptions, WAChat, WAMessageContent, BaileysError, WAMessageProto, TimedOutError, CancelledError, WAGenericMediaMessage, WAMessage, WAMessageKey, DEFAULT_ORIGIN, WAMediaUpload } from './Constants'
|
||||
import KeyedDB from '@adiwajshing/keyed-db'
|
||||
import { Response } from 'node-fetch'
|
||||
import got, { Options, Response } from 'got'
|
||||
import { join } from 'path'
|
||||
import { IAudioMetadata } from 'music-metadata'
|
||||
|
||||
|
||||
const platformMap = {
|
||||
'aix': 'AIX',
|
||||
@@ -222,9 +226,10 @@ const extractVideoThumb = async (
|
||||
})
|
||||
}) as Promise<void>
|
||||
|
||||
export const compressImage = async (buffer: Buffer) => {
|
||||
const jimp = await Jimp.read (buffer)
|
||||
return jimp.resize(48, 48).getBufferAsync (Jimp.MIME_JPEG)
|
||||
export const compressImage = async (bufferOrFilePath: Buffer | string) => {
|
||||
const jimp = await Jimp.read(bufferOrFilePath as any)
|
||||
const result = await jimp.resize(48, 48).getBufferAsync(Jimp.MIME_JPEG)
|
||||
return result
|
||||
}
|
||||
export const generateProfilePicture = async (buffer: Buffer) => {
|
||||
const jimp = await Jimp.read (buffer)
|
||||
@@ -241,42 +246,131 @@ export const mediaMessageSHA256B64 = (message: WAMessageContent) => {
|
||||
const media = Object.values(message)[0] as WAGenericMediaMessage
|
||||
return media?.fileSha256 && Buffer.from(media.fileSha256).toString ('base64')
|
||||
}
|
||||
export async function getAudioDuration (buffer: Buffer) {
|
||||
export async function getAudioDuration (buffer: Buffer | string) {
|
||||
const musicMetadata = await import ('music-metadata')
|
||||
const metadata = await musicMetadata.parseBuffer (buffer, null, {duration: true});
|
||||
let metadata: IAudioMetadata
|
||||
if(Buffer.isBuffer(buffer)) {
|
||||
metadata = await musicMetadata.parseBuffer(buffer, null, { duration: true })
|
||||
} else {
|
||||
const rStream = createReadStream(buffer)
|
||||
metadata = await musicMetadata.parseStream(rStream, null, { duration: true })
|
||||
rStream.close()
|
||||
}
|
||||
return metadata.format.duration;
|
||||
}
|
||||
|
||||
export const toReadable = (buffer: Buffer) => {
|
||||
const readable = new Readable({ read: () => {} })
|
||||
readable.push(buffer)
|
||||
readable.push(null)
|
||||
return readable
|
||||
}
|
||||
export const getStream = async (item: WAMediaUpload) => {
|
||||
if(Buffer.isBuffer(item)) return { stream: toReadable(item), type: 'buffer' }
|
||||
if(item.url.toString().startsWith('http://') || item.url.toString().startsWith('https://')) {
|
||||
return { stream: await getGotStream(item.url), type: 'remote' }
|
||||
}
|
||||
return { stream: createReadStream(item.url), type: 'file' }
|
||||
}
|
||||
/** generates a thumbnail for a given media, if required */
|
||||
export async function generateThumbnail(buffer: Buffer, mediaType: MessageType, info: MessageOptions) {
|
||||
export async function generateThumbnail(file: string, mediaType: MessageType, info: MessageOptions) {
|
||||
if ('thumbnail' in info) {
|
||||
// don't do anything if the thumbnail is already provided, or is null
|
||||
if (mediaType === MessageType.audio) {
|
||||
throw new Error('audio messages cannot have thumbnails')
|
||||
}
|
||||
} else if (mediaType === MessageType.image) {
|
||||
const buff = await compressImage (buffer)
|
||||
const buff = await compressImage(file)
|
||||
info.thumbnail = buff.toString('base64')
|
||||
} else if (mediaType === MessageType.video) {
|
||||
const filename = './' + randomBytes(5).toString('hex') + '.mp4'
|
||||
const imgFilename = filename + '.jpg'
|
||||
await fs.writeFile(filename, buffer)
|
||||
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
|
||||
try {
|
||||
await extractVideoThumb(filename, imgFilename, '00:00:00', { width: 48, height: 48 })
|
||||
await extractVideoThumb(file, imgFilename, '00:00:00', { width: 48, height: 48 })
|
||||
const buff = await fs.readFile(imgFilename)
|
||||
info.thumbnail = buff.toString('base64')
|
||||
await fs.unlink(imgFilename)
|
||||
} catch (err) {
|
||||
console.log('could not generate video thumb: ' + err)
|
||||
}
|
||||
await fs.unlink(filename)
|
||||
}
|
||||
}
|
||||
export const getGotStream = async(url: string | URL, options: Options & { isStream?: true } = {}) => {
|
||||
const fetched = got.stream(url, options)
|
||||
await new Promise((resolve, reject) => {
|
||||
fetched.once('response', ({statusCode: status}: Response) => {
|
||||
if (status >= 400) {
|
||||
reject(new BaileysError (
|
||||
'Invalid code (' + status + ') returned',
|
||||
{ status }
|
||||
))
|
||||
} else {
|
||||
resolve(undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
return fetched
|
||||
}
|
||||
export const encryptedStream = async(media: WAMediaUpload, mediaType: MessageType) => {
|
||||
const { stream, type } = await getStream(media)
|
||||
|
||||
const mediaKey = randomBytes(32)
|
||||
const {cipherKey, iv, macKey} = getMediaKeys(mediaKey, mediaType)
|
||||
// random name
|
||||
const encBodyPath = join(tmpdir(), mediaType + generateMessageID() + '.enc')
|
||||
const encWriteStream = createWriteStream(encBodyPath)
|
||||
let bodyPath: string
|
||||
let writeStream: WriteStream
|
||||
if(type === 'file') {
|
||||
bodyPath = (media as any).url
|
||||
} else {
|
||||
bodyPath = join(tmpdir(), mediaType + generateMessageID())
|
||||
writeStream = createWriteStream(bodyPath)
|
||||
}
|
||||
|
||||
let fileLength = 0
|
||||
const aes = Crypto.createCipheriv('aes-256-cbc', cipherKey, iv)
|
||||
let hmac = Crypto.createHmac('sha256', macKey).update(iv)
|
||||
let sha256Plain = Crypto.createHash('sha256')
|
||||
let sha256Enc = Crypto.createHash('sha256')
|
||||
|
||||
const onChunk = (buff: Buffer) => {
|
||||
sha256Enc = sha256Enc.update(buff)
|
||||
hmac = hmac.update(buff)
|
||||
encWriteStream.write(buff)
|
||||
}
|
||||
for await(const data of stream) {
|
||||
fileLength += data.length
|
||||
sha256Plain = sha256Plain.update(data)
|
||||
writeStream && writeStream.write(data)
|
||||
onChunk(aes.update(data))
|
||||
}
|
||||
onChunk(aes.final())
|
||||
|
||||
const mac = hmac.digest().slice(0, 10)
|
||||
sha256Enc = sha256Enc.update(mac)
|
||||
|
||||
const fileSha256 = sha256Plain.digest()
|
||||
const fileEncSha256 = sha256Enc.digest()
|
||||
|
||||
encWriteStream.write(mac)
|
||||
encWriteStream.close()
|
||||
|
||||
writeStream && writeStream.close()
|
||||
|
||||
return {
|
||||
mediaKey,
|
||||
encBodyPath,
|
||||
bodyPath,
|
||||
mac,
|
||||
fileEncSha256,
|
||||
fileSha256,
|
||||
fileLength
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Decode a media message (video, image, document, audio) & return decrypted buffer
|
||||
* @param message the media message you want to decode
|
||||
*/
|
||||
export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchRequest: (host: string, method: string) => Promise<Response>) {
|
||||
export async function decryptMediaMessageBuffer(message: WAMessageContent): Promise<Readable> {
|
||||
/*
|
||||
One can infer media type from the key in the message
|
||||
it is usually written as [mediaType]Message. Eg. imageMessage, audioMessage etc.
|
||||
@@ -289,7 +383,11 @@ export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchR
|
||||
throw new BaileysError('cannot decode text message', message)
|
||||
}
|
||||
if (type === MessageType.location || type === MessageType.liveLocation) {
|
||||
return Buffer.from(message[type].jpegThumbnail)
|
||||
const buffer = Buffer.from(message[type].jpegThumbnail)
|
||||
const readable = new Readable({ read: () => {} })
|
||||
readable.push(buffer)
|
||||
readable.push(null)
|
||||
return readable
|
||||
}
|
||||
let messageContent: WAGenericMediaMessage
|
||||
if (message.productMessage) {
|
||||
@@ -299,41 +397,39 @@ export async function decodeMediaMessageBuffer(message: WAMessageContent, fetchR
|
||||
} else {
|
||||
messageContent = message[type]
|
||||
}
|
||||
|
||||
// download the message
|
||||
const fetched = await fetchRequest(messageContent.url, 'GET')
|
||||
if (fetched.status >= 400) {
|
||||
throw new BaileysError ('Invalid code (' + fetched.status + ') returned. File has possibly been deleted from WA servers. Run `client.updateMediaMessage()` to refresh the url', {status: fetched.status})
|
||||
}
|
||||
const buffer = await fetched.buffer()
|
||||
const fetched = await getGotStream(messageContent.url, {
|
||||
headers: { Origin: DEFAULT_ORIGIN }
|
||||
})
|
||||
let remainingBytes = Buffer.from([])
|
||||
const { cipherKey, iv } = getMediaKeys(messageContent.mediaKey, type)
|
||||
const aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, iv)
|
||||
|
||||
const decryptedMedia = (type: MessageType) => {
|
||||
// get the keys to decrypt the message
|
||||
const mediaKeys = getMediaKeys(messageContent.mediaKey, type) //getMediaKeys(Buffer.from(messageContent.mediaKey, 'base64'), type)
|
||||
// first part is actual file
|
||||
const file = buffer.slice(0, buffer.length - 10)
|
||||
// last 10 bytes is HMAC sign of file
|
||||
const mac = buffer.slice(buffer.length - 10, buffer.length)
|
||||
// sign IV+file & check for match with mac
|
||||
const testBuff = Buffer.concat([mediaKeys.iv, file])
|
||||
const sign = hmacSign(testBuff, mediaKeys.macKey).slice(0, 10)
|
||||
// our sign should equal the mac
|
||||
if (!sign.equals(mac)) throw new Error()
|
||||
|
||||
return aesDecryptWithIV(file, mediaKeys.cipherKey, mediaKeys.iv) // decrypt media
|
||||
}
|
||||
const allTypes = [type, ...Object.keys(HKDFInfoKeys)]
|
||||
for (let i = 0; i < allTypes.length;i++) {
|
||||
try {
|
||||
const decrypted = decryptedMedia (allTypes[i] as MessageType)
|
||||
|
||||
if (i > 0) { console.log (`decryption of ${type} media with HKDF key of ${allTypes[i]}`) }
|
||||
return decrypted
|
||||
} catch {
|
||||
if (i === 0) { console.log (`decryption of ${type} media with original HKDF key failed`) }
|
||||
}
|
||||
}
|
||||
throw new BaileysError('Decryption failed, HMAC sign does not match', {status: 400})
|
||||
const output = new Transform({
|
||||
transform(chunk, _, callback) {
|
||||
let data = Buffer.concat([remainingBytes, chunk])
|
||||
const decryptLength =
|
||||
Math.floor(data.length / 16) * 16
|
||||
remainingBytes = data.slice(decryptLength)
|
||||
data = data.slice(0, decryptLength)
|
||||
|
||||
try {
|
||||
this.push(aes.update(data))
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
final(callback) {
|
||||
try {
|
||||
this.push(aes.final())
|
||||
callback()
|
||||
} catch(error) {
|
||||
callback(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
return fetched.pipe(output, { end: true })
|
||||
}
|
||||
export function extensionForMediaMessage(message: WAMessageContent) {
|
||||
const getExtension = (mimetype: string) => mimetype.split(';')[0].split('/')[1]
|
||||
|
||||
@@ -2,4 +2,4 @@ export * from '../WAMessage/WAMessage'
|
||||
export * from './Binary/Constants'
|
||||
export * from './Binary/Decoder'
|
||||
export * from './Binary/Encoder'
|
||||
export * from './WAConnection/WAConnection'
|
||||
export * from './WAConnection'
|
||||
|
||||
Reference in New Issue
Block a user