FEAT: Criação do endpoint POST para envio de media #1

Merged
rafael.lopes merged 2 commits from feat/send-media into dev 2026-07-22 17:50:55 -03:00
2 changed files with 157 additions and 1 deletions

View File

@ -1,4 +1,23 @@
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';
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<string, number> = {
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';
@ -64,6 +83,40 @@ 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 },
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,
@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.');
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);
}
@ApiOperation({ summary: 'Inicia atendimento ativo' })
@ApiResponse({ status: 201, description: 'Atendimento ativo iniciado.' })
@Post('start-attendance')

View File

@ -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<any> {
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);