From 30ec6e4b5ea6701fe69475ea0d13ed340b7e928e Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Wed, 22 Jul 2026 16:17:35 -0300 Subject: [PATCH 1/2] =?UTF-8?q?FEAT:=20Adiciona=20endpoint=20POST=20/whats?= =?UTF-8?q?app/send-media=20para=20envio=20de=20m=C3=ADdia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upload do arquivo para Meta API (multipart) para obter media_id - Armazenamento local via MediaStorageService antes do envio - Suporte a image, audio, video e document via mimeTypeToMediaType - Salva mensagem no banco com media_path e media_mime_type - Emite via Socket.IO para atualizar o chat em tempo real --- src/modules/whatsapp/whatsapp.controller.ts | 18 +++- src/modules/whatsapp/whatsapp.service.ts | 103 ++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 4866310..8b4c1f7 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -1,4 +1,5 @@ -import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query } from '@nestjs/common'; +import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { WhatsappService } from './whatsapp.service'; import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service'; @@ -64,6 +65,21 @@ export class WhatsappController { return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName); } + @ApiOperation({ summary: 'Envia mídia no WhatsApp' }) + @ApiResponse({ status: 201, description: 'Mídia enviada.' }) + @Post('send-media') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024 } })) + async sendMediaMessage( + @UploadedFile() file: Express.Multer.File, + @Body('to') to: string, + @Body('senderName') senderName?: string, + @Body('caption') caption?: string, + ) { + if (!file) throw new BadRequestException('Arquivo não enviado.'); + if (!to) throw new BadRequestException('Destinatário (to) obrigatório.'); + return this.whatsappService.sendMediaMessage(to, file.buffer, file.mimetype, senderName, caption); + } + @ApiOperation({ summary: 'Inicia atendimento ativo' }) @ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' }) @Post('start-attendance') diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index ea7dce2..c94e433 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -100,6 +100,109 @@ export class WhatsappService { throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.'); } + async sendMediaMessage( + to: string, + buffer: Buffer, + mimeType: string, + senderName?: string, + caption?: string, + ): Promise { + const { access_token: token, phone_number_id: phoneNumberId, api_version: apiVersion } = + await this.configService.getConfig(); + + if (!token || !phoneNumberId) { + throw new Error('Token e Phone Number ID do WhatsApp não configurados. Acesse Admin → Canais e Integração → WhatsApp → Configurar.'); + } + + const mediaPath = await this.mediaStorageService.upload(buffer, mimeType); + const mediaType = this.mimeTypeToMediaType(mimeType); + + const form = new FormData(); + form.append('file', new Blob([buffer], { type: mimeType }), `media`); + form.append('type', mimeType); + form.append('messaging_product', 'whatsapp'); + + const uploadResponse = await fetch( + `https://graph.facebook.com/${apiVersion}/${phoneNumberId}/media`, + { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: form }, + ); + + if (!uploadResponse.ok) { + const error = await uploadResponse.json().catch(() => ({})); + throw new Error(`Meta API erro ao fazer upload de mídia: ${JSON.stringify(error)}`); + } + + const { id: mediaId } = await uploadResponse.json() as any; + + const mediaPayload: any = { id: mediaId }; + if (caption && mediaType !== 'audio') mediaPayload.caption = senderName ? `*Atendente: ${senderName}*\n\n${caption}` : caption; + + const messageResponse = await fetch( + `https://graph.facebook.com/${apiVersion}/${phoneNumberId}/messages`, + { + method: 'POST', + headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, + body: JSON.stringify({ + messaging_product: 'whatsapp', + recipient_type: 'individual', + to, + type: mediaType, + [mediaType]: mediaPayload, + }), + }, + ); + + if (!messageResponse.ok) { + const error = await messageResponse.json().catch(() => ({})); + throw new Error(`Meta API erro ao enviar mídia para ${to}: ${JSON.stringify(error)}`); + } + + const messageResult = await messageResponse.json() as any; + const wamid: string | undefined = messageResult?.messages?.[0]?.id; + this.logger.log(`[Meta] Mídia (${mediaType}) enviada para ${to}: ${wamid}`); + + const senderLabel = senderName ?? 'Sistema'; + const bodyText = caption ? (senderName ? `*Atendente: ${senderLabel}*\n\n${caption}` : caption) : `[${mediaType}]`; + const timestamp = Math.floor(Date.now() / 1000); + + if (wamid) { + await this.mensagensRepository.save({ + wamid, + chatId: to, + fromMe: true, + type: mediaType, + body: bodyText, + senderName: senderLabel, + status: 'sent', + timestamp, + mediaPath, + mediaMimeType: mimeType, + }); + } + + this.gateway.emitNewMessage({ + from: to, + body: bodyText, + timestamp, + isGroupMsg: false, + id: wamid || '', + fromMe: true, + notifyName: senderLabel, + hasMedia: true, + mediaUrl: this.mediaStorageService.getUrl(mediaPath), + mediaMimeType: mimeType, + }); + + return messageResult; + } + + private mimeTypeToMediaType(mimeType: string): string { + if (mimeType.startsWith('image/')) return 'image'; + if (mimeType.startsWith('audio/')) return 'audio'; + if (mimeType.startsWith('video/')) return 'video'; + return 'document'; + } + private async getCustomerContact(chatId: string) { try { const contact = await this.contactsService.getContact(chatId); From 97a12d700a53f1b986538108b37585f35e933596 Mon Sep 17 00:00:00 2001 From: Rafael Lopes Date: Wed, 22 Jul 2026 16:28:36 -0300 Subject: [PATCH 2/2] =?UTF-8?q?FEAT:=20Adiciona=20valida=C3=A7=C3=B5es=20d?= =?UTF-8?q?e=20tipo=20e=20tamanho=20no=20envio=20de=20m=C3=ADdia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fileFilter no Multer rejeita tipos não suportados antes de bufferizar - Limite por tipo: imagem 5 MB, áudio/vídeo 16 MB, documento 100 MB - Erro retornado ao frontend em caso de violação --- src/modules/whatsapp/whatsapp.controller.ts | 39 ++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 8b4c1f7..880ab7e 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -1,5 +1,23 @@ import { Controller, Get, Post, Body, Delete, Param, ForbiddenException, Query, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; + +const ALLOWED_MEDIA_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/gif', + 'audio/mpeg', 'audio/mp4', 'audio/ogg', 'audio/wav', 'audio/aac', 'audio/amr', + 'video/mp4', 'video/3gpp', + 'application/pdf', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/msword', + 'application/vnd.ms-excel', +]); + +const MAX_SIZE_MB: Record = { + image: 5, + audio: 16, + video: 16, + document: 100, +}; import { ApiBody, ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger'; import { WhatsappService } from './whatsapp.service'; import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service'; @@ -68,7 +86,16 @@ export class WhatsappController { @ApiOperation({ summary: 'Envia mídia no WhatsApp' }) @ApiResponse({ status: 201, description: 'Mídia enviada.' }) @Post('send-media') - @UseInterceptors(FileInterceptor('file', { limits: { fileSize: 100 * 1024 * 1024 } })) + @UseInterceptors(FileInterceptor('file', { + limits: { fileSize: 100 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (ALLOWED_MEDIA_TYPES.has(file.mimetype)) { + cb(null, true); + } else { + cb(new BadRequestException(`Tipo de arquivo não suportado: ${file.mimetype}`), false); + } + }, + })) async sendMediaMessage( @UploadedFile() file: Express.Multer.File, @Body('to') to: string, @@ -77,6 +104,16 @@ export class WhatsappController { ) { if (!file) throw new BadRequestException('Arquivo não enviado.'); if (!to) throw new BadRequestException('Destinatário (to) obrigatório.'); + + const mediaType = file.mimetype.startsWith('image/') ? 'image' + : file.mimetype.startsWith('audio/') ? 'audio' + : file.mimetype.startsWith('video/') ? 'video' + : 'document'; + const limitMB = MAX_SIZE_MB[mediaType]; + if (file.size > limitMB * 1024 * 1024) { + throw new BadRequestException(`Arquivo muito grande. Limite para ${mediaType}: ${limitMB} MB.`); + } + return this.whatsappService.sendMediaMessage(to, file.buffer, file.mimetype, senderName, caption); }