import { Injectable, Logger, NotImplementedException } from '@nestjs/common'; import { WhatsappGateway } from './whatsapp.gateway'; import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service'; import { ContactsService } from '../contacts/contacts.service'; import { WhatsappTemplateService } from './whatsapp-template.service'; import { MensagensRepository } from './repositories/mensagens.repository'; @Injectable() export class WhatsappService { private readonly logger = new Logger(WhatsappService.name); private readonly processedIncomingMessages = new Set(); private readonly incomingRoutingQueues = new Map>(); constructor( private readonly gateway: WhatsappGateway, private readonly assignmentService: AttendanceAssignmentService, private readonly contactsService: ContactsService, private readonly whatsappTemplateService: WhatsappTemplateService, private readonly mensagensRepository: MensagensRepository, ) {} private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) { const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve(); let nextRoute: Promise; nextRoute = previousRoute .catch(() => undefined) .then(async () => { try { const routeResult = await this.assignmentService.routeIncomingMessage( remoteJid, messageBody, messageId, await this.getBotMessageVariables(remoteJid, msg), ); if (routeResult.shouldSendBotMessage && routeResult.botMessage) { this.logger.log(`Agente Virtual Sothis roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`); await msg.reply(routeResult.botMessage); } } catch (err) { this.logger.error(`Erro ao rotear conversa inicial de ${remoteJid}:`, err); } }) .finally(() => { if (this.incomingRoutingQueues.get(remoteJid) === nextRoute) { this.incomingRoutingQueues.delete(remoteJid); } }); this.incomingRoutingQueues.set(remoteJid, nextRoute); await nextRoute; } getStatus() { return 'META_API'; } async getChats() { return this.mensagensRepository.findChats(); } async getChatMessages(chatId: string) { return this.mensagensRepository.findByChatId(chatId); } async getMessageMedia(_chatId: string, _messageId: string) { throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.'); } private async getCustomerContact(chatId: string) { try { const contact = await this.contactsService.getContact(chatId); return contact?.updated_at ? contact : null; } catch { return null; } } private async getBotMessageVariables(chatId: string, msg: any) { const contactProfile = await this.getCustomerContact(chatId); const notifyName = String(msg?.['_data']?.notifyName || '').trim(); return { nome: contactProfile?.name || notifyName, telefone: contactProfile?.phone || chatId, }; } async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, senderName?: string) { const token = process.env.META_ACCESS_TOKEN; const phoneNumberId = process.env.META_PHONE_NUMBER_ID; const apiVersion = process.env.META_API_VERSION || 'v25.0'; if (!token || !phoneNumberId) { throw new Error('META_ACCESS_TOKEN e META_PHONE_NUMBER_ID são obrigatórios.'); } const body = senderName ? `*Atendente: ${senderName}*\n\n${message}` : message; const response = 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: 'text', text: { preview_url: false, body }, }), }, ); if (!response.ok) { const error = await response.json().catch(() => ({})); this.logger.error(`[Meta] Falha ao enviar mensagem para ${to}:`, error); throw new Error(`Meta API erro ${response.status}: ${JSON.stringify(error)}`); } const result = await response.json() as any; const wamid = result?.messages?.[0]?.id; this.logger.log(`[Meta] Mensagem enviada para ${to}: ${wamid}`); if (wamid) { await this.mensagensRepository.save({ wamid, chatId: to, fromMe: true, type: 'text', body: body, senderName: senderName ?? 'Sistema', status: 'sent', timestamp: Math.floor(Date.now() / 1000), }); } await this.assignmentService.clearTransferNote(to); return result; } async startAttendance( to: string, templateId: number, userId: number, areaId?: number | null, variables?: Record, ) { const template = await this.whatsappTemplateService.getTemplateById(templateId); if (!template) throw new Error('Template de WhatsApp nao encontrado'); const renderedContent = this.renderTemplateContent(template.content, variables); await this.sendMessage(to, renderedContent); const assignment = await this.assignmentService.assignChat(to, userId, areaId || null); const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to); return { chatId: to, template: { ...template, content: renderedContent }, messageId: null, assignment: lockedAssignment || assignment, }; } private renderTemplateContent(content: string, variables?: Record) { return String(content || '').replace(/\{([^{}]+)\}/g, (match, key) => { const cleanKey = String(key || '').trim(); const lowerKey = cleanKey.toLowerCase(); const asciiKey = lowerKey.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); const value = variables?.[cleanKey] ?? variables?.[lowerKey] ?? variables?.[asciiKey]; const normalized = String(value || '').trim(); return normalized || match; }); } async handleMetaWebhookEvent(body: any): Promise { if (body?.object !== 'whatsapp_business_account') return; for (const entry of body?.entry ?? []) { for (const change of entry?.changes ?? []) { if (change.field !== 'messages') continue; const value = change.value; if (value?.messages?.length) { for (const message of value.messages) { await this.handleMetaIncomingMessage(message, value.contacts ?? []); } } if (value?.statuses?.length) { for (const status of value.statuses) { this.logger.log(`[Meta] Status de mensagem: ${status.id} → ${status.status} (destinatário: ${status.recipient_id})`); await this.mensagensRepository.updateStatus(status.id, status.status); } } } } } private async handleMetaIncomingMessage(message: any, contacts: any[]): Promise { const from: string = message.from; const messageId: string = message.id; const timestamp: number = parseInt(message.timestamp, 10); const messageBody: string = message.text?.body?.trim() ?? ''; const contact = contacts.find((c) => c.wa_id === from); const contactName: string = contact?.profile?.name || from; if (this.processedIncomingMessages.has(messageId)) return; this.processedIncomingMessages.add(messageId); if (this.processedIncomingMessages.size > 1000) { const [oldest] = this.processedIncomingMessages; this.processedIncomingMessages.delete(oldest); } this.logger.log(`[Meta] Mensagem recebida de ${from} (${contactName}): ${messageBody || '[mídia]'}`); await this.mensagensRepository.save({ wamid: messageId, chatId: from, fromMe: false, type: message.type ?? 'text', body: messageBody || undefined, senderName: contactName, timestamp, }); this.gateway.emitNewMessage({ from, body: messageBody, timestamp, isGroupMsg: false, id: messageId, fromMe: false, notifyName: contactName, hasMedia: message.type !== 'text', media: null, }); if (messageBody) { await this.assignmentService.markCustomerReplied(from); } if (!messageBody) { this.logger.log(`[Meta] Triagem ignorada para ${from}: mensagem sem texto.`); return; } const fakeMsg = { reply: async (text: string) => { await this.sendMessage(from, text); }, _data: { notifyName: contactName }, }; await this.enqueueIncomingRoute(from, fakeMsg, messageBody, messageId); } }