UNPKG

naruyaizumi

Version:

A WebSockets library for interacting with WhatsApp Web

693 lines (673 loc) 25.1 kB
import { Boom } from "@hapi/boom"; import * as Crypto from "crypto"; import { once } from "events"; import { createReadStream, createWriteStream, promises as fs, WriteStream } from "fs"; import { tmpdir } from "os"; import { join } from "path"; import { Readable, Transform } from "stream"; import { URL } from "url"; import { proto } from "../../WAProto/index.js"; import { DEFAULT_ORIGIN, MEDIA_HKDF_KEY_MAPPING, MEDIA_PATH_MAP } from "../Defaults/index.js"; import { getBinaryNodeChild, getBinaryNodeChildBuffer, jidNormalizedUser, } from "../WABinary/index.js"; import { aesDecryptGCM, aesEncryptGCM, hkdf } from "./crypto.js"; import { generateMessageIDV2 } from "./generics.js"; const getTmpFilesDirectory = () => tmpdir(); const getImageProcessingLibrary = async () => { //@ts-ignore const sharp = await import("sharp").catch(() => {}); if (sharp) { return { sharp }; } throw new Boom("No image processing library available"); }; export const hkdfInfoKey = (type) => { const hkdfInfo = MEDIA_HKDF_KEY_MAPPING[type]; return `WhatsApp ${hkdfInfo} Keys`; }; export const getRawMediaUploadData = async (media, mediaType, logger) => { const { stream } = await getStream(media); logger?.debug("got stream for raw upload"); const hasher = Crypto.createHash("sha256"); const filePath = join(tmpdir(), mediaType + generateMessageIDV2()); const fileWriteStream = createWriteStream(filePath); let fileLength = 0; try { for await (const data of stream) { fileLength += data.length; hasher.update(data); if (!fileWriteStream.write(data)) { await once(fileWriteStream, "drain"); } } fileWriteStream.end(); await once(fileWriteStream, "finish"); stream.destroy(); const fileSha256 = hasher.digest(); logger?.debug("hashed data for raw upload"); return { filePath, fileSha256, fileLength, }; } catch (error) { fileWriteStream.destroy(); stream.destroy(); try { await fs.unlink(filePath); } catch { // } throw error; } }; export async function getMediaKeys(buffer, mediaType) { if (!buffer) { throw new Boom("Cannot derive from empty media key"); } if (typeof buffer === "string") { buffer = Buffer.from(buffer.replace("data:;base64,", ""), "base64"); } const expandedMediaKey = await hkdf(buffer, 112, { info: hkdfInfoKey(mediaType), }); return { iv: expandedMediaKey.slice(0, 16), cipherKey: expandedMediaKey.slice(16, 48), macKey: expandedMediaKey.slice(48, 80), }; } const extractVideoThumb = async (path, destPath, time, size) => { const proc = Bun.spawn([ "ffmpeg", "-ss", String(time), "-i", path, "-y", "-vf", `scale=${size.width}:-1`, "-vframes", "1", "-f", "image2", destPath, ]); const exitCode = await proc.exited; if (exitCode !== 0) { throw new Error(`ffmpeg failed with code ${exitCode}`); } }; export const extractImageThumb = async (bufferOrFilePath, width = 32) => { if (bufferOrFilePath instanceof Readable) { bufferOrFilePath = await toBuffer(bufferOrFilePath); } const lib = await getImageProcessingLibrary(); const img = lib.sharp.default(bufferOrFilePath); const dimensions = await img.metadata(); const buffer = await img.resize(width).jpeg({ quality: 50 }).toBuffer(); return { buffer, original: { width: dimensions.width, height: dimensions.height, }, }; }; export const encodeBase64EncodedStringForUpload = (b64) => encodeURIComponent(b64.replace(/\+/g, "-").replace(/\//g, "_").replace(/\=+$/, "")); export const generateProfilePicture = async (mediaUpload, dimensions) => { let buffer; const { width: w = 640, height: h = 640 } = dimensions || {}; if (Buffer.isBuffer(mediaUpload)) { buffer = mediaUpload; } else { const { stream } = await getStream(mediaUpload); buffer = await toBuffer(stream); } const lib = await getImageProcessingLibrary(); const img = lib.sharp.default(buffer).resize(w, h).jpeg({ quality: 50 }).toBuffer(); return { img: await img }; }; /** gets the SHA256 of the given media message */ export const mediaMessageSHA256B64 = (message) => { const media = Object.values(message)[0]; return media?.fileSha256 && Buffer.from(media.fileSha256).toString("base64"); }; export async function getAudioDuration(buffer) { const musicMetadata = await import('music-metadata'); let metadata; const options = { duration: true }; if (Buffer.isBuffer(buffer)) { metadata = await musicMetadata.parseBuffer(buffer, undefined, options); } else if (typeof buffer === 'string') { metadata = await musicMetadata.parseFile(buffer, options); } else { metadata = await musicMetadata.parseStream(buffer, undefined, options); } return metadata.format.duration; } /** * referenced from and modifying https://github.com/wppconnect-team/wa-js/blob/main/src/chat/functions/prepareAudioWaveform.ts * Enhanced to support both Node.js and Bun runtimes with improved waveform quality using RMS */ export async function getAudioWaveform(buffer, logger) { try { // @ts-ignore const { default: decoder } = await import('audio-decode'); let audioData; if (Buffer.isBuffer(buffer)) { audioData = buffer; } else if (typeof buffer === 'string') { const rStream = createReadStream(buffer); audioData = await toBuffer(rStream); } else { audioData = await toBuffer(buffer); } // Ensure proper ArrayBuffer for cross-runtime compatibility const decoderInput = audioData.buffer ? audioData.buffer.slice(audioData.byteOffset, audioData.byteOffset + audioData.byteLength) : audioData; const audioBuffer = await decoder(decoderInput); const rawData = audioBuffer.getChannelData(0); // We only need to work with one channel of data const samples = 64; // Number of samples we want to have in our final data set const blockSize = Math.floor(rawData.length / samples); // the number of samples in each subdivision const filteredData = []; // Use RMS (Root Mean Square) for better dynamic range representation for (let i = 0; i < samples; i++) { const blockStart = blockSize * i; // the location of the first sample in the block let sumSquares = 0; for (let j = 0; j < blockSize; j++) { const sample = rawData[blockStart + j]; sumSquares += sample * sample; // sum of squares for RMS calculation } const rms = Math.sqrt(sumSquares / blockSize); // Root Mean Square filteredData.push(rms); } const max = Math.max(...filteredData) || 1; const normalizedData = filteredData.map(rms => { const normalized = rms / max; const compressed = Math.pow(normalized, 0.7); return compressed; }); const waveform = new Uint8Array(normalizedData.map(n => Math.min(100, Math.max(5, Math.floor(100 * n))))); return waveform; } catch (e) { logger?.debug('Failed to generate waveform: ' + e); const fallback = new Uint8Array(64); for (let i = 0; i < 64; i++) { fallback[i] = Math.floor(Math.random() * 40 + 30); } return fallback; } } export const toReadable = (buffer) => { const readable = new Readable({ read: () => {} }); readable.push(buffer); readable.push(null); return readable; }; export const toBuffer = async (stream) => { const chunks = []; for await (const chunk of stream) { chunks.push(chunk); } stream.destroy(); return Buffer.concat(chunks); }; export const getStream = async (item, opts) => { if (Buffer.isBuffer(item)) { return { stream: toReadable(item), type: "buffer" }; } if ("stream" in item) { return { stream: item.stream, type: "readable" }; } const urlStr = item.url.toString(); if (urlStr.startsWith("data:")) { const buffer = Buffer.from(urlStr.split(",")[1], "base64"); return { stream: toReadable(buffer), type: "buffer" }; } if (urlStr.startsWith("http://") || urlStr.startsWith("https://")) { return { stream: await getHttpStream(item.url, opts), type: "remote", }; } return { stream: createReadStream(item.url), type: "file" }; }; /** generates a thumbnail for a given media, if required */ export async function generateThumbnail(file, mediaType, options) { let thumbnail; let originalImageDimensions; if (mediaType === "image") { const { buffer, original } = await extractImageThumb(file); thumbnail = buffer.toString("base64"); if (original.width && original.height) { originalImageDimensions = { width: original.width, height: original.height, }; } } else if (mediaType === "video") { const imgFilename = join(getTmpFilesDirectory(), generateMessageIDV2() + ".jpg"); try { await extractVideoThumb(file, imgFilename, "00:00:00", { width: 32, height: 32 }); const buff = await fs.readFile(imgFilename); thumbnail = buff.toString("base64"); await fs.unlink(imgFilename); } catch (err) { options.logger?.debug("could not generate video thumb: " + err); } } return { thumbnail, originalImageDimensions, }; } export const getHttpStream = async (url, options = {}) => { const response = await fetch(url.toString(), { dispatcher: options.dispatcher, method: "GET", headers: options.headers, }); if (!response.ok) { throw new Boom(`Failed to fetch stream from ${url}`, { statusCode: response.status, data: { url }, }); } // @ts-ignore Node18+ Readable.fromWeb exists return response.body instanceof Readable ? response.body : Readable.fromWeb(response.body); }; export const encryptedStream = async ( media, mediaType, { logger, saveOriginalFileIfRequired, opts } = {} ) => { const { stream, type } = await getStream(media, opts); logger?.debug("fetched media stream"); const mediaKey = Crypto.randomBytes(32); const { cipherKey, iv, macKey } = await getMediaKeys(mediaKey, mediaType); const encFilePath = join(getTmpFilesDirectory(), mediaType + generateMessageIDV2() + "-enc"); const encFileWriteStream = createWriteStream(encFilePath); let originalFileStream; let originalFilePath; if (saveOriginalFileIfRequired) { originalFilePath = join( getTmpFilesDirectory(), mediaType + generateMessageIDV2() + "-original" ); originalFileStream = createWriteStream(originalFilePath); } let fileLength = 0; const aes = Crypto.createCipheriv("aes-256-cbc", cipherKey, iv); const hmac = Crypto.createHmac("sha256", macKey).update(iv); const sha256Plain = Crypto.createHash("sha256"); const sha256Enc = Crypto.createHash("sha256"); const onChunk = (buff) => { sha256Enc.update(buff); hmac.update(buff); encFileWriteStream.write(buff); }; try { for await (const data of stream) { fileLength += data.length; if ( type === "remote" && opts?.maxContentLength && fileLength + data.length > opts.maxContentLength ) { throw new Boom(`content length exceeded when encrypting "${type}"`, { data: { media, type }, }); } if (originalFileStream) { if (!originalFileStream.write(data)) { await once(originalFileStream, "drain"); } } sha256Plain.update(data); onChunk(aes.update(data)); } onChunk(aes.final()); const mac = hmac.digest().slice(0, 10); sha256Enc.update(mac); const fileSha256 = sha256Plain.digest(); const fileEncSha256 = sha256Enc.digest(); encFileWriteStream.write(mac); encFileWriteStream.end(); originalFileStream?.end?.(); stream.destroy(); logger?.debug("encrypted data successfully"); return { mediaKey, originalFilePath, encFilePath, mac, fileEncSha256, fileSha256, fileLength, }; } catch (error) { // destroy all streams with error encFileWriteStream.destroy(); originalFileStream?.destroy?.(); aes.destroy(); hmac.destroy(); sha256Plain.destroy(); sha256Enc.destroy(); stream.destroy(); try { await fs.unlink(encFilePath); if (originalFilePath) { await fs.unlink(originalFilePath); } } catch (err) { logger?.error({ err }, "failed deleting tmp files"); } throw error; } }; const DEF_HOST = "mmg.whatsapp.net"; const AES_CHUNK_SIZE = 16; const toSmallestChunkSize = (num) => { return Math.floor(num / AES_CHUNK_SIZE) * AES_CHUNK_SIZE; }; export const getUrlFromDirectPath = (directPath) => `https://${DEF_HOST}${directPath}`; export const downloadContentFromMessage = async ( { mediaKey, directPath, url }, type, opts = {} ) => { const isValidMediaUrl = url?.startsWith("https://mmg.whatsapp.net/"); const downloadUrl = isValidMediaUrl ? url : getUrlFromDirectPath(directPath); if (!downloadUrl) { throw new Boom("No valid media URL or directPath present in message", { statusCode: 400 }); } const keys = await getMediaKeys(mediaKey, type); return downloadEncryptedContent(downloadUrl, keys, opts); }; /** * Decrypts and downloads an AES256-CBC encrypted file given the keys. * Assumes the SHA256 of the plaintext is appended to the end of the ciphertext * */ export const downloadEncryptedContent = async ( downloadUrl, { cipherKey, iv }, { startByte, endByte, options } = {} ) => { let bytesFetched = 0; let startChunk = 0; let firstBlockIsIV = false; // if a start byte is specified -- then we need to fetch the previous chunk as that will form the IV if (startByte) { const chunk = toSmallestChunkSize(startByte || 0); if (chunk) { startChunk = chunk - AES_CHUNK_SIZE; bytesFetched = chunk; firstBlockIsIV = true; } } const endChunk = endByte ? toSmallestChunkSize(endByte || 0) + AES_CHUNK_SIZE : undefined; const headersInit = options?.headers ? options.headers : undefined; const headers = { ...(headersInit ? Array.isArray(headersInit) ? Object.fromEntries(headersInit) : headersInit : {}), Origin: DEFAULT_ORIGIN, }; if (startChunk || endChunk) { headers.Range = `bytes=${startChunk}-`; if (endChunk) { headers.Range += endChunk; } } // download the message const fetched = await getHttpStream(downloadUrl, { ...(options || {}), headers, }); let remainingBytes = Buffer.from([]); let aes; const pushBytes = (bytes, push) => { if (startByte || endByte) { const start = bytesFetched >= startByte ? undefined : Math.max(startByte - bytesFetched, 0); const end = bytesFetched + bytes.length < endByte ? undefined : Math.max(endByte - bytesFetched, 0); push(bytes.slice(start, end)); bytesFetched += bytes.length; } else { push(bytes); } }; const output = new Transform({ transform(chunk, _, callback) { let data = Buffer.concat([remainingBytes, chunk]); const decryptLength = toSmallestChunkSize(data.length); remainingBytes = data.slice(decryptLength); data = data.slice(0, decryptLength); if (!aes) { let ivValue = iv; if (firstBlockIsIV) { ivValue = data.slice(0, AES_CHUNK_SIZE); data = data.slice(AES_CHUNK_SIZE); } aes = Crypto.createDecipheriv("aes-256-cbc", cipherKey, ivValue); // if an end byte that is not EOF is specified // stop auto padding (PKCS7) -- otherwise throws an error for decryption if (endByte) { aes.setAutoPadding(false); } } try { pushBytes(aes.update(data), (b) => this.push(b)); callback(); } catch (error) { callback(error); } }, final(callback) { try { pushBytes(aes.final(), (b) => this.push(b)); callback(); } catch (error) { callback(error); } }, }); return fetched.pipe(output, { end: true }); }; export function extensionForMediaMessage(message) { const getExtension = (mimetype) => mimetype.split(";")[0]?.split("/")[1]; const type = Object.keys(message)[0]; let extension; if (type === "locationMessage" || type === "liveLocationMessage" || type === "productMessage") { extension = ".jpeg"; } else { const messageContent = message[type]; extension = getExtension(messageContent.mimetype); } return extension; } export const getWAUploadToServer = ( { customUploadHosts, fetchAgent, logger, options }, refreshMediaConn ) => { return async (filePath, { mediaType, fileEncSha256B64, timeoutMs }) => { // send a query JSON to obtain the url & auth token to upload our media let uploadInfo = await refreshMediaConn(false); let urls; const hosts = [...customUploadHosts, ...uploadInfo.hosts]; fileEncSha256B64 = encodeBase64EncodedStringForUpload(fileEncSha256B64); for (const { hostname } of hosts) { logger.debug(`uploading to "${hostname}"`); const auth = encodeURIComponent(uploadInfo.auth); // the auth token const url = `https://${hostname}${MEDIA_PATH_MAP[mediaType]}/${fileEncSha256B64}?auth=${auth}&token=${fileEncSha256B64}`; // eslint-disable-next-line @typescript-eslint/no-explicit-any let result; try { const stream = createReadStream(filePath); const response = await fetch(url, { dispatcher: fetchAgent, method: "POST", body: stream, headers: { ...(() => { const hdrs = options?.headers; if (!hdrs) return {}; return Array.isArray(hdrs) ? Object.fromEntries(hdrs) : hdrs; })(), "Content-Type": "application/octet-stream", Origin: DEFAULT_ORIGIN, }, duplex: "half", // Note: custom agents/proxy require undici Agent; omitted here. signal: timeoutMs ? AbortSignal.timeout(timeoutMs) : undefined, }); let parsed = undefined; try { parsed = await response.json(); } catch { parsed = undefined; } result = parsed; if (result?.url || result?.directPath) { urls = { mediaUrl: result.url, directPath: result.direct_path, meta_hmac: result.meta_hmac, fbid: result.fbid, ts: result.ts, }; break; } else { uploadInfo = await refreshMediaConn(true); throw new Error(`upload failed, reason: ${JSON.stringify(result)}`); } } catch (error) { const isLast = hostname === hosts[uploadInfo.hosts.length - 1]?.hostname; logger.warn( { trace: error?.stack, uploadResult: result, }, `Error in uploading to ${hostname} ${isLast ? "" : ", retrying..."}` ); } } if (!urls) { throw new Boom("Media upload failed on all hosts", { statusCode: 500 }); } return urls; }; }; const getMediaRetryKey = (mediaKey) => { return hkdf(mediaKey, 32, { info: "WhatsApp Media Retry Notification" }); }; /** * Generate a binary node that will request the phone to re-upload the media & return the newly uploaded URL */ export const encryptMediaRetryRequest = async (key, mediaKey, meId) => { const recp = { stanzaId: key.id }; const recpBuffer = proto.ServerErrorReceipt.encode(recp).finish(); const iv = Crypto.randomBytes(12); const retryKey = await getMediaRetryKey(mediaKey); const ciphertext = aesEncryptGCM(recpBuffer, retryKey, iv, Buffer.from(key.id)); const req = { tag: "receipt", attrs: { id: key.id, to: jidNormalizedUser(meId), type: "server-error", }, content: [ // this encrypt node is actually pretty useless // the media is returned even without this node // keeping it here to maintain parity with WA Web { tag: "encrypt", attrs: {}, content: [ { tag: "enc_p", attrs: {}, content: ciphertext, }, { tag: "enc_iv", attrs: {}, content: iv, }, ], }, { tag: "rmr", attrs: { jid: key.remoteJid, from_me: (!!key.fromMe).toString(), // @ts-ignore participant: key.participant || undefined, }, }, ], }; return req; }; export const decodeMediaRetryNode = (node) => { const rmrNode = getBinaryNodeChild(node, "rmr"); const event = { key: { id: node.attrs.id, remoteJid: rmrNode.attrs.jid, fromMe: rmrNode.attrs.from_me === "true", participant: rmrNode.attrs.participant, }, }; const errorNode = getBinaryNodeChild(node, "error"); if (errorNode) { const errorCode = +errorNode.attrs.code; event.error = new Boom(`Failed to re-upload media (${errorCode})`, { data: errorNode.attrs, statusCode: getStatusCodeForMediaRetry(errorCode), }); } else { const encryptedInfoNode = getBinaryNodeChild(node, "encrypt"); const ciphertext = getBinaryNodeChildBuffer(encryptedInfoNode, "enc_p"); const iv = getBinaryNodeChildBuffer(encryptedInfoNode, "enc_iv"); if (ciphertext && iv) { event.media = { ciphertext, iv }; } else { event.error = new Boom("Failed to re-upload media (missing ciphertext)", { statusCode: 404, }); } } return event; }; export const decryptMediaRetryData = async ({ ciphertext, iv }, mediaKey, msgId) => { const retryKey = await getMediaRetryKey(mediaKey); const plaintext = aesDecryptGCM(ciphertext, retryKey, iv, Buffer.from(msgId)); return proto.MediaRetryNotification.decode(plaintext); }; export const getStatusCodeForMediaRetry = (code) => MEDIA_RETRY_STATUS_MAP[code]; const MEDIA_RETRY_STATUS_MAP = { [proto.MediaRetryNotification.ResultType.SUCCESS]: 200, [proto.MediaRetryNotification.ResultType.DECRYPTION_ERROR]: 412, [proto.MediaRetryNotification.ResultType.NOT_FOUND]: 404, [proto.MediaRetryNotification.ResultType.GENERAL_ERROR]: 418, };