2026-06-18 14:18:38 -03:00
|
|
|
import { Injectable, Logger, NotImplementedException } from '@nestjs/common';
|
2026-05-18 11:14:17 -03:00
|
|
|
import { WhatsappGateway } from './whatsapp.gateway';
|
2026-06-01 14:32:32 -03:00
|
|
|
import { AttendanceAssignmentService } from '../attendance/attendance-assignment.service';
|
2026-06-01 15:42:47 -03:00
|
|
|
import { ContactsService } from '../contacts/contacts.service';
|
2026-06-01 15:35:40 -03:00
|
|
|
import { WhatsappTemplateService } from './whatsapp-template.service';
|
2026-06-10 18:42:09 -03:00
|
|
|
|
2026-05-18 11:14:17 -03:00
|
|
|
@Injectable()
|
2026-06-18 14:18:38 -03:00
|
|
|
export class WhatsappService {
|
2026-05-18 11:14:17 -03:00
|
|
|
private readonly logger = new Logger(WhatsappService.name);
|
2026-05-19 15:28:23 -03:00
|
|
|
private readonly processedIncomingMessages = new Set<string>();
|
|
|
|
|
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
|
2026-05-18 11:14:17 -03:00
|
|
|
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly gateway: WhatsappGateway,
|
2026-06-01 14:32:32 -03:00
|
|
|
private readonly assignmentService: AttendanceAssignmentService,
|
2026-06-01 15:42:47 -03:00
|
|
|
private readonly contactsService: ContactsService,
|
2026-06-01 15:35:40 -03:00
|
|
|
private readonly whatsappTemplateService: WhatsappTemplateService,
|
2026-05-18 11:14:17 -03:00
|
|
|
) {}
|
|
|
|
|
|
2026-05-19 15:28:23 -03:00
|
|
|
private async enqueueIncomingRoute(remoteJid: string, msg: any, messageBody: string, messageId: string) {
|
|
|
|
|
const previousRoute = this.incomingRoutingQueues.get(remoteJid) || Promise.resolve();
|
|
|
|
|
|
|
|
|
|
let nextRoute: Promise<void>;
|
|
|
|
|
nextRoute = previousRoute
|
|
|
|
|
.catch(() => undefined)
|
|
|
|
|
.then(async () => {
|
|
|
|
|
try {
|
2026-05-26 09:08:20 -03:00
|
|
|
const routeResult = await this.assignmentService.routeIncomingMessage(
|
|
|
|
|
remoteJid,
|
|
|
|
|
messageBody,
|
|
|
|
|
messageId,
|
|
|
|
|
await this.getBotMessageVariables(remoteJid, msg),
|
|
|
|
|
);
|
2026-05-19 15:28:23 -03:00
|
|
|
if (routeResult.shouldSendBotMessage && routeResult.botMessage) {
|
2026-05-26 09:08:20 -03:00
|
|
|
this.logger.log(`Agente Virtual Sothis roteou ${remoteJid} para area ${routeResult.assignment?.area_nome || routeResult.assignment?.area_id}`);
|
2026-05-19 15:28:23 -03:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 11:14:17 -03:00
|
|
|
getStatus() {
|
2026-06-18 14:18:38 -03:00
|
|
|
return 'META_API';
|
2026-05-18 11:14:17 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getChats() {
|
2026-06-18 14:18:38 -03:00
|
|
|
return [];
|
2026-05-18 11:14:17 -03:00
|
|
|
}
|
|
|
|
|
|
2026-06-18 14:18:38 -03:00
|
|
|
async getChatMessages(_chatId: string) {
|
|
|
|
|
return [];
|
2026-05-19 17:58:48 -03:00
|
|
|
}
|
|
|
|
|
|
2026-06-18 14:18:38 -03:00
|
|
|
async getMessageMedia(_chatId: string, _messageId: string) {
|
|
|
|
|
throw new NotImplementedException('Busca de mídia não disponível na Meta API ainda.');
|
2026-05-19 17:58:48 -03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async getCustomerContact(chatId: string) {
|
|
|
|
|
try {
|
2026-06-01 15:42:47 -03:00
|
|
|
const contact = await this.contactsService.getContact(chatId);
|
|
|
|
|
return contact?.updated_at ? contact : null;
|
2026-05-19 17:58:48 -03:00
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-26 09:08:20 -03:00
|
|
|
private async getBotMessageVariables(chatId: string, msg: any) {
|
|
|
|
|
const contactProfile = await this.getCustomerContact(chatId);
|
|
|
|
|
const notifyName = String(msg?.['_data']?.notifyName || '').trim();
|
|
|
|
|
|
|
|
|
|
return {
|
2026-06-18 14:18:38 -03:00
|
|
|
nome: contactProfile?.name || notifyName,
|
|
|
|
|
telefone: contactProfile?.phone || chatId,
|
2026-05-26 09:08:20 -03:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-18 14:18:38 -03:00
|
|
|
async sendMessage(to: string, message: string, _media?: { data: string; mimetype: string; filename?: string }, _senderName?: string) {
|
|
|
|
|
throw new NotImplementedException(`Envio via Meta API não implementado ainda. Destinatário: ${to}, mensagem: "${message}"`);
|
2026-05-18 11:14:17 -03:00
|
|
|
}
|
2026-05-18 13:28:17 -03:00
|
|
|
|
2026-05-20 13:56:23 -03:00
|
|
|
async startAttendance(
|
|
|
|
|
to: string,
|
|
|
|
|
templateId: number,
|
|
|
|
|
userId: number,
|
|
|
|
|
areaId?: number | null,
|
|
|
|
|
variables?: Record<string, string | null | undefined>,
|
|
|
|
|
) {
|
2026-06-01 15:35:40 -03:00
|
|
|
const template = await this.whatsappTemplateService.getTemplateById(templateId);
|
2026-06-18 14:18:38 -03:00
|
|
|
if (!template) throw new Error('Template de WhatsApp nao encontrado');
|
2026-05-20 13:56:23 -03:00
|
|
|
|
|
|
|
|
const renderedContent = this.renderTemplateContent(template.content, variables);
|
2026-06-18 14:18:38 -03:00
|
|
|
await this.sendMessage(to, renderedContent);
|
|
|
|
|
|
|
|
|
|
const assignment = await this.assignmentService.assignChat(to, userId, areaId || null);
|
|
|
|
|
const lockedAssignment = await this.assignmentService.markAwaitingCustomerReply(to);
|
2026-05-20 13:56:23 -03:00
|
|
|
|
|
|
|
|
return {
|
2026-06-18 14:18:38 -03:00
|
|
|
chatId: to,
|
2026-05-20 13:56:23 -03:00
|
|
|
template: { ...template, content: renderedContent },
|
2026-06-18 14:18:38 -03:00
|
|
|
messageId: null,
|
2026-05-20 13:56:23 -03:00
|
|
|
assignment: lockedAssignment || assignment,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private renderTemplateContent(content: string, variables?: Record<string, string | null | undefined>) {
|
2026-05-26 09:08:20 -03:00
|
|
|
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];
|
2026-05-20 13:56:23 -03:00
|
|
|
const normalized = String(value || '').trim();
|
|
|
|
|
return normalized || match;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-19 15:28:23 -03:00
|
|
|
|
2026-06-18 13:53:40 -03:00
|
|
|
async handleMetaWebhookEvent(body: any): Promise<void> {
|
|
|
|
|
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})`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private async handleMetaIncomingMessage(message: any, contacts: any[]): Promise<void> {
|
|
|
|
|
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]'}`);
|
|
|
|
|
|
|
|
|
|
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) => {
|
|
|
|
|
// TODO Fase 2: enviar via Meta Graph API
|
|
|
|
|
this.logger.warn(`[Meta] Bot reply pendente de implementação. Mensagem para ${from}: "${text}"`);
|
|
|
|
|
},
|
|
|
|
|
_data: { notifyName: contactName },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
await this.enqueueIncomingRoute(from, fakeMsg, messageBody, messageId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-18 11:14:17 -03:00
|
|
|
}
|