omnichannel-backend/src/modules/whatsapp/whatsapp.service.ts

202 lines
6.9 KiB
TypeScript
Raw Normal View History

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';
@Injectable()
export class WhatsappService {
private readonly logger = new Logger(WhatsappService.name);
private readonly processedIncomingMessages = new Set<string>();
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
constructor(
private readonly gateway: WhatsappGateway,
private readonly assignmentService: AttendanceAssignmentService,
private readonly contactsService: ContactsService,
private readonly whatsappTemplateService: WhatsappTemplateService,
) {}
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 {
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 [];
}
async getChatMessages(_chatId: string) {
return [];
}
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) {
throw new NotImplementedException(`Envio via Meta API não implementado ainda. Destinatário: ${to}, mensagem: "${message}"`);
}
async startAttendance(
to: string,
templateId: number,
userId: number,
areaId?: number | null,
variables?: Record<string, string | null | undefined>,
) {
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<string, string | null | undefined>) {
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<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);
}
}