FEAT: Adicionado sistema de gerenciamento de filas e assimilações de conversas a atendentes
All checks were successful
Deploy Dev / deploy (push) Successful in 3s

This commit is contained in:
Rafael Alves Lopes 2026-05-19 15:28:23 -03:00
parent 8790ce70d0
commit 8f3cb9f75f
3 changed files with 459 additions and 33 deletions

View File

@ -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;
}
}

View File

@ -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);

View File

@ -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<string>();
private readonly incomingRoutingQueues = new Map<string, Promise<void>>();
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<void>;
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;