iachat/app/javascript/dashboard/helper/inactivityAlertTracker.js
Rodribm10 6aa328e329 fix(aggressive-alert): hidrata tracker de inatividade no boot
Bug reportado em 2026-04-24: só o banner vermelho status→open
funcionava. 5/15/28min nunca disparavam.

Causa: o tracker só era alimentado via websocket ao vivo
(message.created). Se a msg do cliente chegou ANTES da aba carregar
(ou depois de F5), o tracker ficava vazio, setInterval nunca começava,
thresholds nunca disparavam.

Fix:
- Nova função `hydrateFromConversations(convs)` no tracker. Varre
  conversas em 'open', pega a última msg não-activity, se for de
  Contact registra com timestamp REAL (msg.created_at), não Date.now().
  Isso fecha o gap de tempo: se o cliente falou 7min atrás, o YELLOW
  já dispara na hora.
- AggressiveConversationBanner.vue tem agora `watch: allConversations`
  chamando hydrate toda vez que a lista muda (boot + F5 + navegação).
- parseCreatedAt() suporta Unix seconds + ISO.
- findLastNonActivityMessage() ignora mensagens de sistema.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 08:00:38 -03:00

174 lines
5.4 KiB
JavaScript

import aggressiveAlert, { LEVEL } from './aggressiveAlert';
// Thresholds de inatividade. Cada um dispara UMA vez por conversa (enquanto
// o cliente segue sendo o último a falar). Ordem: do menos urgente ao mais.
const THRESHOLDS = [
{ minutes: 5, level: LEVEL.YELLOW },
{ minutes: 15, level: LEVEL.ORANGE },
{ minutes: 28, level: LEVEL.RED },
];
// Checa o estado dos alertas a cada 20s — granularidade suficiente pra
// não perder threshold (a menor janela entre thresholds é 5min = 300s).
const CHECK_INTERVAL_MS = 20_000;
function findLastNonActivityMessage(conv) {
if (!conv.messages || !conv.messages.length) return null;
const nonActivity = conv.messages.filter(
m => m && m.message_type !== 2 && m.message_type !== 'activity'
);
return nonActivity[nonActivity.length - 1] || null;
}
// Chatwoot usa Unix timestamp (segundos) na maior parte dos endpoints e
// ISO em alguns. Suporta os dois.
function parseCreatedAt(value) {
if (value == null) return null;
if (typeof value === 'number') {
return value < 10_000_000_000 ? value * 1000 : value;
}
const parsed = new Date(value).getTime();
return Number.isFinite(parsed) ? parsed : null;
}
class InactivityAlertTracker {
constructor() {
// Map<conversationId, { lastClientAt, firedMinutes: Set<number>, contactName, inboxName }>
this.conversations = new Map();
this.interval = null;
this.enabledGetter = () => true; // injetado pelo actionCable com o store
}
setEnabledGetter(fn) {
this.enabledGetter = fn;
}
start() {
if (this.interval) return;
this.interval = setInterval(() => this.tick(), CHECK_INTERVAL_MS);
}
stop() {
if (!this.interval) return;
clearInterval(this.interval);
this.interval = null;
}
/**
* Registra ou atualiza que o CLIENTE mandou mensagem em uma conversa aberta.
* Zera os thresholds se já existia (porque o relógio recomeça).
*/
onClientMessage({ conversationId, contactName, inboxName }) {
if (!conversationId) return;
this.conversations.set(conversationId, {
lastClientAt: Date.now(),
firedMinutes: new Set(),
contactName: contactName || '—',
inboxName: inboxName || '',
});
this.start();
}
/**
* Limpa a conversa — agente respondeu ou cenário não mais aplicável.
* Também dá dismiss no banner pra parar som.
*/
onAgentReplyOrResolved(conversationId) {
if (!conversationId) return;
if (this.conversations.has(conversationId)) {
this.conversations.delete(conversationId);
}
aggressiveAlert.dismissForReply(conversationId);
if (this.conversations.size === 0) this.stop();
}
tick() {
if (!this.enabledGetter()) return;
if (this.conversations.size === 0) {
this.stop();
return;
}
const now = Date.now();
Array.from(this.conversations.entries()).forEach(
([conversationId, entry]) => {
const elapsedMin = (now - entry.lastClientAt) / 60000;
THRESHOLDS.forEach(t => {
if (elapsedMin < t.minutes) return;
if (entry.firedMinutes.has(t.minutes)) return;
entry.firedMinutes.add(t.minutes);
aggressiveAlert.trigger({
conversationId,
level: t.level,
kind: 'inactivity',
contactName: entry.contactName,
inboxName: entry.inboxName,
minutes: t.minutes,
});
});
}
);
}
/**
* Varre a lista de conversas do store e popula o tracker com aquelas
* que estão em 'open' e tiveram o cliente como último remetente.
* Usa o `created_at` da última msg como âncora de tempo (não Date.now()),
* pra fechar o gap dos thresholds perdidos enquanto a aba estava fechada.
*
* Se a conversa já está no tracker com timestamp ≥ ao recém-lido, ignora
* (mantém o estado dos firedMinutes — evita re-trigger em re-hidratação).
*/
hydrateFromConversations(conversations) {
if (!this.enabledGetter()) return;
if (!Array.isArray(conversations) || conversations.length === 0) return;
let hydrated = 0;
conversations.forEach(conv => {
if (!conv || conv.status !== 'open') return;
const lastMsg = findLastNonActivityMessage(conv);
if (!lastMsg) return;
const isClient =
lastMsg.sender_type === 'Contact' ||
lastMsg.message_type === 0 ||
lastMsg.message_type === 'incoming';
if (!isClient) {
// Última msg foi do agente/bot — garante que não está no tracker
if (this.conversations.has(conv.id)) {
this.conversations.delete(conv.id);
}
return;
}
const lastClientAt = parseCreatedAt(lastMsg.created_at);
if (!lastClientAt) return;
const existing = this.conversations.get(conv.id);
if (existing && existing.lastClientAt >= lastClientAt) return;
const contactName =
(conv.meta && conv.meta.sender && conv.meta.sender.name) || '';
const inboxName = (conv.inbox && conv.inbox.name) || '';
this.conversations.set(conv.id, {
lastClientAt,
firedMinutes: new Set(),
contactName,
inboxName,
});
hydrated += 1;
});
if (hydrated > 0) {
this.start();
// Dispara imediatamente — se já passou de algum threshold, o tick
// seguinte (20s) detectaria. Mas rodar aqui antecipa em até 20s.
this.tick();
}
}
}
const inactivityAlertTracker = new InactivityAlertTracker();
export default inactivityAlertTracker;