iachat/app/javascript/dashboard/helper/aggressiveAlert.js
Rodribm10 4b0e8c314e feat(aggressive-alert): escalada amarelo/laranja/vermelho + toggles
Banner agressivo passa de uma notificação só ("status→open") pra
um sistema de escalada baseado em inatividade quando o cliente é
o último a falar.

Níveis:
- 5 min sem resposta  → AMARELO, sem som
- 15 min sem resposta → LARANJA, beep 1x + notificação do SO
- 28 min sem resposta → VERMELHO pulsante + som em loop infinito
- status→open (reabertura) → VERMELHO imediato

Por conversa, o banner mostra um item com nome do contato, inbox
e contexto ("reabriu agora" / "15 min sem resposta"). Headline
grande e explicação clara sobre como limpa.

Comportamento do × dismiss:
- Antes: apagava o alerta de vez. Agente podia "fingir que viu".
- Agora: esconde temporariamente. Volta quando escalar (próximo
  threshold) ou nova mensagem. A única forma de LIMPAR de vez é
  responder o cliente (tracker detecta msg outgoing do User ou
  AgentBot e chama dismissForReply).

Permissões:
- account.settings.aggressive_alert_enabled (master switch admin)
- user.ui_settings.aggressive_alert_enabled (toggle do próprio agente)
- Default true pros dois; um false em qualquer bloqueia alertas.

Settings UI:
- Conta → General: novo card "Alerta agressivo (master switch)"
- Perfil do usuário: novo card "Receber alertas agressivos"

Arquivos:
- helper/aggressiveAlert.js: multi-level state, hide vs dismiss-for-reply
- helper/inactivityAlertTracker.js: timer único, thresholds declarativos
- helper/actionCable.js: hook em onMessageCreated (feed tracker) +
  isAggressiveAlertEnabled() + limpa tracker em status_changed != open
- components/app/AggressiveConversationBanner.vue: variantes de cor,
  headline grande, explanation, × temp-hide
- account.rb + accounts_controller.rb: store_accessor + permitted
- settings UI components (account + profile): switches auto-persist

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

