naruyaizumi
Version:
A WebSockets library for interacting with WhatsApp Web
693 lines (673 loc) • 25.1 kB
JavaScript
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,
};