Skip to content

Commit c22720f

Browse files
committed
feat: support big file downloads (read from WA cache + streaming)
1 parent 1a9a1b7 commit c22720f

File tree

5 files changed

+169
-76
lines changed

5 files changed

+169
-76
lines changed

.eslintignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ dist
33
coverage
44
docs
55
*.min.js
6+
*.d.ts
67
.wa-version
78
.wwebjs_auth
89
.wwebjs_cache

example.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
const fs = require('fs');
12
const { Client, Location, Poll, List, Buttons, LocalAuth } = require('./index');
23

34
const client = new Client({
@@ -232,6 +233,18 @@ client.on('message', async (msg) => {
232233
Platform: ${info.platform}
233234
`,
234235
);
236+
} else if (msg.body === '!streamdownload' && msg.hasMedia) {
237+
const result = await msg.downloadMediaStream();
238+
if (result) {
239+
const filePath = `./${result.filename || 'download'}`;
240+
const writeStream = fs.createWriteStream(filePath);
241+
result.stream.pipe(writeStream);
242+
writeStream.on('finish', () => {
243+
msg.reply(
244+
`Media saved to ${filePath} (${result.mimetype}, ${result.filesize} bytes)`,
245+
);
246+
});
247+
}
235248
} else if (msg.body === '!mediainfo' && msg.hasMedia) {
236249
const attachmentData = await msg.downloadMedia();
237250
msg.reply(`

index.d.ts

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { EventEmitter } from 'events';
2+
import { Readable } from 'stream';
23
import { RequestInit } from 'node-fetch';
34
import * as puppeteer from 'puppeteer';
45
import InterfaceController from './src/util/InterfaceController';
@@ -198,7 +199,7 @@ declare namespace WAWebJS {
198199
): Promise<string>;
199200

200201
/** Cancels an active pairing code session and returns to QR code mode */
201-
cancelPairingCode(): Promise<void>
202+
cancelPairingCode(): Promise<void>;
202203

203204
/** Force reset of connection state for the client */
204205
resetState(): Promise<void>;
@@ -1302,7 +1303,11 @@ declare namespace WAWebJS {
13021303
/** Deletes the message from the chat */
13031304
delete: (everyone?: boolean, clearMedia?: boolean) => Promise<void>;
13041305
/** Downloads and returns the attached message media */
1305-
downloadMedia: () => Promise<MessageMedia>;
1306+
downloadMedia: () => Promise<MessageMedia | undefined>;
1307+
/** Downloads the attached message media as a Node.js Readable stream */
1308+
downloadMediaStream: (
1309+
options?: MediaStreamOptions,
1310+
) => Promise<MessageMediaStream | undefined>;
13061311
/** Returns the Chat this message was sent in */
13071312
getChat: () => Promise<Chat>;
13081313
/** Returns the Contact this message was sent from */
@@ -1610,8 +1615,18 @@ declare namespace WAWebJS {
16101615
reqOptions?: RequestInit;
16111616
}
16121617

1618+
/** Common metadata for media attached to a message */
1619+
export interface MessageMediaMetadata {
1620+
/** MIME type of the attachment */
1621+
mimetype: string;
1622+
/** Document file name. Value can be null */
1623+
filename?: string | null;
1624+
/** Document file size in bytes. Value can be null. */
1625+
filesize?: number | null;
1626+
}
1627+
16131628
/** Media attached to a message */
1614-
export class MessageMedia {
1629+
export class MessageMedia implements MessageMediaMetadata {
16151630
/** MIME type of the attachment */
16161631
mimetype: string;
16171632
/** Base64-encoded data of the file */
@@ -1644,6 +1659,17 @@ declare namespace WAWebJS {
16441659
) => Promise<MessageMedia>;
16451660
}
16461661

1662+
/** Options for downloadMediaStream */
1663+
export interface MediaStreamOptions {
1664+
/** Size in bytes of each chunk read from the browser (default 10MB) */
1665+
chunkSize?: number;
1666+
}
1667+
1668+
/** Result of downloadMediaStream: a Readable stream with media metadata */
1669+
export interface MessageMediaStream extends MessageMediaMetadata {
1670+
stream: Readable;
1671+
}
1672+
16471673
export type MessageContent =
16481674
| string
16491675
| MessageMedia

src/structures/Message.js

Lines changed: 69 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
'use strict';
22

3+
const { Readable } = require('stream');
34
const Base = require('./Base');
45
const MessageMedia = require('./MessageMedia');
56
const Location = require('./Location');
@@ -524,84 +525,25 @@ class Message extends Base {
524525
}
525526

526527
/**
527-
* Downloads and returns the attatched message media
528-
* @returns {Promise<MessageMedia>}
528+
* Downloads and returns the attached message media
529+
* @returns {Promise<MessageMedia|undefined>}
529530
*/
530531
async downloadMedia() {
531-
if (!this.hasMedia) {
532-
return undefined;
533-
}
532+
if (!this.hasMedia) return undefined;
534533

535534
const result = await this.client.pupPage.evaluate(async (msgId) => {
536-
const msg =
537-
window.require('WAWebCollections').Msg.get(msgId) ||
538-
(
539-
await window
540-
.require('WAWebCollections')
541-
.Msg.getMessagesById([msgId])
542-
)?.messages?.[0];
543-
544-
// REUPLOADING mediaStage means the media is expired and the download button is spinning, cannot be downloaded now
545-
if (
546-
!msg ||
547-
!msg.mediaData ||
548-
msg.mediaData.mediaStage === 'REUPLOADING'
549-
) {
550-
return null;
551-
}
552-
if (msg.mediaData.mediaStage != 'RESOLVED') {
553-
// try to resolve media
554-
await msg.downloadMedia({
555-
downloadEvenIfExpensive: true,
556-
rmrReason: 1,
557-
});
558-
}
535+
const resolved = await window.WWebJS.resolveMediaBlob(msgId);
536+
if (!resolved) return null;
559537

560-
if (
561-
msg.mediaData.mediaStage.includes('ERROR') ||
562-
msg.mediaData.mediaStage === 'FETCHING'
563-
) {
564-
// media could not be downloaded
565-
return undefined;
566-
}
567-
568-
try {
569-
const mockQpl = {
570-
addAnnotations: function () {
571-
return this;
572-
},
573-
addPoint: function () {
574-
return this;
575-
},
576-
};
577-
const decryptedMedia = await window
578-
.require('WAWebDownloadManager')
579-
.downloadManager.downloadAndMaybeDecrypt({
580-
directPath: msg.directPath,
581-
encFilehash: msg.encFilehash,
582-
filehash: msg.filehash,
583-
mediaKey: msg.mediaKey,
584-
mediaKeyTimestamp: msg.mediaKeyTimestamp,
585-
type: msg.type,
586-
signal: new AbortController().signal,
587-
downloadQpl: mockQpl,
588-
});
589-
590-
const data =
591-
await window.WWebJS.arrayBufferToBase64Async(
592-
decryptedMedia,
593-
);
594-
595-
return {
596-
data,
597-
mimetype: msg.mimetype,
598-
filename: msg.filename,
599-
filesize: msg.size,
600-
};
601-
} catch (e) {
602-
if (e.status && e.status === 404) return undefined;
603-
throw e;
604-
}
538+
const data = await window.WWebJS.arrayBufferToBase64Async(
539+
await resolved.blob.arrayBuffer(),
540+
);
541+
return {
542+
data,
543+
mimetype: resolved.mimetype,
544+
filename: resolved.filename,
545+
filesize: resolved.filesize,
546+
};
605547
}, this.id._serialized);
606548

607549
if (!result) return undefined;
@@ -613,6 +555,60 @@ class Message extends Base {
613555
);
614556
}
615557

558+
/**
559+
* Like downloadMedia(), but returns a Readable stream instead of loading the entire file into memory.
560+
* @param {Object} [options]
561+
* @param {number} [options.chunkSize=10485760] Size in bytes of each chunk read from the browser (default 10MB)
562+
* @returns {Promise<MessageMediaStream|undefined>} undefined if media is unavailable
563+
*/
564+
async downloadMediaStream({ chunkSize = 10 * 1024 * 1024 } = {}) {
565+
if (!this.hasMedia) return undefined;
566+
567+
const resultHandle = await this.client.pupPage.evaluateHandle(
568+
(msgId) => window.WWebJS.resolveMediaBlob(msgId),
569+
this.id._serialized,
570+
);
571+
572+
const info = await resultHandle.evaluate((r) =>
573+
r
574+
? {
575+
mimetype: r.mimetype,
576+
filename: r.filename,
577+
filesize: r.filesize,
578+
blobSize: r.blob.size,
579+
}
580+
: null,
581+
);
582+
if (!info) {
583+
await resultHandle.dispose();
584+
return undefined;
585+
}
586+
587+
const blobHandle = await resultHandle.evaluateHandle((r) => r.blob);
588+
await resultHandle.dispose();
589+
const { blobSize, ...metadata } = info;
590+
591+
async function* readChunks() {
592+
try {
593+
for (let offset = 0; offset < blobSize; offset += chunkSize) {
594+
const base64 = await blobHandle.evaluate(
595+
async (blob, s, e) =>
596+
window.WWebJS.arrayBufferToBase64Async(
597+
await blob.slice(s, e).arrayBuffer(),
598+
),
599+
offset,
600+
offset + chunkSize,
601+
);
602+
yield Buffer.from(base64, 'base64');
603+
}
604+
} finally {
605+
await blobHandle.dispose();
606+
}
607+
}
608+
609+
return { stream: Readable.from(readChunks()), ...metadata };
610+
}
611+
616612
/**
617613
* Deletes a message from the chat
618614
* @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.

src/util/Injected/Utils.js

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1111,6 +1111,63 @@ exports.LoadUtils = () => {
11111111
});
11121112
};
11131113

1114+
/**
1115+
* Resolves the media blob and metadata for a message.
1116+
* Shared by downloadMedia and downloadMediaStream.
1117+
* @param {string} msgId
1118+
* @returns {Promise<{blob: Blob, mimetype: string, filename: string, filesize: number}|null>}
1119+
*/
1120+
window.WWebJS.resolveMediaBlob = async (msgId) => {
1121+
const { Msg } = window.require('WAWebCollections');
1122+
const msg =
1123+
Msg.get(msgId) ||
1124+
(await Msg.getMessagesById([msgId]))?.messages?.[0];
1125+
1126+
if (
1127+
!msg ||
1128+
!msg.mediaData ||
1129+
msg.mediaData.mediaStage === 'REUPLOADING'
1130+
) {
1131+
return null;
1132+
}
1133+
1134+
// Always call internal downloadMedia - never skip based on
1135+
// mediaStage, because cache eviction can leave stage=RESOLVED
1136+
// with empty InMemoryMediaBlobCache.
1137+
await msg.downloadMedia({
1138+
downloadEvenIfExpensive: true,
1139+
rmrReason: 1,
1140+
isUserInitiated: true,
1141+
});
1142+
1143+
if (
1144+
msg.mediaData.mediaStage.includes('ERROR') ||
1145+
msg.mediaData.mediaStage === 'FETCHING'
1146+
) {
1147+
return null;
1148+
}
1149+
1150+
const cached = window
1151+
.require('WAWebMediaInMemoryBlobCache')
1152+
.InMemoryMediaBlobCache.get(msg.mediaObject?.filehash);
1153+
1154+
let blob;
1155+
if (cached) {
1156+
blob = cached;
1157+
} else if (msg.mediaObject?.mediaBlob) {
1158+
blob = msg.mediaObject.mediaBlob.forceToBlob();
1159+
}
1160+
1161+
if (!blob) return null;
1162+
1163+
return {
1164+
blob,
1165+
mimetype: msg.mimetype,
1166+
filename: msg.filename,
1167+
filesize: msg.size,
1168+
};
1169+
};
1170+
11141171
window.WWebJS.arrayBufferToBase64 = (arrayBuffer) => {
11151172
let binary = '';
11161173
const bytes = new Uint8Array(arrayBuffer);

0 commit comments

Comments
 (0)