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>
This commit is contained in:
Rodribm10 2026-04-23 20:49:24 -03:00
parent f35c3ea821
commit 4b0e8c314e
16 changed files with 664 additions and 177 deletions

View File

@ -96,7 +96,8 @@ class Api::V1::AccountsController < Api::BaseController
end
def permitted_settings_attributes
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label,
:aggressive_alert_enabled]
end
def check_signup_enabled

View File

@ -9,6 +9,7 @@ export default {
data() {
return {
alerts: [],
maxLevel: null,
};
},
computed: {
@ -16,38 +17,74 @@ export default {
hasAlerts() {
return this.alerts.length > 0;
},
bannerLabel() {
if (this.alerts.length === 1) {
bannerClass() {
return [
'aggressive-banner',
this.maxLevel ? `aggressive-banner--${this.maxLevel}` : '',
];
},
bannerHeadline() {
const count = this.alerts.length;
if (count === 1) {
const a = this.alerts[0];
if (a.kind === 'reopened') {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_REOPENED',
'Conversa reaberta — responda agora'
);
}
// inactivity mostra tempo
if (a.minutes >= 28) {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_28',
{ minutes: a.minutes },
`🚨 ${a.minutes} MIN SEM RESPOSTA — conversa fecha em breve`
);
}
if (a.minutes >= 15) {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_15',
{ minutes: a.minutes },
`⚠️ ${a.minutes} MIN SEM RESPOSTA`
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.SINGLE',
'Conversa aguardando resposta!'
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_5',
{ minutes: a.minutes },
`${a.minutes} min sem resposta`
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.MULTIPLE',
{ count: this.alerts.length },
`${this.alerts.length} conversas aguardando resposta!`
'AGGRESSIVE_CONVERSATION_BANNER.HEADLINE_MULTIPLE',
{ count },
`🚨 ${count} conversas aguardando resposta`
);
},
explanation() {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.EXPLANATION',
'Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente.'
);
},
},
mounted() {
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.handleTrigger);
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.handleDismiss);
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
emitter.on(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
// Rehidrata se alertas foram disparados antes do componente montar
this.alerts = aggressiveAlert.getActiveConversations();
this.refreshAlerts();
},
beforeUnmount() {
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.handleTrigger);
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.handleDismiss);
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
},
methods: {
handleTrigger() {
this.alerts = aggressiveAlert.getActiveConversations();
},
handleDismiss() {
refreshAlerts() {
this.alerts = aggressiveAlert.getActiveConversations();
this.maxLevel = aggressiveAlert.getMaxLevel();
},
openConversation(alert) {
// Clica no item abre conversa E esconde o alerta dela (mas se
// não responder, volta a aparecer no próximo threshold).
aggressiveAlert.dismiss(alert.id);
if (!this.currentAccountId) return;
this.$router.push({
@ -58,40 +95,42 @@ export default {
dismissOne(alert) {
aggressiveAlert.dismiss(alert.id);
},
dismissAll() {
aggressiveAlert.dismissAll();
alertItemClass(alert) {
return [
'aggressive-banner__item',
`aggressive-banner__item--${alert.level}`,
];
},
alertContextLabel(alert) {
if (alert.kind === 'reopened') {
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.KIND_REOPENED',
'reabriu'
);
}
return this.$t(
'AGGRESSIVE_CONVERSATION_BANNER.KIND_WAITING',
{ minutes: alert.minutes || '?' },
`${alert.minutes || '?'} min sem resposta`
);
},
},
};
</script>
<template>
<div
v-if="hasAlerts"
class="aggressive-banner"
role="alert"
aria-live="assertive"
>
<div class="aggressive-banner__main">
<span class="aggressive-banner__icon">{{
$t('AGGRESSIVE_CONVERSATION_BANNER.ALERT_ICON', '🚨')
}}</span>
<span class="aggressive-banner__title">{{ bannerLabel }}</span>
<button
type="button"
class="aggressive-banner__dismiss-all"
@click="dismissAll"
>
{{
$t('AGGRESSIVE_CONVERSATION_BANNER.DISMISS_ALL', 'Dispensar todas')
}}
</button>
<div v-if="hasAlerts" :class="bannerClass" role="alert" aria-live="assertive">
<div class="aggressive-banner__headline">
{{ bannerHeadline }}
</div>
<div class="aggressive-banner__explanation">
{{ explanation }}
</div>
<ul class="aggressive-banner__list">
<li
v-for="alert in alerts"
:key="alert.id"
class="aggressive-banner__item"
:class="alertItemClass(alert)"
>
<button
type="button"
@ -104,25 +143,25 @@ export default {
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
· {{ alert.inboxName }}
</span>
<span v-if="alert.previousStatus" class="aggressive-banner__previous">
·
{{
$t(
`AGGRESSIVE_CONVERSATION_BANNER.FROM_${alert.previousStatus.toUpperCase()}`,
alert.previousStatus
)
}}
<span class="aggressive-banner__context">
· {{ alertContextLabel(alert) }}
</span>
</button>
<button
type="button"
class="aggressive-banner__close"
:aria-label="
$t('AGGRESSIVE_CONVERSATION_BANNER.DISMISS_ONE', 'Dispensar')
$t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE', 'Esconder')
"
:title="
$t(
'AGGRESSIVE_CONVERSATION_BANNER.HIDE_ONE_TITLE',
'Esconde temporariamente — volta se não responder'
)
"
@click="dismissOne(alert)"
>
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.CLOSE_ICON', '×') }}
{{ $t('AGGRESSIVE_CONVERSATION_BANNER.HIDE_ICON', '×') }}
</button>
</li>
</ul>
@ -130,10 +169,28 @@ export default {
</template>
<style lang="scss" scoped>
@keyframes aggressive-pulse {
@keyframes aggressive-pulse-yellow {
0%,
100% {
background-color: #b91c1c;
background-color: #eab308;
}
50% {
background-color: #fbbf24;
}
}
@keyframes aggressive-pulse-orange {
0%,
100% {
background-color: #c2410c;
}
50% {
background-color: #f97316;
}
}
@keyframes aggressive-pulse-red {
0%,
100% {
background-color: #991b1b;
}
50% {
background-color: #ef4444;
@ -145,45 +202,38 @@ export default {
top: 0;
z-index: 9999;
width: 100%;
background-color: #b91c1c;
color: #ffffff;
padding: 12px 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
animation: aggressive-pulse 1.2s ease-in-out infinite;
font-weight: 600;
padding: 14px 20px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
font-weight: 700;
}
.aggressive-banner__main {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 8px;
.aggressive-banner--yellow {
background-color: #eab308;
color: #1f2937;
}
.aggressive-banner--orange {
background-color: #c2410c;
animation: aggressive-pulse-orange 1.4s ease-in-out infinite;
}
.aggressive-banner--red {
background-color: #991b1b;
animation: aggressive-pulse-red 0.9s ease-in-out infinite;
}
.aggressive-banner__icon {
font-size: 24px;
line-height: 1;
.aggressive-banner__headline {
font-size: 22px;
line-height: 1.2;
margin-bottom: 4px;
letter-spacing: 0.5px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.aggressive-banner__title {
font-size: 16px;
flex: 1;
}
.aggressive-banner__dismiss-all {
background: rgba(255, 255, 255, 0.2);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.5);
border-radius: 4px;
padding: 6px 12px;
cursor: pointer;
.aggressive-banner__explanation {
font-size: 13px;
font-weight: 500;
transition: background 0.15s ease;
}
.aggressive-banner__dismiss-all:hover {
background: rgba(255, 255, 255, 0.35);
opacity: 0.92;
margin-bottom: 10px;
}
.aggressive-banner__list {
@ -198,51 +248,52 @@ export default {
.aggressive-banner__item {
display: flex;
align-items: center;
background: rgba(0, 0, 0, 0.25);
border-radius: 4px;
background: rgba(0, 0, 0, 0.3);
border-radius: 6px;
overflow: hidden;
font-size: 14px;
}
.aggressive-banner__item--yellow {
background: rgba(0, 0, 0, 0.18);
}
.aggressive-banner__open {
background: transparent;
color: #ffffff;
color: inherit;
border: none;
padding: 6px 12px;
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
font-weight: 500;
font-weight: 600;
text-align: left;
display: flex;
align-items: center;
gap: 4px;
}
.aggressive-banner__open:hover {
background: rgba(255, 255, 255, 0.15);
}
.aggressive-banner__contact {
font-weight: 700;
font-weight: 800;
}
.aggressive-banner__inbox,
.aggressive-banner__previous {
opacity: 0.85;
font-weight: 400;
.aggressive-banner__context {
opacity: 0.9;
font-weight: 500;
}
.aggressive-banner__close {
background: transparent;
color: #ffffff;
color: inherit;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.2);
padding: 6px 10px;
border-left: 1px solid rgba(255, 255, 255, 0.25);
padding: 8px 12px;
cursor: pointer;
font-size: 18px;
font-size: 20px;
line-height: 1;
font-weight: 700;
font-weight: 800;
}
.aggressive-banner__close:hover {
background: rgba(255, 255, 255, 0.2);
}

View File

@ -2,6 +2,7 @@ import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import aggressiveAlert from './aggressiveAlert';
import inactivityAlertTracker from './inactivityAlertTracker';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
import { useImpersonation } from 'dashboard/composables/useImpersonation';
@ -108,6 +109,69 @@ class ActionCableConnector extends BaseActionCableConnector {
lastActivityAt,
conversationId,
});
this.feedInactivityTracker(data);
};
// Alimenta o tracker de inatividade:
// - Cliente (Contact) mandou mensagem em conversa open → começa a contar
// - Agente (User/AgentBot/Captain) mandou mensagem → limpa (agente respondeu)
// - Status deixou de ser open → trata como "resolvido", limpa
feedInactivityTracker = data => {
if (!this.isAggressiveAlertEnabled()) return;
const {
conversation_id: conversationId,
message_type: messageType,
sender_type: senderType,
conversation,
} = data;
// message_type: 0=incoming, 1=outgoing, 2=activity, 3=template
// Activity = evento do sistema (status mudou, etc). Ignora.
if (messageType === 2 || messageType === 'activity') return;
// Incoming (cliente) e conversa aberta → começa/renova tracker
const isIncoming = messageType === 0 || messageType === 'incoming';
const conversationStatus = conversation && conversation.status;
if (isIncoming && conversationStatus === 'open') {
const contactName =
conversation && conversation.meta && conversation.meta.sender
? conversation.meta.sender.name
: '';
const inbox = this.app.$store.getters['inboxes/getInbox']
? this.app.$store.getters['inboxes/getInbox'](conversation.inbox_id)
: null;
const inboxName = inbox && inbox.name ? inbox.name : '';
inactivityAlertTracker.onClientMessage({
conversationId,
contactName,
inboxName,
});
return;
}
// Qualquer mensagem do agente/bot → limpa tracker
if (senderType === 'User' || senderType === 'AgentBot') {
inactivityAlertTracker.onAgentReplyOrResolved(conversationId);
}
};
// Lê account.settings.aggressive_alert_enabled + user.ui_settings
isAggressiveAlertEnabled = () => {
const store = this.app.$store;
const account = store.getters.getCurrentAccount;
const user = store.getters.getCurrentUser;
// Default true se settings não vieram ainda (não bloqueia no boot).
const accountEnabled =
!account ||
!account.settings ||
account.settings.aggressive_alert_enabled !== false;
const userEnabled =
!user ||
!user.ui_settings ||
user.ui_settings.aggressive_alert_enabled !== false;
return accountEnabled && userEnabled;
};
// eslint-disable-next-line class-methods-use-this
@ -115,21 +179,21 @@ class ActionCableConnector extends BaseActionCableConnector {
onStatusChange = data => {
this.maybeTriggerAggressiveAlert(data);
// Se saiu de 'open' (resolvida/snoozada/pending), limpa qualquer alerta
// pendente pra essa conversa.
if (data && data.id && data.status && data.status !== 'open') {
inactivityAlertTracker.onAgentReplyOrResolved(data.id);
}
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();
};
// Dispara banner + som + push do SO toda vez que conversa transita
// pra 'open'. Broadcast `conversation.status_changed` só chega em
// mudança real de status, então confiar no evento é suficiente.
//
// Não usar store.getters.getConversationById(id).status pra detectar
// transição: quando o próprio usuário reabre, o dispatch HTTP local
// (toggleStatus→CHANGE_CONVERSATION_STATUS) já mutou o store antes do
// broadcast chegar → previousStatus === 'open' bloqueava o alerta.
// Conversas novas entram via onConversationCreated, não por status_changed.
// Dispara banner RED toda vez que a conversa transita pra 'open'.
// Broadcast `conversation.status_changed` só chega em mudança real,
// então confiar no evento é suficiente.
maybeTriggerAggressiveAlert = data => {
if (!data || data.status !== 'open') return;
if (!this.isAggressiveAlertEnabled()) return;
const store = this.app.$store;
const contactName =
data.meta && data.meta.sender ? data.meta.sender.name : '';
@ -140,9 +204,10 @@ class ActionCableConnector extends BaseActionCableConnector {
aggressiveAlert.trigger({
conversationId: data.id,
level: 'red',
kind: 'reopened',
contactName,
inboxName,
previousStatus: '',
});
};

View File

@ -6,6 +6,19 @@ 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;
@ -36,17 +49,19 @@ class AggressiveAlertManager {
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);
this.audio.loop = true;
}
playSound() {
// 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(() => {
@ -55,18 +70,53 @@ class AggressiveAlertManager {
}
}
// 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) {
const count = this.activeConversations.size;
if (count === 0) {
if (!this.shouldFlashTitle()) {
document.title = this.originalTitle;
return;
}
const count = this.countVisibleAlerts();
document.title = toggle
? `🚨 (${count}) CONVERSA ABERTA`
: this.originalTitle;
@ -74,6 +124,7 @@ class AggressiveAlertManager {
startTitleFlash() {
if (this.titleInterval) return;
if (!this.shouldFlashTitle()) return;
let toggle = false;
this.updateTitleTick(true);
this.titleInterval = setInterval(() => {
@ -90,49 +141,126 @@ class AggressiveAlertManager {
document.title = this.originalTitle;
}
trigger({ conversationId, contactName, inboxName, previousStatus }) {
if (!conversationId) return;
if (this.activeConversations.has(conversationId)) return;
// Re-avalia som + título após mudanças no map (trigger/dismiss/hide).
refreshOutputs() {
const hasLoop = this.hasLoopSound();
const shouldFlash = this.shouldFlashTitle();
this.activeConversations.set(conversationId, {
contactName: contactName || '—',
inboxName: inboxName || '',
previousStatus: previousStatus || '',
triggeredAt: Date.now(),
});
this.playSound();
showOSNotification(
'🚨 Conversa aberta aguardando resposta',
`${contactName || 'Cliente'}${inboxName || ''}`.trim()
);
vibrateDevice();
this.startTitleFlash();
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, {
conversationId,
contactName,
inboxName,
previousStatus,
total: this.activeConversations.size,
});
}
dismiss(conversationId) {
if (!this.activeConversations.has(conversationId)) return;
this.activeConversations.delete(conversationId);
if (this.activeConversations.size === 0) {
this.stopSound();
this.stopTitleFlash();
if (hasLoop) {
this.playLoopSound();
} else {
this.updateTitleTick(true);
this.stopSound();
}
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
conversationId,
total: this.activeConversations.size,
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] - 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() {
@ -140,17 +268,33 @@ class AggressiveAlertManager {
this.activeConversations.clear();
this.stopSound();
this.stopTitleFlash();
emitter.emit(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, {
conversationId: null,
total: 0,
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()).map(([id, data]) => ({
id,
...data,
}));
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);
}
}

View File

@ -0,0 +1,96 @@
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;
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 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 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,
});
});
}
);
}
}
const inactivityAlertTracker = new InactivityAlertTracker();
export default inactivityAlertTracker;

View File

@ -1,13 +1,15 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"SINGLE": "Conversation waiting for a response!",
"MULTIPLE": "{count} conversations waiting for a response!",
"DISMISS_ALL": "Dismiss all",
"DISMISS_ONE": "Dismiss",
"CLOSE_ICON": "×",
"ALERT_ICON": "🚨",
"FROM_PENDING": "from pending",
"FROM_SNOOZED": "reopened (snoozed)",
"FROM_RESOLVED": "reopened (resolved)"
"HEADLINE_REOPENED": "🚨 Conversation reopened — reply now",
"HEADLINE_5": "⏰ {minutes} min without reply",
"HEADLINE_15": "⚠️ {minutes} MIN WITHOUT REPLY — respond!",
"HEADLINE_28": "🚨 {minutes} MIN WITHOUT REPLY — conversation will auto-close!",
"HEADLINE_MULTIPLE": "🚨 {count} conversations awaiting reply",
"EXPLANATION": "This alert only clears when you REPLY to the conversation. Clicking × hides temporarily — it comes back if you do not reply.",
"KIND_REOPENED": "just reopened",
"KIND_WAITING": "{minutes} min without reply",
"HIDE_ONE": "Hide",
"HIDE_ONE_TITLE": "Hide temporarily — comes back if you do not reply",
"HIDE_ICON": "×"
}
}

View File

@ -104,6 +104,14 @@
"ERROR": "Failed to update audio transcription setting"
}
},
"AGGRESSIVE_ALERT": {
"TITLE": "Aggressive conversation alert (master switch)",
"NOTE": "When on, agents receive a banner + sound + OS notification when a conversation is reopened and at 5/15/28 min without reply. Each agent can still turn it off in their profile — this is the account-wide master. Off here = nobody receives.",
"API": {
"SUCCESS": "Aggressive alert setting updated",
"ERROR": "Failed to update aggressive alert setting"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Inactivity duration for resolution",
"HELP": "Duration after a conversation should auto resolve if there is no activity",

View File

@ -35,6 +35,12 @@
}
}
},
"AGGRESSIVE_ALERT": {
"SECTION_TITLE": "Aggressive conversation alert",
"SECTION_NOTE": "Triggers a banner, sound and OS notification when a conversation is reopened and every 5/15/28 minutes without reply. Only clears when YOU reply. Turn off for a silent shift — but own the risk.",
"TITLE": "Receive aggressive alerts",
"NOTE": "When on, you get a banner + sound + notification when a conversation reopens or goes X minutes without reply. Turn off at your own risk."
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Customize the look and feel of your Chatwoot dashboard.",

View File

@ -1,13 +1,15 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"SINGLE": "Conversa aguardando resposta!",
"MULTIPLE": "{count} conversas aguardando resposta!",
"DISMISS_ALL": "Dispensar todas",
"DISMISS_ONE": "Dispensar",
"CLOSE_ICON": "×",
"ALERT_ICON": "🚨",
"FROM_PENDING": "saiu de pendente",
"FROM_SNOOZED": "reaberta (estava adiada)",
"FROM_RESOLVED": "reaberta (estava resolvida)"
"HEADLINE_REOPENED": "🚨 Conversa reaberta — responda agora",
"HEADLINE_5": "⏰ {minutes} min sem resposta",
"HEADLINE_15": "⚠️ {minutes} MIN SEM RESPOSTA — responda!",
"HEADLINE_28": "🚨 {minutes} MIN SEM RESPOSTA — conversa vai fechar!",
"HEADLINE_MULTIPLE": "🚨 {count} conversas aguardando resposta",
"EXPLANATION": "Este alerta só some quando você RESPONDER a conversa. Clicar no × esconde temporariamente — volta se não responder.",
"KIND_REOPENED": "reabriu agora",
"KIND_WAITING": "{minutes} min sem resposta",
"HIDE_ONE": "Esconder",
"HIDE_ONE_TITLE": "Esconde temporariamente — volta se não responder",
"HIDE_ICON": "×"
}
}

View File

@ -104,6 +104,14 @@
"ERROR": "Falha ao atualizar configuração de transcrição de áudio"
}
},
"AGGRESSIVE_ALERT": {
"TITLE": "Alerta agressivo de conversa (master switch)",
"NOTE": "Quando ligado, atendentes recebem banner + som + notificação do SO quando uma conversa é reaberta e a cada 5/15/28 min sem resposta. Cada agente ainda pode desligar pra si no próprio perfil — este toggle é o mestre da conta. Desligar aqui = ninguém recebe.",
"API": {
"SUCCESS": "Alerta agressivo atualizado",
"ERROR": "Falha ao atualizar o alerta agressivo"
}
},
"AUTO_RESOLVE_DURATION": {
"LABEL": "Tempo de inatividade para resolução",
"HELP": "Tempo de inatividade após o qual a conversa deve ser encerrada automaticamente",

View File

@ -35,6 +35,12 @@
}
}
},
"AGGRESSIVE_ALERT": {
"SECTION_TITLE": "Alerta agressivo de conversa",
"SECTION_NOTE": "Ativa banner, som e notificação do SO quando uma conversa é reaberta e a cada 5/15/28 minutos sem resposta. Só some quando VOCÊ responder. Desativa se quiser turno silencioso — mas a casa cai se esquecer.",
"TITLE": "Receber alertas agressivos",
"NOTE": "Se ligado, você recebe banner + som + notificação quando uma conversa é reaberta ou fica X minutos sem resposta. Só desliga se souber o que está fazendo."
},
"INTERFACE_SECTION": {
"TITLE": "Interface",
"NOTE": "Personalize a aparência do seu painel do Chatwoot.",

View File

@ -15,6 +15,7 @@ import AccountId from './components/AccountId.vue';
import BuildInfo from './components/BuildInfo.vue';
import AccountDelete from './components/AccountDelete.vue';
import AudioTranscription from './components/AudioTranscription.vue';
import AggressiveAlertSetting from './components/AggressiveAlertSetting.vue';
import SectionLayout from './components/SectionLayout.vue';
export default {
@ -25,6 +26,7 @@ export default {
BuildInfo,
AccountDelete,
AudioTranscription,
AggressiveAlertSetting,
SectionLayout,
WithLabel,
NextInput,
@ -232,6 +234,7 @@ export default {
<woot-loading-state v-if="uiFlags.isFetchingItem" />
</div>
<AudioTranscription v-if="showAudioTranscriptionConfig" />
<AggressiveAlertSetting />
<AccountId />
<div v-if="!uiFlags.isFetchingItem && isOnChatwootCloud">
<AccountDelete />

View File

@ -0,0 +1,49 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAccount } from 'dashboard/composables/useAccount';
import { useAlert } from 'dashboard/composables';
import SectionLayout from './SectionLayout.vue';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
// Default true quando account ainda não carregou, assume ligado.
const isEnabled = ref(true);
const { currentAccount, updateAccount } = useAccount();
watch(
currentAccount,
() => {
const settings = currentAccount.value?.settings || {};
// Só trata como false se explicitamente false; qualquer outro valor = ligado.
isEnabled.value = settings.aggressive_alert_enabled !== false;
},
{ deep: true, immediate: true }
);
const toggle = async () => {
try {
await updateAccount({
aggressive_alert_enabled: isEnabled.value,
});
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.SUCCESS'));
} catch (error) {
useAlert(t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.API.ERROR'));
}
};
</script>
<template>
<SectionLayout
:title="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE')"
:description="t('GENERAL_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE')"
with-border
>
<template #headerActions>
<div class="flex justify-end">
<Switch v-model="isEnabled" @change="toggle" />
</div>
</template>
</SectionLayout>
</template>

View File

@ -0,0 +1,37 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useUISettings } from 'dashboard/composables/useUISettings';
import Switch from 'next/switch/Switch.vue';
const { t } = useI18n();
const { uiSettings, updateUISettings } = useUISettings();
// Default true só trata como false se estiver explicitamente como false.
const isEnabled = computed({
get() {
return uiSettings.value?.aggressive_alert_enabled !== false;
},
set(value) {
updateUISettings({ aggressive_alert_enabled: value });
},
});
</script>
<template>
<div
class="border border-solid rounded-lg border-n-weak p-4 bg-n-solid-1 flex items-start gap-4"
>
<div class="flex-1">
<h4 class="text-base font-semibold text-n-slate-12 mb-1">
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.TITLE') }}
</h4>
<p class="text-sm text-n-slate-11 leading-normal">
{{ t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.NOTE') }}
</p>
</div>
<div class="pt-1">
<Switch v-model="isEnabled" />
</div>
</div>
</template>

View File

@ -20,6 +20,7 @@ import AudioNotifications from './AudioNotifications.vue';
import FormSection from 'dashboard/components/FormSection.vue';
import AccessToken from './AccessToken.vue';
import MfaSettingsCard from './MfaSettingsCard.vue';
import AggressiveAlertProfileSetting from './AggressiveAlertProfileSetting.vue';
import Policy from 'dashboard/components/policy.vue';
import {
ROLES,
@ -41,6 +42,7 @@ export default {
AudioNotifications,
AccessToken,
MfaSettingsCard,
AggressiveAlertProfileSetting,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@ -242,6 +244,12 @@ export default {
@update-user="updateProfile"
/>
</div>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_TITLE')"
:description="$t('PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT.SECTION_NOTE')"
>
<AggressiveAlertProfileSetting />
</FormSection>
<FormSection
:title="$t('PROFILE_SETTINGS.FORM.INTERFACE_SECTION.TITLE')"
:description="

View File

@ -90,6 +90,7 @@ class Account < ApplicationRecord
store_accessor :settings, :audio_transcriptions, :auto_resolve_label
store_accessor :settings, :captain_models, :captain_features
store_accessor :settings, :keep_pending_on_bot_failure
store_accessor :settings, :aggressive_alert_enabled
has_many :account_users, dependent: :destroy_async
has_many :agent_bot_inboxes, dependent: :destroy_async