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>
This commit is contained in:
parent
d1fa5335e1
commit
6aa328e329
@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import aggressiveAlert from 'dashboard/helper/aggressiveAlert';
|
||||
import inactivityAlertTracker from 'dashboard/helper/inactivityAlertTracker';
|
||||
|
||||
export default {
|
||||
name: 'AggressiveConversationBanner',
|
||||
@ -13,7 +14,10 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentAccountId: 'getCurrentAccountId' }),
|
||||
...mapGetters({
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
allConversations: 'getAllConversations',
|
||||
}),
|
||||
hasAlerts() {
|
||||
return this.alerts.length > 0;
|
||||
},
|
||||
@ -67,6 +71,18 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
// Rehidrata o tracker de inatividade toda vez que a lista de conversas
|
||||
// muda (inclusive no boot). Dessa forma, conversas que já estão em
|
||||
// 'open' com o cliente esperando resposta entram no tracker mesmo
|
||||
// quando o usuário só abriu a aba sem receber mensagem ao vivo.
|
||||
allConversations: {
|
||||
handler(conversations) {
|
||||
inactivityAlertTracker.hydrateFromConversations(conversations);
|
||||
},
|
||||
immediate: true,
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
|
||||
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
|
||||
|
||||
@ -12,6 +12,25 @@ const THRESHOLDS = [
|
||||
// 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 }>
|
||||
@ -89,6 +108,64 @@ class InactivityAlertTracker {
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user