Skip to content
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ dist
coverage
docs
*.min.js
*.d.ts
.wa-version
.wwebjs_auth
.wwebjs_cache
13 changes: 13 additions & 0 deletions example.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const fs = require('fs');
const { Client, Location, Poll, List, Buttons, LocalAuth } = require('./index');

const client = new Client({
Expand Down Expand Up @@ -232,6 +233,18 @@ client.on('message', async (msg) => {
Platform: ${info.platform}
`,
);
} else if (msg.body === '!streamdownload' && msg.hasMedia) {
const result = await msg.downloadMediaStream();
if (result) {
const filePath = `./${result.filename || 'download'}`;
const writeStream = fs.createWriteStream(filePath);
result.stream.pipe(writeStream);
writeStream.on('finish', () => {
msg.reply(
`Media saved to ${filePath} (${result.mimetype}, ${result.filesize} bytes)`,
);
});
}
} else if (msg.body === '!mediainfo' && msg.hasMedia) {
const attachmentData = await msg.downloadMedia();
msg.reply(`
Expand Down
32 changes: 29 additions & 3 deletions index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import { RequestInit } from 'node-fetch';
import * as puppeteer from 'puppeteer';
import InterfaceController from './src/util/InterfaceController';
Expand Down Expand Up @@ -198,7 +199,7 @@ declare namespace WAWebJS {
): Promise<string>;

/** Cancels an active pairing code session and returns to QR code mode */
cancelPairingCode(): Promise<void>
cancelPairingCode(): Promise<void>;

/** Force reset of connection state for the client */
resetState(): Promise<void>;
Expand Down Expand Up @@ -1308,7 +1309,11 @@ declare namespace WAWebJS {
/** Deletes the message from the chat */
delete: (everyone?: boolean, clearMedia?: boolean) => Promise<void>;
/** Downloads and returns the attached message media */
downloadMedia: () => Promise<MessageMedia>;
downloadMedia: () => Promise<MessageMedia | undefined>;
/** Downloads the attached message media as a Node.js Readable stream */
downloadMediaStream: (
options?: MediaStreamOptions,
) => Promise<MessageMediaStream | undefined>;
/** Returns the Chat this message was sent in */
getChat: () => Promise<Chat>;
/** Returns the Contact this message was sent from */
Expand Down Expand Up @@ -1616,8 +1621,18 @@ declare namespace WAWebJS {
reqOptions?: RequestInit;
}

/** Common metadata for media attached to a message */
export interface MessageMediaMetadata {
/** MIME type of the attachment */
mimetype: string;
/** Document file name. Value can be null */
filename?: string | null;
/** Document file size in bytes. Value can be null. */
filesize?: number | null;
}

/** Media attached to a message */
export class MessageMedia {
export class MessageMedia implements MessageMediaMetadata {
/** MIME type of the attachment */
mimetype: string;
/** Base64-encoded data of the file */
Expand Down Expand Up @@ -1650,6 +1665,17 @@ declare namespace WAWebJS {
) => Promise<MessageMedia>;
}

/** Options for downloadMediaStream */
export interface MediaStreamOptions {
/** Size in bytes of each chunk read from the browser (default 10MB) */
chunkSize?: number;
}

/** Result of downloadMediaStream: a Readable stream with media metadata */
export interface MessageMediaStream extends MessageMediaMetadata {
stream: Readable;
}

export type MessageContent =
| string
| MessageMedia
Expand Down
149 changes: 76 additions & 73 deletions src/structures/Message.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
'use strict';

const { Readable } = require('stream');
const Base = require('./Base');
const MessageMedia = require('./MessageMedia');
const Location = require('./Location');
Expand Down Expand Up @@ -507,84 +508,25 @@ class Message extends Base {
}

/**
* Downloads and returns the attatched message media
* @returns {Promise<MessageMedia>}
* Downloads and returns the attached message media
* @returns {Promise<MessageMedia|undefined>}
*/
async downloadMedia() {
if (!this.hasMedia) {
return undefined;
}
if (!this.hasMedia) return undefined;

const result = await this.client.pupPage.evaluate(async (msgId) => {
const msg =
window.require('WAWebCollections').Msg.get(msgId) ||
(
await window
.require('WAWebCollections')
.Msg.getMessagesById([msgId])
)?.messages?.[0];

// REUPLOADING mediaStage means the media is expired and the download button is spinning, cannot be downloaded now
if (
!msg ||
!msg.mediaData ||
msg.mediaData.mediaStage === 'REUPLOADING'
) {
return null;
}
if (msg.mediaData.mediaStage != 'RESOLVED') {
// try to resolve media
await msg.downloadMedia({
downloadEvenIfExpensive: true,
rmrReason: 1,
});
}

if (
msg.mediaData.mediaStage.includes('ERROR') ||
msg.mediaData.mediaStage === 'FETCHING'
) {
// media could not be downloaded
return undefined;
}

try {
const mockQpl = {
addAnnotations: function () {
return this;
},
addPoint: function () {
return this;
},
};
const decryptedMedia = await window
.require('WAWebDownloadManager')
.downloadManager.downloadAndMaybeDecrypt({
directPath: msg.directPath,
encFilehash: msg.encFilehash,
filehash: msg.filehash,
mediaKey: msg.mediaKey,
mediaKeyTimestamp: msg.mediaKeyTimestamp,
type: msg.type,
signal: new AbortController().signal,
downloadQpl: mockQpl,
});

const data =
await window.WWebJS.arrayBufferToBase64Async(
decryptedMedia,
);
const resolved = await window.WWebJS.resolveMediaBlob(msgId);
if (!resolved) return null;

return {
data,
mimetype: msg.mimetype,
filename: msg.filename,
filesize: msg.size,
};
} catch (e) {
if (e.status && e.status === 404) return undefined;
throw e;
}
const data = await window.WWebJS.arrayBufferToBase64Async(
await resolved.blob.arrayBuffer(),
);
return {
data,
mimetype: resolved.mimetype,
filename: resolved.filename,
filesize: resolved.filesize,
};
}, this.id._serialized);

if (!result) return undefined;
Expand All @@ -596,6 +538,67 @@ class Message extends Base {
);
}

/**
* Like downloadMedia(), but returns a Readable stream instead of loading the entire file into memory.
* @param {Object} [options]
* @param {number} [options.chunkSize=10485760] Size in bytes of each chunk read from the browser (default 10MB)
* @returns {Promise<MessageMediaStream|undefined>} undefined if media is unavailable
*/
async downloadMediaStream({ chunkSize = 10 * 1024 * 1024 } = {}) {
if (!this.hasMedia) return undefined;

const blobHandle = await this.client.pupPage.evaluateHandle(
async (msgId) => {
const result = await window.WWebJS.resolveMediaBlob(msgId);
return result?.blob ?? null;
},
this.id._serialized,
);

let metadata;
try {
metadata = await blobHandle.evaluate((blob, msgId) => {
if (!blob) return null;
const msg = window.require('WAWebCollections').Msg.get(msgId);
return {
blobSize: blob.size,
mimetype: msg?.mimetype,
filename: msg?.filename,
filesize: msg?.size,
};
}, this.id._serialized);
} catch (err) {
await blobHandle.dispose().catch(() => {});
throw err;
}
if (!metadata) {
await blobHandle.dispose().catch(() => {});
return undefined;
}

const { blobSize, ...rest } = metadata;

async function* readChunks() {
try {
for (let offset = 0; offset < blobSize; offset += chunkSize) {
const base64 = await blobHandle.evaluate(
async (blob, s, e) =>
window.WWebJS.arrayBufferToBase64Async(
await blob.slice(s, e).arrayBuffer(),
),
offset,
offset + chunkSize,
);
yield Buffer.from(base64, 'base64');
}
} finally {
await blobHandle.dispose().catch(() => {});
}
}

return { stream: Readable.from(readChunks()), ...rest };
}

/**
* Deletes a message from the chat
* @param {?boolean} everyone If true and the message is sent by the current user or the user is an admin, will delete it for everyone in the chat.
Expand Down
57 changes: 57 additions & 0 deletions src/util/Injected/Utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -1105,6 +1105,63 @@ exports.LoadUtils = () => {
});
};

/**
* Resolves the media blob and metadata for a message.
* Shared by downloadMedia and downloadMediaStream.
* @param {string} msgId
* @returns {Promise<{blob: Blob, mimetype: string, filename: string, filesize: number}|null>}
*/
window.WWebJS.resolveMediaBlob = async (msgId) => {
const { Msg } = window.require('WAWebCollections');
const msg =
Msg.get(msgId) ||
(await Msg.getMessagesById([msgId]))?.messages?.[0];

if (
!msg ||
!msg.mediaData ||
msg.mediaData.mediaStage === 'REUPLOADING'
) {
return null;
}

// Always call internal downloadMedia - never skip based on
// mediaStage, because cache eviction can leave stage=RESOLVED
// with empty InMemoryMediaBlobCache.
await msg.downloadMedia({
downloadEvenIfExpensive: true,
rmrReason: 1,
isUserInitiated: true,
});

if (
msg.mediaData.mediaStage.includes('ERROR') ||
msg.mediaData.mediaStage === 'FETCHING'
) {
return null;
}

const cached = window
.require('WAWebMediaInMemoryBlobCache')
.InMemoryMediaBlobCache.get(msg.mediaObject?.filehash);

let blob;
if (cached) {
blob = cached;
} else if (msg.mediaObject?.mediaBlob) {
blob = msg.mediaObject.mediaBlob.forceToBlob();
}

if (!blob) return null;

return {
blob,
mimetype: msg.mimetype,
filename: msg.filename,
filesize: msg.size,
};
};

window.WWebJS.arrayBufferToBase64 = (arrayBuffer) => {
let binary = '';
const bytes = new Uint8Array(arrayBuffer);
Expand Down
Loading