dfaeef0db1
The latest android version has messages with the last: true flag set on all messages, causing incorrect events being fired off. This update creates a timeout that fires off the initial-data-received 2500ms after the last messages received. This may not be as accurate in production but will be better than the current version
468 lines
19 KiB
TypeScript
468 lines
19 KiB
TypeScript
import * as Crypto from 'crypto'
|
|
import { Readable, Transform } from 'stream'
|
|
import HKDF from 'futoin-hkdf'
|
|
import Jimp from 'jimp'
|
|
import {createReadStream, createWriteStream, promises as fs, WriteStream} from 'fs'
|
|
import { exec } from 'child_process'
|
|
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, DEFAULT_ORIGIN, WAMediaUpload } from './Constants'
|
|
import KeyedDB from '@adiwajshing/keyed-db'
|
|
import got, { Options, Response } from 'got'
|
|
import { join } from 'path'
|
|
import { IAudioMetadata } from 'music-metadata'
|
|
|
|
const platformMap = {
|
|
'aix': 'AIX',
|
|
'darwin': 'Mac OS',
|
|
'win32': 'Windows',
|
|
'android': 'Android'
|
|
}
|
|
export const Browsers = {
|
|
ubuntu: browser => ['Ubuntu', browser, '18.04'] as [string, string, string],
|
|
macOS: browser => ['Mac OS', browser, '10.15.3'] as [string, string, string],
|
|
baileys: browser => ['Baileys', browser, '3.0'] as [string, string, string],
|
|
/** The appropriate browser based on your OS & release */
|
|
appropriate: browser => [ platformMap [platform()] || 'Ubuntu', browser, release() ] as [string, string, string]
|
|
}
|
|
export const toNumber = (t: Long | number) => (t['low'] || t) as number
|
|
export const waChatKey = (pin: boolean) => ({
|
|
key: (c: WAChat) => (pin ? (c.pin ? '1' : '0') : '') + (c.archive === 'true' ? '0' : '1') + c.t.toString(16).padStart(8, '0') + c.jid,
|
|
compare: (k1: string, k2: string) => k2.localeCompare (k1)
|
|
})
|
|
export const waMessageKey = {
|
|
key: (m: WAMessage) => (5000 + (m['epoch'] || 0)).toString(16) + toNumber (m.messageTimestamp).toString(16).padStart(8, '0'),
|
|
compare: (k1: string, k2: string) => k1.localeCompare (k2)
|
|
}
|
|
export const WA_MESSAGE_ID = (m: WAMessage) => GET_MESSAGE_ID (m.key)
|
|
export const GET_MESSAGE_ID = (key: WAMessageKey) => `${key.id}|${key.fromMe ? 1 : 0}`
|
|
|
|
export const whatsappID = (jid: string) => jid?.replace ('@c.us', '@s.whatsapp.net')
|
|
export const isGroupID = (jid: string) => jid?.endsWith ('@g.us')
|
|
|
|
export const newMessagesDB = (messages: WAMessage[] = []) => {
|
|
const db = new KeyedDB(waMessageKey, WA_MESSAGE_ID)
|
|
messages.forEach(m => !db.get(WA_MESSAGE_ID(m)) && db.insert(m))
|
|
return db
|
|
}
|
|
|
|
export function shallowChanges <T> (old: T, current: T, {lookForDeletedKeys}: {lookForDeletedKeys: boolean}): Partial<T> {
|
|
let changes: Partial<T> = {}
|
|
for (let key in current) {
|
|
if (old[key] !== current[key]) {
|
|
changes[key] = current[key] || null
|
|
}
|
|
}
|
|
if (lookForDeletedKeys) {
|
|
for (let key in old) {
|
|
if (!changes[key] && old[key] !== current[key]) {
|
|
changes[key] = current[key] || null
|
|
}
|
|
}
|
|
}
|
|
return changes
|
|
}
|
|
|
|
/** decrypt AES 256 CBC; where the IV is prefixed to the buffer */
|
|
export function aesDecrypt(buffer: Buffer, key: Buffer) {
|
|
return aesDecryptWithIV(buffer.slice(16, buffer.length), key, buffer.slice(0, 16))
|
|
}
|
|
/** decrypt AES 256 CBC */
|
|
export function aesDecryptWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
|
const aes = Crypto.createDecipheriv('aes-256-cbc', key, IV)
|
|
return Buffer.concat([aes.update(buffer), aes.final()])
|
|
}
|
|
// encrypt AES 256 CBC; where a random IV is prefixed to the buffer
|
|
export function aesEncrypt(buffer: Buffer, key: Buffer) {
|
|
const IV = randomBytes(16)
|
|
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
|
|
return Buffer.concat([IV, aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
|
}
|
|
// encrypt AES 256 CBC with a given IV
|
|
export function aesEncrypWithIV(buffer: Buffer, key: Buffer, IV: Buffer) {
|
|
const aes = Crypto.createCipheriv('aes-256-cbc', key, IV)
|
|
return Buffer.concat([aes.update(buffer), aes.final()]) // prefix IV to the buffer
|
|
}
|
|
// sign HMAC using SHA 256
|
|
export function hmacSign(buffer: Buffer, key: Buffer) {
|
|
return Crypto.createHmac('sha256', key).update(buffer).digest()
|
|
}
|
|
export function sha256(buffer: Buffer) {
|
|
return Crypto.createHash('sha256').update(buffer).digest()
|
|
}
|
|
// HKDF key expansion
|
|
export function hkdf(buffer: Buffer, expandedLength: number, info = null) {
|
|
return HKDF(buffer, expandedLength, { salt: Buffer.alloc(32), info: info, hash: 'SHA-256' })
|
|
}
|
|
// generate a buffer with random bytes of the specified length
|
|
export function randomBytes(length) {
|
|
return Crypto.randomBytes(length)
|
|
}
|
|
/** unix timestamp of a date in seconds */
|
|
export const unixTimestampSeconds = (date: Date = new Date()) => Math.floor(date.getTime()/1000)
|
|
|
|
export type DebouncedTimeout = ReturnType<typeof debouncedTimeout>
|
|
export const debouncedTimeout = (intervalMs: number = 1000, task: () => void = undefined) => {
|
|
let timeout: NodeJS.Timeout
|
|
return {
|
|
start: (newIntervalMs?: number, newTask?: () => void) => {
|
|
task = newTask || task
|
|
intervalMs = newIntervalMs || intervalMs
|
|
timeout && clearTimeout(timeout)
|
|
timeout = setTimeout(task, intervalMs)
|
|
},
|
|
cancel: () => {
|
|
timeout && clearTimeout(timeout)
|
|
timeout = undefined
|
|
},
|
|
setTask: (newTask: () => void) => task = newTask,
|
|
setInterval: (newInterval: number) => intervalMs = newInterval
|
|
}
|
|
}
|
|
|
|
export const delay = (ms: number) => delayCancellable (ms).delay
|
|
export const delayCancellable = (ms: number) => {
|
|
const stack = new Error().stack
|
|
let timeout: NodeJS.Timeout
|
|
let reject: (error) => void
|
|
const delay: Promise<void> = new Promise((resolve, _reject) => {
|
|
timeout = setTimeout(resolve, ms)
|
|
reject = _reject
|
|
})
|
|
const cancel = () => {
|
|
clearTimeout (timeout)
|
|
reject (CancelledError(stack))
|
|
}
|
|
return { delay, cancel }
|
|
}
|
|
export async function promiseTimeout<T>(ms: number, promise: (resolve: (v?: T)=>void, reject: (error) => void) => void) {
|
|
if (!ms) return new Promise (promise)
|
|
const stack = new Error().stack
|
|
// Create a promise that rejects in <ms> milliseconds
|
|
let {delay, cancel} = delayCancellable (ms)
|
|
const p = new Promise ((resolve, reject) => {
|
|
delay
|
|
.then(() => reject(TimedOutError(stack)))
|
|
.catch (err => reject(err))
|
|
|
|
promise (resolve, reject)
|
|
})
|
|
.finally (cancel)
|
|
return p as Promise<T>
|
|
}
|
|
// whatsapp requires a message tag for every message, we just use the timestamp as one
|
|
export function generateMessageTag(epoch?: number) {
|
|
let tag = unixTimestampSeconds().toString()
|
|
if (epoch) tag += '.--' + epoch // attach epoch if provided
|
|
return tag
|
|
}
|
|
// generate a random 16 byte client ID
|
|
export function generateClientID() {
|
|
return randomBytes(16).toString('base64')
|
|
}
|
|
// generate a random 16 byte ID to attach to a message
|
|
export function generateMessageID() {
|
|
return '3EB0' + randomBytes(4).toString('hex').toUpperCase()
|
|
}
|
|
export function decryptWA (message: string | Buffer, macKey: Buffer, encKey: Buffer, decoder: Decoder, fromMe: boolean=false): [string, Object, [number, number]?] {
|
|
let commaIndex = message.indexOf(',') // all whatsapp messages have a tag and a comma, followed by the actual message
|
|
if (commaIndex < 0) throw new BaileysError ('invalid message', { message }) // if there was no comma, then this message must be not be valid
|
|
|
|
if (message[commaIndex+1] === ',') commaIndex += 1
|
|
let data = message.slice(commaIndex+1, message.length)
|
|
|
|
// get the message tag.
|
|
// If a query was done, the server will respond with the same message tag we sent the query with
|
|
const messageTag: string = message.slice(0, commaIndex).toString()
|
|
let json
|
|
let tags
|
|
if (data.length > 0) {
|
|
if (typeof data === 'string') {
|
|
json = JSON.parse(data) // parse the JSON
|
|
} else {
|
|
if (!macKey || !encKey) {
|
|
throw new BaileysError ('recieved encrypted buffer when auth creds unavailable', { message })
|
|
}
|
|
/*
|
|
If the data recieved was not a JSON, then it must be an encrypted message.
|
|
Such a message can only be decrypted if we're connected successfully to the servers & have encryption keys
|
|
*/
|
|
if (fromMe) {
|
|
tags = [data[0], data[1]]
|
|
data = data.slice(2, data.length)
|
|
}
|
|
|
|
const checksum = data.slice(0, 32) // the first 32 bytes of the buffer are the HMAC sign of the message
|
|
data = data.slice(32, data.length) // the actual message
|
|
const computedChecksum = hmacSign(data, macKey) // compute the sign of the message we recieved using our macKey
|
|
|
|
if (checksum.equals(computedChecksum)) {
|
|
// the checksum the server sent, must match the one we computed for the message to be valid
|
|
const decrypted = aesDecrypt(data, encKey) // decrypt using AES
|
|
json = decoder.read(decrypted) // decode the binary message into a JSON array
|
|
} else {
|
|
throw new BaileysError ('checksum failed', {
|
|
received: checksum.toString('hex'),
|
|
computed: computedChecksum.toString('hex'),
|
|
data: data.slice(0, 80).toString(),
|
|
tag: messageTag,
|
|
message: message.slice(0, 80).toString()
|
|
})
|
|
}
|
|
}
|
|
}
|
|
return [messageTag, json, tags]
|
|
}
|
|
/** generates all the keys required to encrypt/decrypt & sign a media message */
|
|
export function getMediaKeys(buffer, mediaType: MessageType) {
|
|
if (typeof buffer === 'string') {
|
|
buffer = Buffer.from (buffer.replace('data:;base64,', ''), 'base64')
|
|
}
|
|
// expand using HKDF to 112 bytes, also pass in the relevant app info
|
|
const expandedMediaKey = hkdf(buffer, 112, HKDFInfoKeys[mediaType])
|
|
return {
|
|
iv: expandedMediaKey.slice(0, 16),
|
|
cipherKey: expandedMediaKey.slice(16, 48),
|
|
macKey: expandedMediaKey.slice(48, 80),
|
|
}
|
|
}
|
|
/** Extracts video thumb using FFMPEG */
|
|
const extractVideoThumb = async (
|
|
path: string,
|
|
destPath: string,
|
|
time: string,
|
|
size: { width: number; height: number },
|
|
) =>
|
|
new Promise((resolve, reject) => {
|
|
const cmd = `ffmpeg -ss ${time} -i ${path} -y -s ${size.width}x${size.height} -vframes 1 -f image2 ${destPath}`
|
|
exec(cmd, (err) => {
|
|
if (err) reject(err)
|
|
else resolve()
|
|
})
|
|
}) as Promise<void>
|
|
|
|
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)
|
|
const min = Math.min(jimp.getWidth (), jimp.getHeight ())
|
|
const cropped = jimp.crop (0, 0, min, min)
|
|
return {
|
|
img: await cropped.resize(640, 640).getBufferAsync (Jimp.MIME_JPEG),
|
|
preview: await cropped.resize(96, 96).getBufferAsync (Jimp.MIME_JPEG)
|
|
}
|
|
}
|
|
export const ProxyAgent = (host: string | URL) => HttpsProxyAgent(host) as any as Agent
|
|
/** gets the SHA256 of the given media message */
|
|
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 | string) {
|
|
const musicMetadata = await import ('music-metadata')
|
|
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(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(file)
|
|
info.thumbnail = buff.toString('base64')
|
|
} else if (mediaType === MessageType.video) {
|
|
const imgFilename = join(tmpdir(), generateMessageID() + '.jpg')
|
|
try {
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
export const getGotStream = async(url: string | URL, options: Options & { isStream?: true } = {}) => {
|
|
const fetched = got.stream(url, { ...options, isStream: true })
|
|
await new Promise((resolve, reject) => {
|
|
fetched.once('error', 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, saveOriginalFileIfRequired = true) => {
|
|
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 if(saveOriginalFileIfRequired) {
|
|
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 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.
|
|
*/
|
|
const type = Object.keys(message)[0] as MessageType
|
|
if (!type) {
|
|
throw new BaileysError('unknown message type', message)
|
|
}
|
|
if (type === MessageType.text || type === MessageType.extendedText) {
|
|
throw new BaileysError('cannot decode text message', message)
|
|
}
|
|
if (type === MessageType.location || type === MessageType.liveLocation) {
|
|
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) {
|
|
const product = message.productMessage.product?.productImage
|
|
if (!product) throw new BaileysError ('product has no image', message)
|
|
messageContent = product
|
|
} else {
|
|
messageContent = message[type]
|
|
}
|
|
// download the message
|
|
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 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]
|
|
const type = Object.keys(message)[0] as MessageType
|
|
let extension: string
|
|
if (type === MessageType.location || type === MessageType.liveLocation || type === MessageType.product) {
|
|
extension = '.jpeg'
|
|
} else {
|
|
const messageContent = message[type] as
|
|
| WAMessageProto.VideoMessage
|
|
| WAMessageProto.ImageMessage
|
|
| WAMessageProto.AudioMessage
|
|
| WAMessageProto.DocumentMessage
|
|
extension = getExtension (messageContent.mimetype)
|
|
}
|
|
return extension
|
|
} |