diff --git a/src/modules/whatsapp/whatsapp-assignment.service.ts b/src/modules/whatsapp/whatsapp-assignment.service.ts index dac22a7..6303f67 100644 --- a/src/modules/whatsapp/whatsapp-assignment.service.ts +++ b/src/modules/whatsapp/whatsapp-assignment.service.ts @@ -1,50 +1,417 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { DatabaseService } from '../../infra/database/database.service'; +interface TransferInput { + chatId: string; + areaId: number; + userId?: number | null; + note?: string | null; +} + +const SUPPORT_KEYWORDS = [ + 'suporte', + 'bug', + 'erro', + 'falha', + 'problema', + 'tecnico', + 'tecnica', + 'internet', + 'instabilidade', + 'sistema', +]; + +const FINANCE_KEYWORDS = [ + 'financeiro', + 'fatura', + 'boleto', + 'dinheiro', + 'cartao', + 'cartao', + 'atraso', + 'pagamento', + 'cobranca', + 'nota', +]; + +const SALES_KEYWORDS = [ + 'comercial', + 'produto', + 'novo', + 'contratar', + 'compra', + 'plano', + 'proposta', + 'preco', + 'valor', +]; + @Injectable() -export class WhatsappAssignmentService { +export class WhatsappAssignmentService implements OnModuleInit { private readonly logger = new Logger(WhatsappAssignmentService.name); constructor(private readonly db: DatabaseService) {} - async assignChat(chatId: string, userId: string, areaId?: string) { - this.logger.log(`Atribuindo chat ${chatId} ao usuário ${userId}`); - + async onModuleInit() { + await this.ensureSchema(); + } + + async ensureSchema() { + await this.db.query(` + ALTER TABLE whatsapp_chat_atribuicoes + ALTER COLUMN user_id DROP NOT NULL, + ALTER COLUMN area_id DROP NOT NULL; + `).catch(() => undefined); + + await this.db.query(` + ALTER TABLE whatsapp_chat_atribuicoes + ADD COLUMN IF NOT EXISTS status VARCHAR(40) NOT NULL DEFAULT 'assigned', + ADD COLUMN IF NOT EXISTS conversation_started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMP WITH TIME ZONE DEFAULT (CURRENT_TIMESTAMP + INTERVAL '24 hours'), + ADD COLUMN IF NOT EXISTS transfer_note TEXT, + ADD COLUMN IF NOT EXISTS routing_attempts INTEGER NOT NULL DEFAULT 0, + ADD COLUMN IF NOT EXISTS last_routed_message_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS last_bot_sent_at TIMESTAMP WITH TIME ZONE, + ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP; + `); + } + + async assignChat(chatId: string, userId: string | number, areaId?: string | number | null) { + this.logger.log(`Atribuindo chat ${chatId} ao usuario ${userId}`); + const query = ` - INSERT INTO whatsapp_chat_atribuicoes (chat_id, user_id, area_id) - VALUES ($1, $2, $3) + INSERT INTO whatsapp_chat_atribuicoes ( + chat_id, user_id, area_id, status, conversation_started_at, expires_at, assigned_at, updated_at + ) + VALUES ($1, $2, $3, 'assigned', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (chat_id) DO UPDATE SET + user_id = EXCLUDED.user_id, + area_id = COALESCE(EXCLUDED.area_id, whatsapp_chat_atribuicoes.area_id), + status = 'assigned', + assigned_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING *; + `; + + const result = await this.db.query(query, [chatId, Number(userId), areaId ? Number(areaId) : null]); + return this.enrichAssignment(result.rows[0]); + } + + async queueChat(chatId: string, areaId: number, note?: string | null) { + const query = ` + INSERT INTO whatsapp_chat_atribuicoes ( + chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at + ) + VALUES ($1, NULL, $2, 'queued', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $3, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) + ON CONFLICT (chat_id) DO UPDATE SET + user_id = NULL, + area_id = EXCLUDED.area_id, + status = 'queued', + conversation_started_at = CASE + WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP + ELSE whatsapp_chat_atribuicoes.conversation_started_at + END, + expires_at = CASE + WHEN whatsapp_chat_atribuicoes.expires_at <= CURRENT_TIMESTAMP THEN CURRENT_TIMESTAMP + INTERVAL '24 hours' + ELSE whatsapp_chat_atribuicoes.expires_at + END, + transfer_note = EXCLUDED.transfer_note, + updated_at = CURRENT_TIMESTAMP + RETURNING *; + `; + + const result = await this.db.query(query, [chatId, areaId, note || null]); + return this.enrichAssignment(result.rows[0]); + } + + async transferChat(input: TransferInput) { + const status = input.userId ? 'assigned' : 'queued'; + const query = ` + INSERT INTO whatsapp_chat_atribuicoes ( + chat_id, user_id, area_id, status, conversation_started_at, expires_at, transfer_note, assigned_at, updated_at + ) + VALUES ($1, $2, $3, $4, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', $5, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) ON CONFLICT (chat_id) DO UPDATE SET user_id = EXCLUDED.user_id, area_id = EXCLUDED.area_id, - assigned_at = CURRENT_TIMESTAMP + status = EXCLUDED.status, + transfer_note = EXCLUDED.transfer_note, + assigned_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP RETURNING *; `; - - const result = await this.db.query(query, [chatId, userId, areaId]); - return result.rows[0]; + + const result = await this.db.query(query, [ + input.chatId, + input.userId || null, + input.areaId, + status, + input.note || null, + ]); + return this.enrichAssignment(result.rows[0]); } async getAssignment(chatId: string) { - const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE chat_id = $1`; + const query = ` + SELECT * FROM whatsapp_chat_atribuicoes + WHERE chat_id = $1 + LIMIT 1 + `; const result = await this.db.query(query, [chatId]); - return result.rows[0]; + const assignment = result.rows[0]; + + if (!assignment) return null; + + if (assignment.expires_at && new Date(assignment.expires_at).getTime() <= Date.now()) { + await this.db.query( + `UPDATE whatsapp_chat_atribuicoes SET status = 'expired', user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE chat_id = $1`, + [chatId], + ); + return null; + } + + return this.enrichAssignment(assignment); } async releaseChat(chatId: string) { this.logger.log(`Liberando chat ${chatId}`); - const query = `DELETE FROM whatsapp_chat_atribuicoes WHERE chat_id = $1`; - await this.db.query(query, [chatId]); + const query = ` + UPDATE whatsapp_chat_atribuicoes + SET user_id = NULL, status = 'queued', updated_at = CURRENT_TIMESTAMP + WHERE chat_id = $1 + RETURNING *; + `; + const result = await this.db.query(query, [chatId]); + return result.rows[0] ? this.enrichAssignment(result.rows[0]) : null; + } + + async routeIncomingMessage(chatId: string, message: string, messageId?: string) { + const cleanMessage = (message || '').trim(); + if (!cleanMessage) { + const current = await this.getAssignment(chatId); + return { assignment: current, shouldSendBotMessage: false, botMessage: null }; + } + + const current = await this.getAssignment(chatId); + + if (current?.last_routed_message_id && messageId && current.last_routed_message_id === messageId) { + return { assignment: current, shouldSendBotMessage: false, botMessage: null }; + } + + if (current && current.status !== 'bot_triage') { + return { assignment: current, shouldSendBotMessage: false, botMessage: null }; + } + + const detectedArea = await this.detectKnownArea(cleanMessage); + + if (detectedArea) { + const assignment = await this.queueChat(chatId, detectedArea.id, 'Roteado automaticamente pelo Omnino'); + await this.markBotRoute(chatId, messageId); + const hasPreviousTriage = current?.status === 'bot_triage'; + const botMessage = hasPreviousTriage + ? `Vi aqui que voce quer falar com ${detectedArea.article} ${detectedArea.name}. Irei te encaminhar.` + : `Oi, vou te ajudar. Vi aqui que voce quer falar com ${detectedArea.article} ${detectedArea.name}. Irei te encaminhar.`; + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', 'Omnino', botMessage), + }; + } + + if (current?.status === 'bot_triage') { + const attempts = Number(current.routing_attempts || 0); + + if (this.shouldSuppressRepeatedGreeting(cleanMessage, current.last_bot_sent_at)) { + await this.markMessageRouted(chatId, messageId); + return { assignment: current, shouldSendBotMessage: false, botMessage: null }; + } + + if (attempts >= 2) { + const supportArea = await this.getAreaByName('Suporte'); + const assignment = await this.queueChat(chatId, supportArea.id, 'Roteado para suporte por falta de classificacao'); + await this.markBotRoute(chatId, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + 'Omnino', + 'Nao consegui identificar a area ideal com seguranca. Vou te encaminhar para o suporte para agilizar seu atendimento.', + ), + }; + } + + const assignment = await this.upsertTriage(chatId, attempts + 1, messageId); + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage( + 'Atendente virtual', + 'Omnino', + 'Para eu te encaminhar corretamente, responda por favor com uma destas opcoes: suporte, financeiro ou comercial.', + ), + }; + } + + const assignment = await this.upsertTriage(chatId, 1, messageId); + const intentMessage = + 'Ola, tudo bem? Me chamo Omnino e irei te transferir para alguem especializado. Gostaria de falar com suporte para resolver algum bug ou problema, financeiro para faturas, ou comercial para contratar um novo produto?'; + + return { + assignment, + shouldSendBotMessage: true, + botMessage: this.formatSenderMessage('Atendente virtual', 'Omnino', intentMessage), + }; } async getChatsByArea(areaId: string) { - const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE area_id = $1`; + const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE area_id = $1 AND status <> 'expired'`; const result = await this.db.query(query, [areaId]); return result.rows; } async getChatsByUser(userId: string) { - const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE user_id = $1`; + const query = `SELECT * FROM whatsapp_chat_atribuicoes WHERE user_id = $1 AND status = 'assigned'`; const result = await this.db.query(query, [userId]); return result.rows; } + + private async detectKnownArea(message: string) { + const normalized = this.normalize(message); + const targetName = this.matchAny(normalized, FINANCE_KEYWORDS) + ? 'Financeiro' + : this.matchAny(normalized, SALES_KEYWORDS) + ? 'Comercial' + : this.matchAny(normalized, SUPPORT_KEYWORDS) + ? 'Suporte' + : null; + + if (!targetName) { + return null; + } + + return this.getAreaByName(targetName); + } + + private async getAreaByName(targetName: string) { + const result = await this.db.query<{ id: number; nome: string }>( + `SELECT id, nome FROM areas WHERE nome = $1 LIMIT 1`, + [targetName], + ); + + if (result.rows[0]) { + return { + id: result.rows[0].id, + name: result.rows[0].nome.toLowerCase(), + article: result.rows[0].nome === 'Comercial' ? 'o' : 'o', + }; + } + + return { id: 1, name: targetName.toLowerCase(), article: 'o' }; + } + + private async upsertTriage(chatId: string, attempts: number, messageId?: string) { + const query = ` + INSERT INTO whatsapp_chat_atribuicoes ( + chat_id, user_id, area_id, status, routing_attempts, last_routed_message_id, + last_bot_sent_at, conversation_started_at, expires_at, assigned_at, updated_at + ) + VALUES ( + $1, NULL, NULL, 'bot_triage', $2, $3, + CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + INTERVAL '24 hours', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + ) + ON CONFLICT (chat_id) DO UPDATE SET + user_id = NULL, + area_id = NULL, + status = 'bot_triage', + routing_attempts = EXCLUDED.routing_attempts, + last_routed_message_id = EXCLUDED.last_routed_message_id, + last_bot_sent_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + RETURNING *; + `; + + const result = await this.db.query(query, [chatId, attempts, messageId || null]); + return this.enrichAssignment(result.rows[0]); + } + + private async markBotRoute(chatId: string, messageId?: string, updateBotTimestamp = true) { + await this.db.query( + ` + UPDATE whatsapp_chat_atribuicoes + SET + routing_attempts = 0, + last_routed_message_id = $2, + last_bot_sent_at = CASE WHEN $3 THEN CURRENT_TIMESTAMP ELSE last_bot_sent_at END, + updated_at = CURRENT_TIMESTAMP + WHERE chat_id = $1 + `, + [chatId, messageId || null, updateBotTimestamp], + ); + } + + private async markMessageRouted(chatId: string, messageId?: string) { + await this.db.query( + ` + UPDATE whatsapp_chat_atribuicoes + SET + last_routed_message_id = $2, + updated_at = CURRENT_TIMESTAMP + WHERE chat_id = $1 + `, + [chatId, messageId || null], + ); + } + + private matchAny(text: string, keywords: string[]) { + return keywords.some((keyword) => text.includes(this.normalize(keyword))); + } + + private normalize(value: string) { + return String(value || '') + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase(); + } + + private shouldSuppressRepeatedGreeting(message: string, lastBotSentAt?: string | Date | null) { + if (!lastBotSentAt) return false; + + const normalized = this.normalize(message).replace(/[!?.,\s]+/g, ' ').trim(); + const greetingOnlyMessages = ['oi', 'ola', 'olá', 'bom dia', 'boa tarde', 'boa noite']; + if (!greetingOnlyMessages.includes(normalized)) return false; + + const lastBotTime = new Date(lastBotSentAt).getTime(); + if (Number.isNaN(lastBotTime)) return false; + + return Date.now() - lastBotTime < 10000; + } + + private formatSenderMessage(senderRole: string, senderName: string, message: string) { + return `*${senderRole}: ${senderName}*\n\n${message}`; + } + + private async enrichAssignment(assignment: any) { + if (!assignment) return null; + + const result = await this.db.query( + ` + SELECT + wca.*, + a.nome AS area_nome, + u.nome AS user_nome, + u.email AS user_email + FROM whatsapp_chat_atribuicoes wca + LEFT JOIN areas a ON a.id = wca.area_id + LEFT JOIN usuarios u ON u.id = wca.user_id + WHERE wca.id = $1 + `, + [assignment.id], + ); + + return result.rows[0] || assignment; + } } diff --git a/src/modules/whatsapp/whatsapp.controller.ts b/src/modules/whatsapp/whatsapp.controller.ts index 90ae786..f52282a 100644 --- a/src/modules/whatsapp/whatsapp.controller.ts +++ b/src/modules/whatsapp/whatsapp.controller.ts @@ -30,8 +30,8 @@ export class WhatsappController { } @Post('send') - async sendMessage(@Body() body: { to: string; message: string; media?: { data: string; mimetype: string; filename?: string } }) { - return this.whatsappService.sendMessage(body.to, body.message, body.media); + async sendMessage(@Body() body: { to: string; message: string; senderName?: string; media?: { data: string; mimetype: string; filename?: string } }) { + return this.whatsappService.sendMessage(body.to, body.message, body.media, body.senderName); } @Post('assign') @@ -39,6 +39,11 @@ export class WhatsappController { return this.assignmentService.assignChat(body.chatId, body.userId, body.areaId); } + @Post('transfer') + async transferChat(@Body() body: { chatId: string; areaId: number; userId?: number | null; note?: string | null }) { + return this.assignmentService.transferChat(body); + } + @Delete('release/:chatId') async releaseChat(@Param('chatId') chatId: string) { return this.assignmentService.releaseChat(chatId); diff --git a/src/modules/whatsapp/whatsapp.service.ts b/src/modules/whatsapp/whatsapp.service.ts index 03b509d..cdf7875 100644 --- a/src/modules/whatsapp/whatsapp.service.ts +++ b/src/modules/whatsapp/whatsapp.service.ts @@ -12,6 +12,8 @@ export class WhatsappService implements OnModuleInit { private readonly logger = new Logger(WhatsappService.name); private status: 'DISCONNECTED' | 'AWAITING_QR' | 'CONNECTED' = 'DISCONNECTED'; private currentQr: string | null = null; + private readonly processedIncomingMessages = new Set(); + private readonly incomingRoutingQueues = new Map>(); constructor( private readonly gateway: WhatsappGateway, @@ -94,7 +96,9 @@ export class WhatsappService implements OnModuleInit { if (msg.from === 'status@broadcast') return; const remoteJid = msg.id.remote || (msg.fromMe ? msg.to : msg.from); - this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${msg.body}`); + const messageId = msg.id._serialized; + const messageBody = (msg.body || '').trim(); + this.logger.log(`Mensagem registrada (fromMe: ${msg.fromMe}) remote: ${remoteJid} - ${messageBody}`); let mediaData: any = null; if (msg.hasMedia) { @@ -119,7 +123,7 @@ export class WhatsappService implements OnModuleInit { body: msg.body, timestamp: msg.timestamp, isGroupMsg: remoteJid.endsWith('@g.us'), - id: msg.id._serialized, + id: messageId, fromMe: msg.fromMe, notifyName: msg['_data']?.notifyName || '', hasMedia: msg.hasMedia, @@ -127,9 +131,6 @@ export class WhatsappService implements OnModuleInit { }); // Salva ou atualiza a conversa na persistência híbrida - const persistentChats = await this.loadPersistentChats(); - const isNewNumber = !persistentChats[remoteJid]; - await this.addOrUpdatePersistentChat(remoteJid, { name: msg['_data']?.notifyName || remoteJid.split('@')[0], preview: msg.hasMedia ? `[Mídia: ${mediaData?.filename || 'Arquivo'}]` : (msg.body || '[Mídia]'), @@ -137,19 +138,55 @@ export class WhatsappService implements OnModuleInit { unreadCount: msg.fromMe ? 0 : 1 }); - if (!msg.fromMe && isNewNumber) { - try { - this.logger.log(`Auto-resposta de boas vindas enviada para novo contato: ${remoteJid}`); - await this.client.sendMessage(remoteJid, "Olá! Seja bem-vindo a Sothis Telecom Como podemos te ajudar?"); - } catch (err) { - this.logger.error(`Erro ao enviar auto-resposta de boas vindas para ${remoteJid}:`, err); + if (!msg.fromMe) { + if (!messageBody) { + this.logger.log(`Triagem ignorada para ${remoteJid}: mensagem sem texto.`); + return; } + + if (this.processedIncomingMessages.has(messageId)) { + return; + } + this.processedIncomingMessages.add(messageId); + if (this.processedIncomingMessages.size > 1000) { + const [oldest] = this.processedIncomingMessages; + this.processedIncomingMessages.delete(oldest); + } + + await this.enqueueIncomingRoute(remoteJid, msg, messageBody, messageId); } }); this.client.initialize(); } + 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); + if (routeResult.shouldSendBotMessage && routeResult.botMessage) { + this.logger.log(`Omnino 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; + } + private getPersistFilePath() { return path.join(process.cwd(), 'whatsapp-chats-persist.json'); } @@ -323,16 +360,18 @@ export class WhatsappService implements OnModuleInit { throw new Error('Mídia não encontrada para esta mensagem'); } - async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }) { + async sendMessage(to: string, message: string, media?: { data: string; mimetype: string; filename?: string }, senderName?: string) { if (this.status !== 'CONNECTED') throw new Error('WhatsApp não está conectado'); + + const outboundMessage = this.formatOutboundMessage(message, senderName); let sentMsg; if (media) { this.logger.log(`Enviando mídia para ${to}: ${media.filename} (${media.mimetype})`); const messageMedia = new MessageMedia(media.mimetype, media.data, media.filename); - sentMsg = await this.client.sendMessage(to, messageMedia, { caption: message }); + sentMsg = await this.client.sendMessage(to, messageMedia, { caption: outboundMessage }); } else { - sentMsg = await this.client.sendMessage(to, message); + sentMsg = await this.client.sendMessage(to, outboundMessage); } // Sincronizar na persistência também! @@ -346,6 +385,21 @@ export class WhatsappService implements OnModuleInit { return sentMsg; } + private formatOutboundMessage(message: string, senderName?: string) { + const cleanMessage = (message || '').trim(); + const cleanSenderName = (senderName || '').trim(); + + if (!cleanSenderName) { + return cleanMessage; + } + + if (!cleanMessage) { + return `*Atendente: ${cleanSenderName}*`; + } + + return `*Atendente: ${cleanSenderName}*\n\n${cleanMessage}`; + } + async getTemplates() { const res = await this.db.query('SELECT * FROM whatsapp_templates ORDER BY id ASC'); return res.rows;