From 3f1535b9605e3d1bd5d6b473657dfd0f7ebe135b Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Tue, 21 Jul 2026 15:52:49 -0300 Subject: [PATCH] =?UTF-8?q?FEAT:=20Implementa=20download=20e=20armazenamen?= =?UTF-8?q?to=20de=20m=C3=ADdia=20recebida=20via=20webhook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Detecta mensagens de mídia (image, audio, video, document, sticker) - downloadMetaMedia() busca URL temporária na Meta e baixa o binário - Salva via MediaStorageService antes de persistir e emitir - Socket.IO passa mediaUrl e mediaMimeType ao invés de media: null - Falha no download loga erro mas não bloqueia o salvamento da mensagem --- src/modules/whatsapp/whatsapp.service.ts | 67 +++++++++++++++++++++--- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 18d3810..e19b86c 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -5,6 +5,9 @@ import { ContactsService } from '../contacts/contacts.service'; import { WhatsappTemplateService } from './whatsapp-template.service'; import { MensagensRepository } from './repositories/mensagens.repository'; import { WhatsappConfigService } from './whatsapp-config.service'; +import { MediaStorageService } from './media/media-storage.service'; + +const MEDIA_TYPES = ['image', 'audio', 'video', 'document', 'sticker'] as const; @Injectable() export class WhatsappService { @@ -19,6 +22,7 @@ export class WhatsappService { private readonly whatsappTemplateService: WhatsappTemplateService, private readonly mensagensRepository: MensagensRepository, private readonly configService: WhatsappConfigService, + private readonly mediaStorageService: MediaStorageService, ) {} private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) { @@ -310,6 +314,7 @@ export class WhatsappService { const from: string = message.from; const messageId: string = message.id; const timestamp: number = parseInt(message.timestamp, 10); + const messageType: string = message.type ?? 'text'; const messageBody: string = message.text?.body?.trim() ?? ''; const contact = contacts.find((c) => c.wa_id === from); @@ -322,16 +327,35 @@ export class WhatsappService { this.processedIncomingMessages.delete(oldest); } - this.logger.log(`[Meta] Mensagem recebida de ${from} (${contactName}): ${messageBody || '[mídia]'}`); + this.logger.log(`[Meta] Mensagem recebida de ${from} (${contactName}): ${messageBody || `[${messageType}]`}`); + + let mediaPath: string | undefined; + let mediaMimeType: string | undefined; + + if ((MEDIA_TYPES as readonly string[]).includes(messageType)) { + const mediaObj = message[messageType]; + if (mediaObj?.id) { + try { + const { buffer, mimeType } = await this.downloadMetaMedia(mediaObj.id, mediaObj.mime_type); + mediaPath = await this.mediaStorageService.upload(buffer, mimeType); + mediaMimeType = mimeType; + this.logger.log(`[Meta] Mídia salva: ${mediaPath}`); + } catch (err) { + this.logger.error(`[Meta] Falha ao baixar mídia ${mediaObj.id} de ${from}:`, err); + } + } + } await this.mensagensRepository.save({ wamid: messageId, chatId: from, fromMe: false, - type: message.type ?? 'text', + type: messageType, body: messageBody || undefined, senderName: contactName, timestamp, + mediaPath, + mediaMimeType, }); this.gateway.emitNewMessage({ @@ -342,16 +366,17 @@ export class WhatsappService { id: messageId, fromMe: false, notifyName: contactName, - hasMedia: message.type !== 'text', - media: null, + hasMedia: !!mediaPath, + mediaUrl: mediaPath ? this.mediaStorageService.getUrl(mediaPath) : null, + mediaMimeType: mediaMimeType ?? null, }); - if (messageBody) { + if (messageBody || mediaPath) { await this.assignmentService.markCustomerReplied(from); } if (!messageBody) { - this.logger.log(`[Meta] Triagem ignorada para ${from}: mensagem sem texto.`); + this.logger.log(`[Meta] Triagem ignorada para ${from}: mensagem de mídia sem texto.`); return; } @@ -365,4 +390,34 @@ export class WhatsappService { await this.enqueueIncomingRoute(from, fakeMsg, messageBody, messageId); } + private async downloadMetaMedia(mediaId: string, webhookMimeType?: string): Promise<{ buffer: Buffer; mimeType: string }> { + const { access_token: token, api_version: apiVersion } = await this.configService.getConfig(); + + if (!token) throw new Error('Token do WhatsApp não configurado.'); + + const metaResponse = await fetch( + `https://graph.facebook.com/${apiVersion}/${mediaId}`, + { headers: { Authorization: `Bearer ${token}` } }, + ); + + if (!metaResponse.ok) { + throw new Error(`Meta API erro ao buscar URL de mídia ${mediaId}: ${metaResponse.status}`); + } + + const metaData = await metaResponse.json() as any; + const downloadUrl: string = metaData.url; + const mimeType: string = webhookMimeType || metaData.mime_type || 'application/octet-stream'; + + const fileResponse = await fetch(downloadUrl, { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (!fileResponse.ok) { + throw new Error(`Falha ao baixar arquivo de mídia: ${fileResponse.status}`); + } + + const arrayBuffer = await fileResponse.arrayBuffer(); + return { buffer: Buffer.from(arrayBuffer), mimeType }; + } + }