From 99e4dfcfb536ea9e534579b935ca965c2f773720 Mon Sep 17 00:00:00 2001 From: devlikepro Date: Fri, 21 Aug 2026 11:12:21 +0700 Subject: [PATCH] feat: granular control for media - api, events, mimetypes closes #2211 Co-authored-by: Elimeshi1 --- .env.example | 10 +++ docker-compose/chatwoot/.waha.env | 10 +++ src/apps/mcp/tools/channels.tools.ts | 5 +- src/apps/mcp/tools/chats.tools.ts | 13 ++- src/config.service.ts | 53 ++++++++++-- src/core/abc/session.abc.ts | 23 ++++- src/core/engines/gows/session.gows.core.ts | 74 +++++++++------- src/core/engines/noweb/session.noweb.core.ts | 66 ++++++++------- src/core/engines/webjs/session.webjs.core.ts | 84 ++++++++++++------- src/core/engines/wpp/session.wpp.core.ts | 55 +++++++----- src/core/manager.core.ts | 3 +- src/core/media/IMediaManager.ts | 11 ++- src/core/media/MediaManager.ts | 44 +++++----- .../validation/CommaSeparatedStrings.test.ts | 45 ++++++++++ .../validation/CommaSeparatedStrings.ts | 20 +++++ src/structures/channels.dto.ts | 13 +++ src/structures/chats.dto.ts | 33 ++++++-- 17 files changed, 399 insertions(+), 163 deletions(-) create mode 100644 src/nestjs/validation/CommaSeparatedStrings.test.ts create mode 100644 src/nestjs/validation/CommaSeparatedStrings.ts diff --git a/.env.example b/.env.example index 1d79c5f3..a229878d 100644 --- a/.env.example +++ b/.env.example @@ -64,7 +64,17 @@ WAHA_MEDIA_STORAGE=LOCAL WHATSAPP_FILES_LIFETIME=0 WHATSAPP_FILES_FOLDER=/app/.media +# # Media download settings +# +# Events (webhooks, websockets) +# WAHA_EVENTS_DOWNLOAD_MEDIA=true +# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png +# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.) +# WAHA_API_DOWNLOAD_MEDIA=true +# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png + +# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above # WHATSAPP_DOWNLOAD_MEDIA=true # WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png diff --git a/docker-compose/chatwoot/.waha.env b/docker-compose/chatwoot/.waha.env index 826dbadd..e646754d 100644 --- a/docker-compose/chatwoot/.waha.env +++ b/docker-compose/chatwoot/.waha.env @@ -60,7 +60,17 @@ WAHA_MEDIA_STORAGE=LOCAL WHATSAPP_FILES_LIFETIME=1800 WHATSAPP_FILES_FOLDER=/app/.media +# # Media download settings +# +# Events (webhooks, websockets) +# WAHA_EVENTS_DOWNLOAD_MEDIA=true +# WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png +# API default behavior (GET /api/{session}/chats/{chatId}/messages, etc.) +# WAHA_API_DOWNLOAD_MEDIA=true +# WAHA_API_DOWNLOAD_MEDIA_MIMETYPES=image/jpeg,image/png + +# DEPRECATED - use WAHA_EVENTS_* and WAHA_API_* variables above # WHATSAPP_DOWNLOAD_MEDIA=true # WHATSAPP_FILES_MIMETYPES=image/jpeg,image/png diff --git a/src/apps/mcp/tools/channels.tools.ts b/src/apps/mcp/tools/channels.tools.ts index aa3bb780..2432b6f2 100644 --- a/src/apps/mcp/tools/channels.tools.ts +++ b/src/apps/mcp/tools/channels.tools.ts @@ -107,7 +107,10 @@ export class ChannelTools extends McpController { return this.textRequest({ method: 'GET', url: `/api/${session}/channels/${id}/messages/preview`, - params: query, + params: { + ...query, + downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','), + }, }); } diff --git a/src/apps/mcp/tools/chats.tools.ts b/src/apps/mcp/tools/chats.tools.ts index abbe5a35..b8120a21 100644 --- a/src/apps/mcp/tools/chats.tools.ts +++ b/src/apps/mcp/tools/chats.tools.ts @@ -145,7 +145,8 @@ export class ChatTools extends McpController { description: 'Get messages in a chat. ' + 'To retrieve all messages, paginate by incrementing the offset by limit until the returned array is empty or shorter than the limit. ' + - 'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here.', + 'To fetch media for a specific message, use chats-get-message with that message id and downloadMedia=true instead of fetching it here. ' + + 'Use downloadMediaMimetypes to download only media with the given mimetypes (prefix match).', inputSchema: ChatMessagesInput, annotations: { readOnlyHint: true, @@ -162,7 +163,10 @@ export class ChatTools extends McpController { const response = await this.request({ method: 'GET', url: `/api/${session}/chats/${chatId}/messages`, - params: query, + params: { + ...query, + downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','), + }, }); let messages = response.data; if (!_data && Array.isArray(messages)) { @@ -229,7 +233,10 @@ export class ChatTools extends McpController { const result = await this.textRequest({ method: 'GET', url: `/api/${session}/chats/${chatId}/messages/${messageId}`, - params: query, + params: { + ...query, + downloadMediaMimetypes: query.downloadMediaMimetypes?.join(','), + }, }); if (query.downloadMedia) { const mediaKey = await this.mediaApiKey(session); diff --git a/src/config.service.ts b/src/config.service.ts index df86d5ca..2e963513 100644 --- a/src/config.service.ts +++ b/src/config.service.ts @@ -1,7 +1,9 @@ import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { GlobalWebhookConfigConfig } from '@waha/core/config/GlobalWebhookConfig'; +import { MediaDownloadOptions } from '@waha/core/media/IMediaManager'; import { IgnoreJidConfig } from '@waha/core/utils/jids'; +import * as lodash from 'lodash'; import { parseBool } from './helpers'; import { WebhookConfig } from './structures/webhooks.config.dto'; @@ -63,17 +65,50 @@ export class WhatsappConfigService implements OnApplicationBootstrap { } } - get mimetypes(): string[] { - if (!this.shouldDownloadMedia) { - return ['mimetype/ignore-all-media']; - } - const types = this.configService.get('WHATSAPP_FILES_MIMETYPES', ''); - return types ? types.split(',') : []; + private get mediaGlobalParams() { + return { + download: this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true'), + mimetypes: this.configService.get('WHATSAPP_FILES_MIMETYPES', ''), + }; } - get shouldDownloadMedia(): boolean { - const value = this.configService.get('WHATSAPP_DOWNLOAD_MEDIA', 'true'); - return parseBool(value); + private get mediaApiConfig(): MediaDownloadOptions { + const params = { + download: this.configService.get('WAHA_API_DOWNLOAD_MEDIA'), + mimetypes: this.configService.get('WAHA_API_DOWNLOAD_MEDIA_MIMETYPES'), + }; + const config = lodash.defaults({}, params, this.mediaGlobalParams); + return this.parseMediaDownloadOptions(config); + } + + private get mediaEventsConfig(): MediaDownloadOptions { + const params = { + download: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA'), + mimetypes: this.configService.get('WAHA_EVENTS_DOWNLOAD_MEDIA_MIMETYPES'), + }; + const config = lodash.defaults({}, params, this.mediaGlobalParams); + return this.parseMediaDownloadOptions(config); + } + + private parseMediaDownloadOptions(config: { + download: string; + mimetypes: string; + }): MediaDownloadOptions { + const types = config.mimetypes; + const mimetypes = types + ? types.split(',').map((type: string) => type.trim()) + : []; + return { + download: parseBool(config.download), + mimetypes: mimetypes, + }; + } + + get mediaConfig() { + return { + api: this.mediaApiConfig, + events: this.mediaEventsConfig, + }; } get startSessions(): string[] { diff --git a/src/core/abc/session.abc.ts b/src/core/abc/session.abc.ts index 770e0b2f..9eaf6ef7 100644 --- a/src/core/abc/session.abc.ts +++ b/src/core/abc/session.abc.ts @@ -139,7 +139,7 @@ import { AvailableInPlusVersion, NotImplementedByEngineError, } from '../exceptions'; -import { IMediaManager } from '../media/IMediaManager'; +import { IMediaManager, MediaDownloadOptions } from '../media/IMediaManager'; import { QR } from '../QR'; import { DataStore } from './DataStore'; import { fetchBuffer } from '@waha/utils/fetch'; @@ -147,7 +147,6 @@ import { PRESENCE_AUTO_ONLINE, PRESENCE_AUTO_ONLINE_DURATION_SECONDS, } from '@waha/core/env'; -import { Activity } from '@waha/core/abc/activity'; // eslint-disable-next-line @typescript-eslint/no-var-requires const qrcode = require('qrcode-terminal'); @@ -162,6 +161,11 @@ export function ensureSuffix(phone) { return phone + suffix; } +interface MediaConfig { + api: MediaDownloadOptions; + events: MediaDownloadOptions; +} + export interface SessionParams { name: string; printQR: boolean; @@ -174,6 +178,8 @@ export interface SessionParams { engineConfig?: any; // Ignore settings ignore: IgnoreJidConfig; + // Media config + media: MediaConfig; } /** @@ -199,6 +205,7 @@ export abstract class WhatsappSession { protected sessionStore: DataStore; protected proxyConfig?: ProxyConfig; public sessionConfig?: SessionConfig; + protected media: MediaConfig; protected engineConfig?: any; protected unpairing: boolean = false; protected jids: JidFilter; @@ -244,6 +251,7 @@ export abstract class WhatsappSession { sessionConfig, engineConfig, ignore, + media, }: SessionParams) { this._status = WAHASessionStatus.STOPPED; this.status$ = new Subject(); @@ -362,6 +370,17 @@ export abstract class WhatsappSession { 'The session ignores the following chat ids', ); this.jids = new JidFilter(ignore); + + // + // Media options + // + this.media = media; + const mimetypes = this.media.events.mimetypes; + if (mimetypes && mimetypes.length > 0) { + const str = mimetypes.join(','); + const msg = `Only '${str}' mimetypes will be downloaded for the session`; + this.logger.info(msg); + } } public getEventObservable(event: WAHAEvents) { diff --git a/src/core/engines/gows/session.gows.core.ts b/src/core/engines/gows/session.gows.core.ts index af308df4..9525a6fc 100644 --- a/src/core/engines/gows/session.gows.core.ts +++ b/src/core/engines/gows/session.gows.core.ts @@ -222,6 +222,7 @@ import axiosRetry from 'axios-retry'; import * as path from 'path'; import MessageServiceClient = messages.MessageServiceClient; import * as fsp from 'fs/promises'; +import { MediaDownloadOptions } from '@waha/core/media/IMediaManager'; axiosRetry(axios, { retries: 3 }); @@ -586,7 +587,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { msg.Status = MessageStatus.ServerAck; return msg; }), - mergeMap((msg) => this.processIncomingMessage(msg, true)), + mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)), filter(Boolean), // Deduplicate messages by ID to prevent duplicate webhooks // @see https://github.com/devlikeapro/waha/issues/1564 @@ -598,7 +599,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { msg.Status = MessageStatus.DeliveryAck; return msg; }), - mergeMap((msg) => this.processIncomingMessage(msg, true)), + mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)), filter(Boolean), // Deduplicate messages by ID to prevent duplicate webhooks // @see https://github.com/devlikeapro/waha/issues/1564 @@ -2173,7 +2174,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { inviteCode: string, query: PreviewChannelMessages, ): Promise { - const downloadMedia = query.downloadMedia; const request = new messages.GetNewsletterMessagesByInviteRequest({ session: this.session, invite: inviteCode, @@ -2187,12 +2187,17 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { if (!resp.Messages) { return []; } + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const msg of resp.Messages) { promises.push( this.GowsChannelMessageToChannelMessage( resp.NewsletterJID, msg, - downloadMedia, + options, ), ); } @@ -2204,7 +2209,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { private async GowsChannelMessageToChannelMessage( jid: string, channelMessage: any, - downloadMedia: boolean, + options: MediaDownloadOptions, ): Promise { const msg = { Info: { @@ -2217,7 +2222,7 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { }, Message: channelMessage.Message, }; - const message = await this.processIncomingMessage(msg, downloadMedia); + const message = await this.processIncomingMessage(msg, options); const reactions: any = sortObjectByValues(channelMessage.ReactionCounts) || {}; return { @@ -2576,7 +2581,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { query: GetChatMessagesQuery, filter: GetChatMessagesFilter, ) { - const downloadMedia = query.downloadMedia; const merge = query.merge ?? true; let jid: messages.OptionalString; if (chatId === 'all') { @@ -2620,8 +2624,13 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { const response = await promisify(this.client.GetMessages)(request); const msgs = parseJsonList(response); const promises = []; + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const msg of msgs) { - promises.push(this.processIncomingMessage(msg, downloadMedia)); + promises.push(this.processIncomingMessage(msg, options)); } let result = await Promise.all(promises); result = result.filter(Boolean); @@ -2648,7 +2657,12 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { }); const response = await promisify(this.client.GetMessageById)(request); const msg = parseJson(response); - return this.processIncomingMessage(msg, query.downloadMedia); + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); + return this.processIncomingMessage(msg, options); } /** @@ -2812,19 +2826,19 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { return true; } - protected async processIncomingMessage(message, downloadMedia = true) { + protected async processIncomingMessage( + message, + options: MediaDownloadOptions, + ) { // Filter if (!this.shouldProcessIncomingMessage(message)) { return null; } // Convert const wamessage = this.toWAMessage(message); - // Media - if (downloadMedia) { - const media = await this.downloadMediaSafe(message); - wamessage.media = media; - } - if (downloadMedia && wamessage.replyTo?.hasMedia) { + const media = await this.downloadMediaSafe(message, options); + wamessage.media = media; + if (wamessage.replyTo?.hasMedia) { const msg = { Message: wamessage.replyTo._data, Info: { @@ -2832,14 +2846,23 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { ID: wamessage.replyTo.id || '', }, }; - wamessage.replyTo.media = await this.downloadMediaSafe(msg); + wamessage.replyTo.media = await this.downloadMediaSafe(msg, options); } return wamessage; } - protected async downloadMediaSafe(message) { + protected async downloadMediaSafe(message, options: MediaDownloadOptions) { try { - return await this.downloadMedia(message); + let processor: IMediaEngineProcessor = new GOWSEngineMediaProcessor( + this, + ); + processor = new LottieMediaProcessorWrapper(processor, this.logger); + const media = await this.mediaManager.processMedia( + processor, + message, + options, + ); + return media; } catch (e) { this.logger.error('Failed when tried to download media for a message'); this.logger.error(e, e.stack); @@ -2847,19 +2870,6 @@ export class WhatsappSessionGoWSCore extends WhatsappSession { } } - protected async downloadMedia(message) { - let processor: IMediaEngineProcessor = new GOWSEngineMediaProcessor( - this, - ); - processor = new LottieMediaProcessorWrapper(processor, this.logger); - const media = await this.mediaManager.processMedia( - processor, - message, - this.name, - ); - return media; - } - protected toWAMessage(message): WAMessage { const fromToParticipant = getFromToParticipant(message); const id = buildMessageId(message); diff --git a/src/core/engines/noweb/session.noweb.core.ts b/src/core/engines/noweb/session.noweb.core.ts index 7428ae7d..f8495cb1 100644 --- a/src/core/engines/noweb/session.noweb.core.ts +++ b/src/core/engines/noweb/session.noweb.core.ts @@ -72,6 +72,7 @@ import { toVcardV3 } from '@waha/core/vcard'; import { createAgentProxy } from '@waha/core/helpers.proxy'; import type { Agent } from 'https'; import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; +import { MediaDownloadOptions } from '@waha/core/media/IMediaManager'; import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper'; import { QR } from '@waha/core/QR'; import { AckToStatus, StatusToAck } from '@waha/core/utils/acks'; @@ -1549,7 +1550,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { query: GetChatMessagesQuery, filter: GetChatMessagesFilter, ) { - const downloadMedia = query.downloadMedia; const pagination = query as PaginationParams; const merge = query.merge ?? true; const messages = await this.store.getMessagesByJid( @@ -1560,8 +1560,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { ); const promises = []; + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const msg of messages) { - promises.push(this.processIncomingMessage(msg, downloadMedia)); + promises.push(this.processIncomingMessage(msg, options)); } let result = await Promise.all(promises); result = result.filter(Boolean); @@ -1589,7 +1594,12 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { merge, ); if (!message) return null; - return await this.processIncomingMessage(message, query.downloadMedia); + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); + return await this.processIncomingMessage(message, options); } @Activity() @@ -2510,7 +2520,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { inviteCode: string, query: PreviewChannelMessages, ): Promise { - const downloadMedia = query.downloadMedia; const updates = await this.sock.newsletterFetchPreviewMessages( 'invite', inviteCode, @@ -2518,9 +2527,14 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { null, ); const promises = []; + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const update of updates) { promises.push( - this.NewsletterFetchedUpdateToChannelMessage(update, downloadMedia), + this.NewsletterFetchedUpdateToChannelMessage(update, options), ); } let result = await Promise.all(promises); @@ -2530,16 +2544,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { private async NewsletterFetchedUpdateToChannelMessage( update: NewsletterFetchedUpdate, - downloadMedia: boolean, + options: MediaDownloadOptions, ): Promise { let reactions: any = Object.fromEntries( update.reactions.map(({ code, count }) => [code, count]), ); reactions = sortObjectByValues(reactions) || {}; - const message = await this.processIncomingMessage( - update.message, - downloadMedia, - ); + const message = await this.processIncomingMessage(update.message, options); return { message: message, reactions: reactions, @@ -2674,13 +2685,13 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { isMine, ); messagesFromMe$ = messagesFromMe$.pipe( - mergeMap((msg) => this.processIncomingMessage(msg, true)), + mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)), filter(Boolean), DistinctMessages(), share(), // share it so we don't process twice in message.any ); messagesFromOthers$ = messagesFromOthers$.pipe( - mergeMap((msg) => this.processIncomingMessage(msg, true)), + mergeMap((msg) => this.processIncomingMessage(msg, this.media.events)), filter(Boolean), DistinctMessages(), share(), // share it so we don't process twice in message.any @@ -3230,7 +3241,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { protected async processIncomingMessage( message, - downloadMedia: boolean, + options: MediaDownloadOptions, ): Promise { // Filter if (!this.shouldProcessIncomingMessage(message)) { @@ -3242,11 +3253,9 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { return null; } // Media - if (downloadMedia && wamessage.hasMedia) { - wamessage.media = await this.downloadMediaSafe(message); - } + wamessage.media = await this.downloadMediaSafe(message, options); - if (downloadMedia && wamessage.replyTo?.hasMedia) { + if (wamessage.replyTo?.hasMedia) { const mediaContent = extractMediaContent(wamessage.replyTo._data); const m = { message: wamessage.replyTo._data, @@ -3259,7 +3268,7 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { remoteJid: message.key.remoteJid, }, }; - wamessage.replyTo.media = await this.downloadMediaSafe(m); + wamessage.replyTo.media = await this.downloadMediaSafe(m, options); } return wamessage; } @@ -3514,9 +3523,17 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { return { id: chatId, presences: presences }; } - protected async downloadMediaSafe(message): Promise { + protected async downloadMediaSafe( + message, + options: MediaDownloadOptions, + ): Promise { try { - return await this.downloadMedia(message); + let processor: IMediaEngineProcessor = new NOWEBEngineMediaProcessor( + this, + this.loggerBuilder, + ); + processor = new LottieMediaProcessorWrapper(processor, this.logger); + return await this.mediaManager.processMedia(processor, message, options); } catch (e) { this.logger.error('Failed when tried to download media for a message'); this.logger.error(e, e.stack); @@ -3524,15 +3541,6 @@ export class WhatsappSessionNoWebCore extends WhatsappSession { return null; } - protected async downloadMedia(message): Promise { - let processor: IMediaEngineProcessor = new NOWEBEngineMediaProcessor( - this, - this.loggerBuilder, - ); - processor = new LottieMediaProcessorWrapper(processor, this.logger); - return this.mediaManager.processMedia(processor, message, this.name); - } - protected async getMessageOptions(request: { id?: string; chatId: string; diff --git a/src/core/engines/webjs/session.webjs.core.ts b/src/core/engines/webjs/session.webjs.core.ts index b743c114..774082d2 100644 --- a/src/core/engines/webjs/session.webjs.core.ts +++ b/src/core/engines/webjs/session.webjs.core.ts @@ -40,6 +40,7 @@ import { WAMimeType } from '@waha/core/media/WAMimeType'; import { detectMimetype } from '@waha/utils/files'; import { NotImplementedByEngineError } from '@waha/core/exceptions'; import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; +import { MediaDownloadOptions } from '@waha/core/media/IMediaManager'; import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper'; import { QR } from '@waha/core/QR'; import { StatusToAck } from '@waha/core/utils/acks'; @@ -1337,7 +1338,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { ); } - const downloadMedia = query.downloadMedia; // Test there's chat with id await this.whatsapp.getChatById(this.ensureSuffix(chatId)); const pagination: PaginationParams = query; @@ -1347,8 +1347,13 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { pagination, ); const promises = []; + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const msg of messages) { - promises.push(this.processIncomingMessage(msg, downloadMedia)); + promises.push(this.processIncomingMessage(msg, options)); } let result = await Promise.all(promises); result = result.filter(Boolean); @@ -1430,7 +1435,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { return null; }); } - return await this.processIncomingMessage(message, query.downloadMedia); + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); + return await this.processIncomingMessage(message, options); } @Activity() @@ -1964,21 +1974,24 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { query.limit, ); const promises = []; + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); for (const msg of channelMessages) { - promises.push( - this.WebjsChannelMessageToChannelMessage(msg, query.downloadMedia), - ); + promises.push(this.WebjsChannelMessageToChannelMessage(msg, options)); } return await Promise.all(promises); } private async WebjsChannelMessageToChannelMessage( channelMessage: WebjsChannelMessage, - downloadMedia: boolean, + options: MediaDownloadOptions, ): Promise { const message = await this.processIncomingMessage( channelMessage.message, - downloadMedia, + options, ); const reactions = {}; for (const reaction of channelMessage.reactions.sort((x) => -x.count)) { @@ -2292,7 +2305,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { const messageReceived$ = fromEvent(this.whatsapp, Events.MESSAGE_RECEIVED); const messagesFromOthers$ = messageReceived$.pipe( filter((msg: Message) => this.jids.include(msg?.id?.remote)), - mergeMap((msg: any) => this.processIncomingMessage(msg, true)), + mergeMap((msg: any) => + this.processIncomingMessage(msg, this.media.events), + ), share(), ); this.events2.get(WAHAEvents.MESSAGE).switch(messagesFromOthers$); @@ -2300,7 +2315,9 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { const messageCreate$ = fromEvent(this.whatsapp, Events.MESSAGE_CREATE); const messagesFromAll$ = messageCreate$.pipe( filter((msg: Message) => this.jids.include(msg?.id?.remote)), - mergeMap((msg: any) => this.processIncomingMessage(msg, true)), + mergeMap((msg: any) => + this.processIncomingMessage(msg, this.media.events), + ), share(), ); this.events2.get(WAHAEvents.MESSAGE_ANY).switch(messagesFromAll$); @@ -2311,7 +2328,12 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { ); const messagesWaiting$ = messageCiphertext$.pipe( filter((msg: Message) => this.jids.include(msg?.id?.remote)), - mergeMap((msg: any) => this.processIncomingMessage(msg, false)), + mergeMap((msg: any) => + this.processIncomingMessage(msg, { + ...this.media.events, + download: false, + }), + ), share(), ); this.events2.get(WAHAEvents.MESSAGE_WAITING).switch(messagesWaiting$); @@ -2543,19 +2565,20 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { protected async processIncomingMessage( message: Message, - downloadMedia = true, + options: MediaDownloadOptions, ) { // Convert const wamessage = this.toWAMessage(message); // Media - if (downloadMedia) { - const media = await this.downloadMediaSafe(message); - wamessage.media = media; - } - if (downloadMedia && wamessage.replyTo?.hasMedia) { + const media = await this.downloadMediaSafe(message, options); + wamessage.media = media; + if (wamessage.replyTo?.hasMedia) { const quotedMessage = await message.getQuotedMessage().catch(() => null); if (quotedMessage) { - wamessage.replyTo.media = await this.downloadMediaSafe(quotedMessage); + wamessage.replyTo.media = await this.downloadMediaSafe( + quotedMessage, + options, + ); } } return wamessage; @@ -2773,9 +2796,19 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { return contact; } - protected async downloadMediaSafe(message): Promise { + protected async downloadMediaSafe( + message: Message, + options: MediaDownloadOptions, + ): Promise { try { - return await this.downloadMedia(message); + let processor = new WEBJSEngineMediaProcessor(); + processor = new LottieMediaProcessorWrapper(processor, this.logger); + const media = await this.mediaManager.processMedia( + processor, + message, + options, + ); + return media; } catch (e) { this.logger.error('Failed when tried to download media for a message'); this.logger.error(e, e.stack); @@ -2783,17 +2816,6 @@ export class WhatsappSessionWebJSCore extends WhatsappSession { return null; } - protected async downloadMedia(message: Message) { - let processor = new WEBJSEngineMediaProcessor(); - processor = new LottieMediaProcessorWrapper(processor, this.logger); - const media = await this.mediaManager.processMedia( - processor, - message, - this.name, - ); - return media; - } - protected getMessageOptions(request: any): any { let mentions = request.mentions; mentions = mentions ? mentions.map(this.ensureSuffix) : undefined; diff --git a/src/core/engines/wpp/session.wpp.core.ts b/src/core/engines/wpp/session.wpp.core.ts index 7d1b0866..09870008 100644 --- a/src/core/engines/wpp/session.wpp.core.ts +++ b/src/core/engines/wpp/session.wpp.core.ts @@ -132,6 +132,7 @@ import { } from '@waha/core/engines/wpp/WppTypes'; import { NotImplementedByEngineError } from '@waha/core/exceptions'; import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; +import { MediaDownloadOptions } from '@waha/core/media/IMediaManager'; import { LottieMediaProcessorWrapper } from '@waha/core/media/LottieMediaProcessorWrapper'; import { IWPPAuthManager } from '@waha/core/engines/wpp/IWPPAuthManager'; import { QR } from '@waha/core/QR'; @@ -1103,7 +1104,6 @@ export class WhatsappSessionWPPCore extends WhatsappSession { const offset = query?.offset || 0; const limit = query?.limit || 10; const fetchCount = offset + limit; - const downloadMedia = query.downloadMedia; const rawMessages = await this.wpp!.getMessages(id, { count: fetchCount, direction: 'before', @@ -1127,10 +1127,11 @@ export class WhatsappSessionWPPCore extends WhatsappSession { sortOrder: query?.sortOrder || SortOrder.DESC, }).apply(messages); - if (!downloadMedia) { - return messages; - } - + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); const promises = []; for (const message of messages) { const rawMessage = messagesById.get(message.id); @@ -1138,7 +1139,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { promises.push(Promise.resolve(message)); continue; } - promises.push(this.processIncomingMessage(rawMessage, true)); + promises.push(this.processIncomingMessage(rawMessage, options)); } let result = await Promise.all(promises); result = result.filter(Boolean); @@ -1155,7 +1156,12 @@ export class WhatsappSessionWPPCore extends WhatsappSession { if (!message) { return null; } - return this.processIncomingMessage(message, query.downloadMedia); + const params = { + download: query.downloadMedia, + mimetypes: query.downloadMediaMimetypes, + }; + const options = lodash.defaults({}, params, this.media.api); + return this.processIncomingMessage(message, options); } @Activity() @@ -2429,27 +2435,25 @@ export class WhatsappSessionWPPCore extends WhatsappSession { }; } - protected async processIncomingMessage(message: any, downloadMedia = true) { + protected async processIncomingMessage( + message: any, + options: MediaDownloadOptions, + ) { const wamessage = this.toWAMessage(message); - if (downloadMedia) { - const media = await this.downloadMediaSafe(message); - wamessage.media = media; - } - if (downloadMedia && wamessage.replyTo?.hasMedia) { + const media = await this.downloadMediaSafe(message, options); + wamessage.media = media; + if (wamessage.replyTo?.hasMedia) { const quotedMessage = message?.quotedMsg || message?._data?.quotedMsg; if (quotedMessage) { - wamessage.replyTo.media = await this.downloadMediaSafe(quotedMessage); + wamessage.replyTo.media = await this.downloadMediaSafe( + quotedMessage, + options, + ); } } return wamessage; } - protected async downloadMedia(message: any): Promise { - let processor = new WPPEngineMediaProcessor(this.wpp); - processor = new LottieMediaProcessorWrapper(processor, this.logger); - return this.mediaManager.processMedia(processor, message, this.name); - } - protected checkStatusRequest(request: { contacts?: any[] }) { if (request.contacts && request.contacts?.length > 0) { const msg = @@ -2458,9 +2462,14 @@ export class WhatsappSessionWPPCore extends WhatsappSession { } } - protected async downloadMediaSafe(message): Promise { + protected async downloadMediaSafe( + message: any, + options: MediaDownloadOptions, + ): Promise { try { - return await this.downloadMedia(message); + let processor = new WPPEngineMediaProcessor(this.wpp); + processor = new LottieMediaProcessorWrapper(processor, this.logger); + return await this.mediaManager.processMedia(processor, message, options); } catch (error) { this.logger.error('Failed when tried to download media for a message'); this.logger.error(error, error.stack); @@ -2523,7 +2532,7 @@ export class WhatsappSessionWPPCore extends WhatsappSession { if (msg?.fromMe) { await sleep(3_000); } - return this.processIncomingMessage(msg, true); + return this.processIncomingMessage(msg, this.media.events); } private async refreshMeInfo() { diff --git a/src/core/manager.core.ts b/src/core/manager.core.ts index 37ed6710..09e02ad4 100644 --- a/src/core/manager.core.ts +++ b/src/core/manager.core.ts @@ -344,8 +344,8 @@ export class SessionManagerCore ); await storage.init(); const mediaManager = new MediaManager( + name, storage, - this.config.mimetypes, loggerBuilder.child({ name: 'MediaManager' }), ); const webhook = new WebhookConductor(loggerBuilder); @@ -359,6 +359,7 @@ export class SessionManagerCore proxyConfig: proxyConfig, sessionConfig: config, ignore: this.ignoreChatsConfig(config), + media: this.config.mediaConfig, }; if (this.EngineClass === WhatsappSessionWebJSCore) { sessionConfig.engineConfig = this.webjsEngineConfigService.getConfig(); diff --git a/src/core/media/IMediaManager.ts b/src/core/media/IMediaManager.ts index 944e4f9f..8ae2ac7d 100644 --- a/src/core/media/IMediaManager.ts +++ b/src/core/media/IMediaManager.ts @@ -1,17 +1,20 @@ import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; import { WAMedia } from '@waha/structures/media.dto'; +export interface MediaDownloadOptions { + download: boolean; + mimetypes: string[]; +} + /** * General interface for MediaManager - one that handles the logic * and manipulates MediaStorage and MediaEngineProcessor */ -interface IMediaManager { +export interface IMediaManager { processMedia( processor: IMediaEngineProcessor, message: Message, - session: string, + options: MediaDownloadOptions, ): Promise; close(): void; } - -export { IMediaManager }; diff --git a/src/core/media/MediaManager.ts b/src/core/media/MediaManager.ts index 9af0b01c..98b2d83d 100644 --- a/src/core/media/MediaManager.ts +++ b/src/core/media/MediaManager.ts @@ -1,5 +1,8 @@ import { IMediaEngineProcessor } from '@waha/core/media/IMediaEngineProcessor'; -import { IMediaManager } from '@waha/core/media/IMediaManager'; +import { + IMediaManager, + MediaDownloadOptions, +} from '@waha/core/media/IMediaManager'; import { IMediaStorage, MediaData, @@ -22,52 +25,38 @@ export class MediaManager implements IMediaManager { }; constructor( + private sessionName: string, private storage: IMediaStorage, - private mimetypes: string[], protected log: Logger, - ) { - // Log mimetypes - if (this.mimetypes && this.mimetypes.length > 0) { - const mimetypes = this.mimetypes.join(','); - const msg = `Only '${mimetypes}' mimetypes will be downloaded for the session`; - this.log.info(msg); - } - } + ) {} /** * Check that we need to download files with the mimetype */ - private shouldProcessMimetype(mimetype: string) { + private shouldProcessMimetype(mimetypes: string[], mimetype: string) { // No specific mimetypes provided - always download - if (!this.mimetypes || this.mimetypes.length === 0) { + if (!mimetypes || mimetypes.length === 0) { return true; } // Found "right" mimetype in the list of allowed mimetypes - download it - return this.mimetypes.some((type) => mimetype.startsWith(type)); + return mimetypes.some((type) => mimetype.startsWith(type)); } private async processMediaInternal( processor: IMediaEngineProcessor, message: Message, - session: string, ): Promise { const messageId = processor.getMessageId(message); const chatId = processor.getChatId(message); const mimetype = processor.getMimetype(message); const filename = processor.getFilename(message); - if (!this.shouldProcessMimetype(mimetype)) { - this.log.info( - `The message '${messageId}' has '${mimetype}' mimetype media, skip it.`, - ); - return null; - } let extension = mime.extension(mimetype); if (mimetype == 'application/was' && !extension) { extension = 'zip'; } const mediaData: MediaData = { - session: session, + session: this.sessionName, message: { id: messageId, chatId: chatId, @@ -105,7 +94,7 @@ export class MediaManager implements IMediaManager { async processMedia( processor: IMediaEngineProcessor, message: Message, - session: string, + options: MediaDownloadOptions, ): Promise { let messageId: string; try { @@ -129,7 +118,16 @@ export class MediaManager implements IMediaManager { try { media.filename = processor.getFilename(message); media.mimetype = processor.getMimetype(message); - const data = await this.processMediaInternal(processor, message, session); + if (!options.download) { + return media; + } + if (!this.shouldProcessMimetype(options.mimetypes, media.mimetype)) { + this.log.info( + `The message '${messageId}' has '${media.mimetype}' mimetype media, skip it.`, + ); + return media; + } + const data = await this.processMediaInternal(processor, message); media = { ...media, ...data }; } catch (err) { this.log.error(err, `Error processing media for message '${messageId}'`); diff --git a/src/nestjs/validation/CommaSeparatedStrings.test.ts b/src/nestjs/validation/CommaSeparatedStrings.test.ts new file mode 100644 index 00000000..73b1c957 --- /dev/null +++ b/src/nestjs/validation/CommaSeparatedStrings.test.ts @@ -0,0 +1,45 @@ +import { CommaSeparatedStrings } from './CommaSeparatedStrings'; + +describe('CommaSeparatedStrings', () => { + it('should convert a single value to an array', () => { + // ?a=image/jpeg + expect(CommaSeparatedStrings({ value: 'image/jpeg' })).toEqual([ + 'image/jpeg', + ]); + }); + + it('should keep the repeated form as is', () => { + // ?a=image/jpeg&a=image/png + expect( + CommaSeparatedStrings({ value: ['image/jpeg', 'image/png'] }), + ).toEqual(['image/jpeg', 'image/png']); + }); + + it('should split comma separated values', () => { + // ?a=image/jpeg,image/png + expect(CommaSeparatedStrings({ value: 'image/jpeg,image/png' })).toEqual([ + 'image/jpeg', + 'image/png', + ]); + }); + + it('should split comma separated values in the repeated form', () => { + // ?a=image/jpeg,image/png&a=video/mp4 + expect( + CommaSeparatedStrings({ value: ['image/jpeg,image/png', 'video/mp4'] }), + ).toEqual(['image/jpeg', 'image/png', 'video/mp4']); + }); + + it('should trim values and remove empty ones', () => { + // ?a=image/jpeg, image/png, + expect(CommaSeparatedStrings({ value: 'image/jpeg, image/png,' })).toEqual([ + 'image/jpeg', + 'image/png', + ]); + }); + + it('should keep null and undefined as is', () => { + expect(CommaSeparatedStrings({ value: null })).toBeNull(); + expect(CommaSeparatedStrings({ value: undefined })).toBeUndefined(); + }); +}); diff --git a/src/nestjs/validation/CommaSeparatedStrings.ts b/src/nestjs/validation/CommaSeparatedStrings.ts new file mode 100644 index 00000000..6844f042 --- /dev/null +++ b/src/nestjs/validation/CommaSeparatedStrings.ts @@ -0,0 +1,20 @@ +/** + * Convert a query param to a list of strings. + * Accepts: + * - single value: ?a=x => ['x'] + * - repeated form: ?a=x&a=y => ['x', 'y'] + * - comma separated form: ?a=x,y => ['x', 'y'] + * Values are trimmed, empty ones are removed. + * @param value + * @constructor + */ +export function CommaSeparatedStrings({ value }: { value: any }) { + if (value == null) { + return value; + } + const values = Array.isArray(value) ? value : [value]; + return values + .flatMap((item: string) => String(item).split(',')) + .map((item: string) => item.trim()) + .filter(Boolean); +} diff --git a/src/structures/channels.dto.ts b/src/structures/channels.dto.ts index 7dca65a1..2d3aab18 100644 --- a/src/structures/channels.dto.ts +++ b/src/structures/channels.dto.ts @@ -1,5 +1,6 @@ import { ApiParam, ApiProperty, getSchemaPath } from '@nestjs/swagger'; import { BooleanString } from '@waha/nestjs/validation/BooleanString'; +import { CommaSeparatedStrings } from '@waha/nestjs/validation/CommaSeparatedStrings'; import { BinaryFile, RemoteFile } from '@waha/structures/files.dto'; import { WAMessage } from '@waha/structures/responses.dto'; import { Transform, Type } from 'class-transformer'; @@ -193,6 +194,18 @@ export class PreviewChannelMessages { @IsBoolean() downloadMedia: boolean = false; + @ApiProperty({ + type: String, + isArray: true, + required: false, + description: 'Download only media with these mimetypes (prefix match)', + }) + @Transform(CommaSeparatedStrings) + @IsArray() + @IsString({ each: true }) + @IsOptional() + downloadMediaMimetypes?: string[]; + @IsNumber() @Type(() => Number) limit: number = 10; diff --git a/src/structures/chats.dto.ts b/src/structures/chats.dto.ts index cbdbb389..07e099f3 100644 --- a/src/structures/chats.dto.ts +++ b/src/structures/chats.dto.ts @@ -1,6 +1,7 @@ import { BadRequestException } from '@nestjs/common'; import { ApiProperty } from '@nestjs/swagger'; import { BooleanString } from '@waha/nestjs/validation/BooleanString'; +import { CommaSeparatedStrings } from '@waha/nestjs/validation/CommaSeparatedStrings'; import { WAMessageAck, WAMessageAckName } from '@waha/structures/enums.dto'; import { LimitOffsetParams, @@ -113,14 +114,25 @@ export class GetChatMessagesQuery extends PaginationParams { sortBy?: string = MessageSortField.TIMESTAMP; @ApiProperty({ - example: false, required: false, description: 'Download media for messages', }) @Transform(BooleanString) @IsBoolean() @IsOptional() - downloadMedia: boolean = true; + downloadMedia?: boolean; + + @ApiProperty({ + type: String, + isArray: true, + required: false, + description: 'Download only media with these mimetypes (prefix match)', + }) + @Transform(CommaSeparatedStrings) + @IsArray() + @IsString({ each: true }) + @IsOptional() + downloadMediaMimetypes?: string[]; @ApiProperty({ example: true, @@ -165,14 +177,25 @@ export class ReadChatMessagesResponse { export class GetChatMessageQuery { @ApiProperty({ - example: true, required: false, description: 'Download media for messages', }) @Transform(BooleanString) @IsBoolean() @IsOptional() - downloadMedia: boolean = true; + downloadMedia?: boolean; + + @ApiProperty({ + type: String, + isArray: true, + required: false, + description: 'Download only media with these mimetypes (prefix match)', + }) + @Transform(CommaSeparatedStrings) + @IsArray() + @IsString({ each: true }) + @IsOptional() + downloadMediaMimetypes?: string[]; @ApiProperty({ example: true, @@ -256,7 +279,7 @@ export class OverviewFilter { @IsOptional() @IsArray() @IsString({ each: true }) - @Transform(({ value }) => (Array.isArray(value) ? value : [value])) + @Transform(CommaSeparatedStrings) @ApiProperty({ description: 'Filter by chat ids', required: false,