304 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
const ALERT_AUDIO_PATH = '/audio/dashboard/bell.mp3';
const VIBRATION_PATTERN = [500, 200, 500, 200, 500];
const TITLE_FLASH_INTERVAL_MS = 1000;
const NOTIFICATION_TAG = 'chatwoot-aggressive-alert';
// Níveis de severidade — ordem numérica cresce com a urgência.
export const LEVEL = {
YELLOW: 'yellow',
ORANGE: 'orange',
RED: 'red',
};
const LEVEL_SEVERITY = {
[LEVEL.YELLOW]: 1,
[LEVEL.ORANGE]: 2,
[LEVEL.RED]: 3,
};
const showOSNotification = (title, body) => {
if (typeof window === 'undefined' || !('Notification' in window)) return;
if (Notification.permission !== 'granted') return;
try {
// eslint-disable-next-line no-new
new Notification(title, {
body,
tag: NOTIFICATION_TAG,
requireInteraction: true,
renotify: true,
});
} catch (e) {
// Safari iOS lança TypeError no construtor; banner visual + som cobrem.
}
};
const vibrateDevice = () => {
if (
typeof navigator !== 'undefined' &&
typeof navigator.vibrate === 'function'
) {
navigator.vibrate(VIBRATION_PATTERN);
}
};
class AggressiveAlertManager {
constructor() {
this.audio = null;
this.titleInterval = null;
this.originalTitle = typeof document !== 'undefined' ? document.title : '';
// Map<conversationId, { level, kind, contactName, inboxName, minutes, triggeredAt, temporarilyHidden }>
this.activeConversations = new Map();
}
ensureAudio() {
if (this.audio) return;
this.audio = new Audio(ALERT_AUDIO_PATH);
}
// Som em loop infinito (usado pro nível RED — urgência máxima)
playLoopSound() {
this.ensureAudio();
this.audio.loop = true;
const playPromise = this.audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {
// Autoplay bloqueado pelo browser — banner visual permanece.
});
}
}
// Som 1x (usado pro ORANGE — chama atenção mas não satura)
playOnceSound() {
// Se já está tocando em loop pra outro alerta, não interfere.
if (this.hasLoopSound()) return;
this.ensureAudio();
this.audio.loop = false;
const playPromise = this.audio.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(() => {});
}
}
hasLoopSound() {
// Loop está ativo se algum alerta no map tem level === RED e não está hidden.
return Array.from(this.activeConversations.values()).some(
entry => entry.level === LEVEL.RED && !entry.temporarilyHidden
);
}
stopSound() {
if (!this.audio) return;
this.audio.pause();
this.audio.currentTime = 0;
this.audio.loop = false;
}
// O título pisca se existir pelo menos 1 alerta visível com level ORANGE ou RED.
shouldFlashTitle() {
return Array.from(this.activeConversations.values()).some(
entry =>
!entry.temporarilyHidden &&
(entry.level === LEVEL.ORANGE || entry.level === LEVEL.RED)
);
}
countVisibleAlerts() {
return Array.from(this.activeConversations.values()).filter(
entry => !entry.temporarilyHidden
).length;
}
updateTitleTick(toggle) {
if (!this.shouldFlashTitle()) {
document.title = this.originalTitle;
return;
}
const count = this.countVisibleAlerts();
document.title = toggle
? `🚨 (${count}) CONVERSA ABERTA`
: this.originalTitle;
}
startTitleFlash() {
if (this.titleInterval) return;
if (!this.shouldFlashTitle()) return;
let toggle = false;
this.updateTitleTick(true);
this.titleInterval = setInterval(() => {
toggle = !toggle;
this.updateTitleTick(toggle);
}, TITLE_FLASH_INTERVAL_MS);
}
stopTitleFlash() {
if (this.titleInterval) {
clearInterval(this.titleInterval);
this.titleInterval = null;
}
document.title = this.originalTitle;
}
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
refreshOutputs() {
const hasLoop = this.hasLoopSound();
const shouldFlash = this.shouldFlashTitle();
if (hasLoop) {
this.playLoopSound();
} else {
this.stopSound();
}
if (shouldFlash) {
this.startTitleFlash();
} else {
this.stopTitleFlash();
}
}
/**
* Dispara ou escala um alerta.
* @param {Object} opts
* @param {number|string} opts.conversationId
* @param {string} opts.level - LEVEL.YELLOW | LEVEL.ORANGE | LEVEL.RED
* @param {string} opts.kind - 'reopened' | 'inactivity'
* @param {string} [opts.contactName]
* @param {string} [opts.inboxName]
* @param {number} [opts.minutes] - só pra inactivity (5/15/28)
*/
trigger({
conversationId,
level = LEVEL.RED,
kind = 'reopened',
contactName,
inboxName,
minutes,
}) {
if (!conversationId) return;
const existing = this.activeConversations.get(conversationId);
// Escalada: se já existe e o novo level é MENOS severo, ignora.
// Se for mais severo, atualiza (ex: yellow → orange, inactivity).
if (existing) {
const currentSev = LEVEL_SEVERITY[existing.level] || 0;
const incomingSev = LEVEL_SEVERITY[level] || 0;
// Se o alerta tá "escondido temporariamente" e chegou novo, desesconde.
if (incomingSev >= currentSev || existing.temporarilyHidden) {
this.activeConversations.set(conversationId, {
...existing,
level: incomingSev > currentSev ? level : existing.level,
kind: incomingSev > currentSev ? kind : existing.kind,
minutes: incomingSev > currentSev ? minutes : existing.minutes,
contactName: contactName || existing.contactName,
inboxName: inboxName || existing.inboxName,
temporarilyHidden: false,
triggeredAt: Date.now(),
});
}
} else {
this.activeConversations.set(conversationId, {
level,
kind,
contactName: contactName || '—',
inboxName: inboxName || '',
minutes: minutes || null,
triggeredAt: Date.now(),
temporarilyHidden: false,
});
}
// Som por nível
if (level === LEVEL.RED) {
this.playLoopSound();
} else if (level === LEVEL.ORANGE) {
this.playOnceSound();
}
// YELLOW: sem som
if (level === LEVEL.ORANGE || level === LEVEL.RED) {
showOSNotification(
'🚨 Conversa aguardando resposta',
`${contactName || 'Cliente'}${inboxName || ''}`.trim()
);
vibrateDevice();
}
this.startTitleFlash();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, conversationId);
}
/**
* × — dismiss temporário. Remove do visual mas mantém no map como "hidden".
* Volta a aparecer se escalar (receber mais severo) ou receber nova mensagem.
* Pra limpar de verdade, o agente tem que responder (então o tracker chama
* dismissForReply).
*/
hide(conversationId) {
const entry = this.activeConversations.get(conversationId);
if (!entry) return;
this.activeConversations.set(conversationId, {
...entry,
temporarilyHidden: true,
});
this.refreshOutputs();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
}
/**
* Dismiss definitivo — chamado quando o agente respondeu ou o tracker
* detectou que o cliente não é mais o último a mandar.
*/
dismissForReply(conversationId) {
if (!this.activeConversations.has(conversationId)) return;
this.activeConversations.delete(conversationId);
this.refreshOutputs();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, conversationId);
}
// Mesmo que hide, mas pra API pública (botão × do banner)
dismiss(conversationId) {
this.hide(conversationId);
}
dismissAll() {
if (this.activeConversations.size === 0) return;
this.activeConversations.clear();
this.stopSound();
this.stopTitleFlash();
this.emitBusEvent(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, null);
}
emitBusEvent(event, conversationId) {
emitter.emit(event, {
conversationId,
total: this.countVisibleAlerts(),
});
}
getActiveConversations() {
return Array.from(this.activeConversations.entries())
.filter(([, data]) => !data.temporarilyHidden)
.map(([id, data]) => ({ id, ...data }));
}
// Level mais alto entre os alertas visíveis — o banner usa pra cor do wrapper.
getMaxLevel() {
const visible = Array.from(this.activeConversations.values()).filter(
entry => !entry.temporarilyHidden
);
if (visible.length === 0) return null;
return visible.reduce((winner, entry) => {
const sevWinner = LEVEL_SEVERITY[winner] || 0;
const sevEntry = LEVEL_SEVERITY[entry.level] || 0;
return sevEntry > sevWinner ? entry.level : winner;
}, null);
}
}
const aggressiveAlert = new AggressiveAlertManager();
export default aggressiveAlert;