Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -1302,7 +1303,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 @@ -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 */
Expand Down Expand Up @@ -1644,6 +1659,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
142 changes: 69 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 @@ -524,84 +525,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,
});
}
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;
Expand All @@ -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<MessageMediaStream|undefined>} 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.
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 @@ -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);
Expand Down
Loading