diff --git a/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue index b7df23925..03e6e96cd 100644 --- a/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue +++ b/app/javascript/dashboard/components/app/AggressiveConversationBanner.vue @@ -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); diff --git a/app/javascript/dashboard/helper/inactivityAlertTracker.js b/app/javascript/dashboard/helper/inactivityAlertTracker.js index 9ddf9efb3..e152c46ca 100644 --- a/app/javascript/dashboard/helper/inactivityAlertTracker.js +++ b/app/javascript/dashboard/helper/inactivityAlertTracker.js @@ -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, 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();