diff --git a/.eslintignore b/.eslintignore index ad9338310d..c52e328b65 100644 --- a/.eslintignore +++ b/.eslintignore @@ -3,6 +3,7 @@ dist coverage docs *.min.js +*.d.ts .wa-version .wwebjs_auth .wwebjs_cache diff --git a/example.js b/example.js index 195f3c0cab..9127841880 100644 --- a/example.js +++ b/example.js @@ -1,3 +1,4 @@ +const fs = require('fs'); const { Client, Location, Poll, List, Buttons, LocalAuth } = require('./index'); const client = new Client({ @@ -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(` diff --git a/index.d.ts b/index.d.ts index 9fc11bb5cc..6a9f58dda7 100644 --- a/index.d.ts +++ b/index.d.ts @@ -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'; @@ -198,7 +199,7 @@ declare namespace WAWebJS { ): Promise; /** Cancels an active pairing code session and returns to QR code mode */ - cancelPairingCode(): Promise + cancelPairingCode(): Promise; /** Force reset of connection state for the client */ resetState(): Promise; @@ -1302,7 +1303,11 @@ declare namespace WAWebJS { /** Deletes the message from the chat */ delete: (everyone?: boolean, clearMedia?: boolean) => Promise; /** Downloads and returns the attached message media */ - downloadMedia: () => Promise; + downloadMedia: () => Promise; + /** Downloads the attached message media as a Node.js Readable stream */ + downloadMediaStream: ( + options?: MediaStreamOptions, + ) => Promise; /** Returns the Chat this message was sent in */ getChat: () => Promise; /** Returns the Contact this message was sent from */ @@ -1610,8 +1615,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 */ @@ -1644,6 +1659,17 @@ declare namespace WAWebJS { ) => Promise; } + /** 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 diff --git a/src/structures/Message.js b/src/structures/Message.js index 8ac970ede7..77b3d440e1 100644 --- a/src/structures/Message.js +++ b/src/structures/Message.js @@ -1,5 +1,6 @@ 'use strict'; +const { Readable } = require('stream'); const Base = require('./Base'); const MessageMedia = require('./MessageMedia'); const Location = require('./Location'); @@ -524,84 +525,25 @@ class Message extends Base { } /** - * Downloads and returns the attatched message media - * @returns {Promise} + * Downloads and returns the attached message media + * @returns {Promise} */ 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, - }); - } + const resolved = await window.WWebJS.resolveMediaBlob(msgId); + if (!resolved) return null; - 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, - ); - - 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; @@ -613,6 +555,60 @@ 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} undefined if media is unavailable + */ + async downloadMediaStream({ chunkSize = 10 * 1024 * 1024 } = {}) { + if (!this.hasMedia) return undefined; + + const resultHandle = await this.client.pupPage.evaluateHandle( + (msgId) => window.WWebJS.resolveMediaBlob(msgId), + this.id._serialized, + ); + + const info = await resultHandle.evaluate((r) => + r + ? { + mimetype: r.mimetype, + filename: r.filename, + filesize: r.filesize, + blobSize: r.blob.size, + } + : null, + ); + if (!info) { + await resultHandle.dispose(); + return undefined; + } + + const blobHandle = await resultHandle.evaluateHandle((r) => r.blob); + await resultHandle.dispose(); + const { blobSize, ...metadata } = info; + + 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(); + } + } + + return { stream: Readable.from(readChunks()), ...metadata }; + } + /** * 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. diff --git a/src/util/Injected/Utils.js b/src/util/Injected/Utils.js index 53cc348794..42433a2daa 100644 --- a/src/util/Injected/Utils.js +++ b/src/util/Injected/Utils.js @@ -1111,6 +1111,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);