FEAT: Implementa download e armazenamento de mídia recebida via webhook

- 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
This commit is contained in:
Rafael Alves Lopes 2026-07-21 15:52:49 -03:00
parent c988a84f97
commit 3f1535b960

View File

@ -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 };
}
}