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

238 lines
8.0 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) {
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();
this.logger.log(`[Meta] Mensagem enviada para ${to}: ${(result as any)?.messages?.[0]?.id}`);
await this.assignmentService.clearTransferNote(to);
return result;
}
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) => {
await this.sendMessage(from, text);
},
_data: { notifyName: contactName },
};
await this.enqueueIncomingRoute(from, fakeMsg, messageBody, messageId);
}
}