Compare commits

..

8 Commits

Author SHA1 Message Date
Codex CLI
abf9f4057e review: auto-review do Captain em 2026-05-01
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-05-01 03:08:36 +00:00
Codex CLI
7d03430113 review: auto-review do Captain em 2026-04-28
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-04-28 03:04:01 +00:00
Codex CLI
39bda94b93 review: auto-review do Captain em 2026-04-25
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
2026-04-25 03:12:19 +00:00
Codex CLI
1adc79320a feat(captain): aplica pernoite sem café = padrão − R$10 (todos os 4 hotéis)
Some checks failed
Build and Push to GHCR (multi-arch) / build (linux/amd64, ubuntu-latest) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / build (linux/arm64, ubuntu-22.04-arm) (push) Has been cancelled
Build and Push to GHCR (multi-arch) / merge (push) Has been cancelled
Aprovado pelo Rodrigo via comentário no Multica issue ad2ad5ae (2026-04-23T18:00).

Mudanças aplicadas:
- [pergunta 1] pernoite sem café custa R$10 a menos que pernoite c/ café
  Afeta: jasmine_primeal, jasmine_primevl, jasmine_qnn01, jasmine_express

Co-Authored-By: Captain Reviewer <captain@hoteis1001noites.com.br>
2026-04-23 18:02:41 +00:00
Codex CLI
645ae4fec7 review: registra todas as rejeições de Rodrigo + resposta Pergunta 1 (pernoite sem café = -R$10) 2026-04-23 17:43:17 +00:00
Codex CLI
3d6e16f5f1 review: marca Padrão 1 e Padrão 2 como REJEITADOS por Rodrigo (2026-04-23) 2026-04-23 17:41:25 +00:00
Codex CLI
bf09e76eae review: auto-review do Captain em 2026-04-23 (v2 — 7 padrões) 2026-04-23 17:32:52 +00:00
Codex CLI
6e7bcc9b44 review: auto-review do Captain em 2026-04-23 2026-04-23 17:01:14 +00:00
226 changed files with 2562 additions and 15611 deletions

View File

@ -137,4 +137,3 @@ Thanks goes to all these [wonderful people](https://www.chatwoot.com/docs/contri
*Chatwoot* &copy; 2017-2026, Chatwoot Inc - Released under the MIT License.
<!-- Status: integração Mattermost ativa -->

View File

@ -12,24 +12,14 @@ class V2::Reports::BotMetricsBuilder
conversation_count: bot_conversations.count,
message_count: bot_messages.count,
resolution_rate: bot_resolution_rate.to_i,
handoff_rate: total_handoff_rate.to_i,
bot_resolutions_count: bot_resolutions_count,
auto_handoffs_count: auto_handoffs_count,
manual_takeovers_count: manual_takeovers_count
handoff_rate: bot_handoff_rate.to_i
}
end
private
def filter_inbox_id
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
end
def bot_activated_inbox_ids
@bot_activated_inbox_ids ||= begin
ids = account.inboxes.filter(&:active_bot?).map(&:id)
filter_inbox_id ? ids & [filter_inbox_id] : ids
end
@bot_activated_inbox_ids ||= account.inboxes.filter(&:active_bot?).map(&:id)
end
def bot_conversations
@ -40,47 +30,14 @@ class V2::Reports::BotMetricsBuilder
@bot_messages ||= account.messages.outgoing.where(conversation_id: bot_conversations.ids).where(created_at: range)
end
def base_reporting_events
scope = account.reporting_events.where(account_id: account.id, created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope
end
def bot_resolutions_count
@bot_resolutions_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_resolved)
.distinct.count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_resolved,
created_at: range).distinct.count
end
# Auto handoff = Jasmine called bot_handoff! explicitly (loop, timeout, max_turns, intent)
def auto_handoffs_count
@auto_handoffs_count ||= base_reporting_events.joins(:conversation)
.select(:conversation_id)
.where(name: :conversation_bot_handoff)
.distinct.count
end
# Manual takeover = a human replied (via Chatwoot UI or WhatsApp echo) WITHOUT a bot_handoff
# event being emitted for the same conversation. The bot itself uses sender_type 'Captain::Assistant',
# so it's never counted here.
def manual_takeovers_count
@manual_takeovers_count ||= begin
conv_ids_with_human_reply = bot_conversations
.joins(:messages)
.where(messages: { message_type: :outgoing })
.where('messages.sender_type = ? OR messages.sender_type IS NULL', 'User')
.distinct
.pluck(:id)
conv_ids_with_auto_handoff = ReportingEvent.unscope(:order)
.where(name: 'conversation_bot_handoff',
conversation_id: conv_ids_with_human_reply)
.distinct
.pluck(:conversation_id)
(conv_ids_with_human_reply - conv_ids_with_auto_handoff).count
end
def bot_handoffs_count
account.reporting_events.joins(:conversation).select(:conversation_id).where(account_id: account.id, name: :conversation_bot_handoff,
created_at: range).distinct.count
end
def bot_resolution_rate
@ -89,10 +46,9 @@ class V2::Reports::BotMetricsBuilder
bot_resolutions_count.to_f / bot_conversations.count * 100
end
# Total handoff = auto + manual (the gear that closes the math now)
def total_handoff_rate
def bot_handoff_rate
return 0 if bot_conversations.count.zero?
(auto_handoffs_count + manual_takeovers_count).to_f / bot_conversations.count * 100
bot_handoffs_count.to_f / bot_conversations.count * 100
end
end

View File

@ -1,97 +0,0 @@
class V2::Reports::ConversionFunnelBuilder
include DateRangeHelper
# Reservation statuses we treat as "paid" — covers PIX (Inter), payments at the
# reception, card on arrival, etc. Anything that means the booking went through.
PAID_STATUSES = %w[active completed confirmed].freeze
# Statuses we ignore from "created" (drafts are pre-save, never went live)
IGNORED_CREATED_STATUSES = %w[draft].freeze
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def metrics
{
leads: leads_breakdown,
reservations: reservations_breakdown,
conversion_rates: conversion_rates_breakdown
}
end
private
def filter_inbox_id
@filter_inbox_id ||= params[:inbox_id].presence&.to_i
end
def conversations_in_period
@conversations_in_period ||= begin
scope = account.conversations.where(created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope
end
end
def reservations_in_period
@reservations_in_period ||= begin
scope = account.captain_reservations.where(created_at: range)
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope.where.not(status: IGNORED_CREATED_STATUSES)
end
end
# Leads classified using the same logic as the "Novas × Retorno" tab:
# new = no prior conversation in any inbox of the account
# returning = had a prior conversation
def leads_breakdown
total = conversations_in_period.count
return { total: 0, new: 0, returning: 0 } if total.zero?
conv_with_prior_ids = conversations_in_period
.joins('INNER JOIN conversations prev ON prev.contact_id = conversations.contact_id ' \
'AND prev.account_id = conversations.account_id ' \
'AND prev.id < conversations.id')
.distinct
.pluck(:id)
returning = conv_with_prior_ids.size
{
total: total,
new: total - returning,
returning: returning
}
end
def reservations_breakdown
created = reservations_in_period.count
paid = reservations_in_period.where(status: PAID_STATUSES).count
{
created: created,
paid: paid
}
end
def conversion_rates_breakdown
leads_total = conversations_in_period.count
reservations_paid = reservations_in_period.where(status: PAID_STATUSES).count
reservations_created = reservations_in_period.count
{
lead_to_paid_reservation: percent(reservations_paid, leads_total),
lead_to_any_reservation: percent(reservations_created, leads_total),
created_to_paid: percent(reservations_paid, reservations_created)
}
end
def percent(numerator, denominator)
return 0 if denominator.to_i.zero?
(numerator.to_f / denominator * 100).round(1)
end
end

View File

@ -1,79 +0,0 @@
class V2::Reports::InboxBenchmarkingBuilder
include DateRangeHelper
PAID_STATUSES = V2::Reports::ConversionFunnelBuilder::PAID_STATUSES
IGNORED_CREATED_STATUSES = V2::Reports::ConversionFunnelBuilder::IGNORED_CREATED_STATUSES
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
# Returns one row per inbox of the account, with leads + reservations + rate,
# plus the brand name so the frontend can group by brand for benchmarking.
def build
return [] if range.blank?
inbox_brand_lookup = build_inbox_brand_lookup
account.inboxes.map do |inbox|
brand_name = inbox_brand_lookup[inbox.id]
leads_total = leads_count_by_inbox[inbox.id] || 0
reservations_created = reservations_created_by_inbox[inbox.id] || 0
reservations_paid = reservations_paid_by_inbox[inbox.id] || 0
{
inbox_id: inbox.id,
inbox_name: inbox.name,
brand_name: brand_name,
leads_total: leads_total,
reservations_created: reservations_created,
reservations_paid: reservations_paid,
conversion_rate: percent(reservations_paid, leads_total)
}
end
end
private
def leads_count_by_inbox
@leads_count_by_inbox ||= account.conversations
.where(created_at: range)
.group(:inbox_id)
.count
end
def reservations_created_by_inbox
@reservations_created_by_inbox ||= account.captain_reservations
.where(created_at: range)
.where.not(status: IGNORED_CREATED_STATUSES)
.group(:inbox_id)
.count
end
def reservations_paid_by_inbox
@reservations_paid_by_inbox ||= account.captain_reservations
.where(created_at: range, status: PAID_STATUSES)
.group(:inbox_id)
.count
end
# inbox_id => brand_name (or nil when there is no brand mapped for this inbox)
def build_inbox_brand_lookup
rows = Captain::UnitInbox
.joins(captain_unit: :brand)
.where(inbox_id: account.inboxes.select(:id))
.pluck('captain_unit_inboxes.inbox_id, captain_brands.name')
rows.to_h
end
def percent(numerator, denominator)
return 0 if denominator.to_i.zero?
(numerator.to_f / denominator * 100).round(1)
end
end

View File

@ -1,111 +0,0 @@
class V2::Reports::InboxLeadsSummaryBuilder
include DateRangeHelper
ALLOWED_GROUP_BY = %w[day week month].freeze
RETURN_THRESHOLD = '24 hours'.freeze
attr_reader :account, :params
def initialize(account, params)
@account = account
@params = params
end
def build
return [] if range.blank? || inbox.blank?
rows = ActiveRecord::Base.connection.exec_query(
ActiveRecord::Base.sanitize_sql_array([sql, sql_bindings])
)
rows.map do |row|
{
period: row['period'].iso8601,
new_leads: row['new_leads'].to_i,
returning: row['returning'].to_i,
others: row['others'].to_i
}
end
end
private
def inbox
@inbox ||= account.inboxes.find_by(id: params[:inbox_id])
end
def group_by
value = params[:group_by].to_s
ALLOWED_GROUP_BY.include?(value) ? value : 'day'
end
def sql_bindings
{
account_id: account.id,
inbox_id: inbox.id,
since: range.begin,
until_t: range.end,
group_by: group_by,
return_threshold: RETURN_THRESHOLD
}
end
# Single CTE to classify each conversation in the period as:
# * new_leads: contact has no prior conversation in any inbox of the account
# * returning: contact had a prior conversation whose latest 'conversation_resolved'
# event occurred more than 24h before the new conversation
# * others: prior conversation existed but was not resolved or was resolved <24h ago
# rubocop:disable Metrics/MethodLength
def sql
<<~SQL.squish
WITH period_conversations AS (
SELECT id, contact_id, created_at
FROM conversations
WHERE account_id = :account_id
AND inbox_id = :inbox_id
AND created_at >= :since
AND created_at < :until_t
),
classified AS (
SELECT
c.id,
c.created_at,
EXISTS (
SELECT 1 FROM conversations prev
WHERE prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS has_prior,
(
SELECT MAX(re.created_at)
FROM reporting_events re
INNER JOIN conversations prev ON prev.id = re.conversation_id
WHERE re.name = 'conversation_resolved'
AND prev.contact_id = c.contact_id
AND prev.account_id = :account_id
AND prev.id < c.id
) AS latest_prior_resolution_at
FROM period_conversations c
)
SELECT
date_trunc(:group_by, created_at) AS period,
COUNT(*) FILTER (WHERE NOT has_prior) AS new_leads,
COUNT(*) FILTER (
WHERE has_prior
AND latest_prior_resolution_at IS NOT NULL
AND latest_prior_resolution_at < created_at - (:return_threshold)::interval
) AS returning,
COUNT(*) FILTER (
WHERE has_prior
AND (
latest_prior_resolution_at IS NULL
OR latest_prior_resolution_at >= created_at - (:return_threshold)::interval
)
) AS others
FROM classified
GROUP BY period
ORDER BY period
SQL
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -23,9 +23,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def update
user_attrs = agent_params.slice(:name).to_h.compact.symbolize_keys
user_attrs[:ui_settings] = merged_ui_settings if agent_params[:ui_settings].present?
@agent.update!(user_attrs) if user_attrs.any?
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
end
@ -74,19 +72,13 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
end
def allowed_agent_params
[:name, :email, :role, :availability, :auto_offline, { ui_settings: [:aggressive_alert_inbox_ids_mode, { aggressive_alert_inbox_ids: [] }] }]
[:name, :email, :role, :availability, :auto_offline]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
end
def merged_ui_settings
existing = @agent.ui_settings || {}
incoming = agent_params[:ui_settings].to_h.deep_stringify_keys
existing.merge(incoming)
end
def new_agent_params
params.require(:agent).permit(:email, :name, :role, :availability, :auto_offline)
end

View File

@ -1,6 +1,13 @@
class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Accounts::BaseController
def index
insights = filtered_insights.order(period_start: :desc).limit(12)
unit_id = params[:unit_id].present? ? params[:unit_id].to_i : nil
inbox_id = params[:inbox_id].present? ? params[:inbox_id].to_i : nil
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: unit_id) if unit_id
scope = scope.where(inbox_id: inbox_id) if inbox_id
insights = scope.order(period_start: :desc).limit(12)
render json: insights.map { |i| format_insight(i) }
end
@ -32,22 +39,6 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
private
def filtered_insights
scope = Captain::ConversationInsight.where(account_id: Current.account.id)
scope = scope.where(captain_unit_id: filter_unit_id) if filter_unit_id
scope = scope.where(inbox_id: filter_inbox_id) if filter_inbox_id
scope = scope.for_period(*requested_period) if requested_period
scope
end
def filter_unit_id
params[:unit_id].presence&.to_i
end
def filter_inbox_id
params[:inbox_id].presence&.to_i
end
def enqueue_insight(unit_id, inbox_id, period_start, period_end)
insight = find_or_init_insight(unit_id, inbox_id, period_start, period_end)
return render json: { status: 'processing', message: 'Análise já está em andamento' } if insight.processing?
@ -86,14 +77,6 @@ class Api::V1::Accounts::Captain::Reports::InsightsController < Api::V1::Account
default
end
def requested_period
return nil if params[:period_start].blank? || params[:period_end].blank?
[Date.parse(params[:period_start].to_s), Date.parse(params[:period_end].to_s)]
rescue ArgumentError, TypeError
nil
end
def format_insight(insight)
{
id: insight.id,

View File

@ -96,8 +96,7 @@ 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,
:aggressive_alert_enabled]
[:auto_resolve_after, :auto_resolve_message, :auto_resolve_ignore_waiting, :audio_transcriptions, :auto_resolve_label]
end
def check_signup_enabled

View File

@ -1,4 +1,3 @@
# rubocop:disable Metrics/ClassLength
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
@ -59,7 +58,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
end
def bot_metrics
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, bot_metrics_params).metrics
bot_metrics = V2::Reports::BotMetricsBuilder.new(Current.account, params).metrics
render json: bot_metrics
end
@ -88,23 +87,6 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
render json: builder.build
end
def inbox_leads_summary
return head :unprocessable_entity if params[:inbox_id].blank?
builder = V2::Reports::InboxLeadsSummaryBuilder.new(Current.account, inbox_leads_summary_params)
render json: builder.build
end
def conversion_funnel
builder = V2::Reports::ConversionFunnelBuilder.new(Current.account, conversion_funnel_params)
render json: builder.metrics
end
def inbox_benchmarking
builder = V2::Reports::InboxBenchmarkingBuilder.new(Current.account, inbox_benchmarking_params)
render json: builder.build
end
private
def generate_csv(filename, template)
@ -206,37 +188,4 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
until: params[:until]
}
end
def bot_metrics_params
{
inbox_id: params[:inbox_id],
since: params[:since],
until: params[:until]
}
end
def inbox_leads_summary_params
{
inbox_id: params[:inbox_id],
group_by: params[:group_by],
since: params[:since],
until: params[:until]
}
end
def conversion_funnel_params
{
inbox_id: params[:inbox_id],
since: params[:since],
until: params[:until]
}
end
def inbox_benchmarking_params
{
since: params[:since],
until: params[:until]
}
end
end
# rubocop:enable Metrics/ClassLength

View File

@ -1,43 +0,0 @@
# Recebe callback do Hermes Construtor (plugin captain-http-callback).
#
# Construtor responde async via POST pra esta URL com:
# { content: "<resposta>", reply_to: ..., metadata: {...}, timestamp: ... }
#
# Este controller identifica a sessão do admin (por session_id no metadata
# OU pelo cache key derivado de account_id que veio na query string) e
# armazena a resposta no Rails.cache pra UI poder ler via polling.
class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
def process_payload
content = params[:content].to_s.strip
return head :bad_request if content.blank?
session_key = resolve_session_key
if session_key.blank?
Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando')
return head :ok
end
HermesBuilder::Storage.append(session_key, role: 'construtor', content: content)
Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)")
head :ok
rescue StandardError => e
Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}")
head :internal_server_error
end
private
# Hermes nao propaga chat_id no metadata da resposta de callback, entao
# usamos a ultima sessao ativa do account (gravada por
# HermesBuilder::Storage.remember_last_session no /start e /create).
# MVP-safe pra 1 admin por vez por conta.
def resolve_session_key
account_id = params[:account_id]
return nil if account_id.blank?
HermesBuilder::Storage.last_session_for(account_id)
end
end

View File

@ -1,226 +0,0 @@
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
#
# Fluxo:
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
# 2. Hermes processa via subscription Codex/etc dele.
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
# 4. Este controller cria a mensagem outgoing na conversation correta.
#
# Identificação da conversation: como o Hermes não preserva metadata customizado
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
class Webhooks::Captain::HermesCallbackController < ApplicationController
RECENT_WINDOW = 5.minutes
# "Um momento — vou verificar" é a frase-âncora de handoff intencional
# (quando o agente não sabe responder e quer escalar pra humano). NÃO
# bloqueamos — entregamos pro cliente e marcamos triagem_humana pra
# próximas msgs não dispararem Hermes.
HANDOFF_PATTERNS = [
/\A\s*[⏳⌛]?\s*um\s+momento.*verificar/i,
/\A\s*[⏳⌛]?\s*um\s+instante.*verificar/i,
/\A\s*aguarde\s+um\s+instante/i
].freeze
# Loop detection: 2 sinais combinados.
# 1. Jaccard de tokens >= 0.50 → resposta praticamente igual.
# 2. >= 3 palavras-chave em comum (sem stopwords) E ambas inquisitivas →
# repetiu pergunta sobre o mesmo tópico.
LOOP_SIMILARITY_THRESHOLD = 0.50
LOOP_TOPIC_KEYWORD_OVERLAP = 3
LOOP_STOPWORDS = %w[
voce voces para por pra como mas isso esse essa estou esta este aqui ali
eles elas tem ter tinha tendo era ser sou foi fui agora ainda ja muito mais
quer quero queria pode posso podia consegue consigo conseguia preciso precisar
sim nao não talvez bom boa olha veja oi ola ola tchau certo ok blz beleza
obrigado obrigada valeu vlw thanks por favor please
apenas somente algum alguma quem onde quando o a os as do da dos das no na nos nas
em com sem sob sobre antes apos depois entre meio tudo todo toda
perfeito otimo certinho confirma confirme
].freeze
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
before_action :fetch_inbox
def process_payload
content = extract_content
return head :bad_request if content.blank?
conversation = recent_conversation_for(@inbox)
return log_no_conversation_and_ack if conversation.blank?
log_reply(conversation, content)
detect_handoff_or_loop(conversation, content)
deliver_outgoing(conversation, content)
head :ok
rescue StandardError => e
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
head :internal_server_error
end
private
# Hermes mandou frase-âncora de handoff: entrega ao cliente normalmente,
# mas marca conv pra triagem humana — próximas msgs não disparam Hermes
# de novo (guard em OutgoingJob). OU: detectou loop (mesma resposta /
# pergunta reformulada) e escala.
def detect_handoff_or_loop(conversation, content)
if handoff_response?(content)
mark_for_human_triage(conversation, reason: 'handoff_intencional')
elsif looped_response?(conversation, content)
mark_for_human_triage(conversation, reason: 'loop_detectado')
end
end
def deliver_outgoing(conversation, content)
if defined?(Captain::Hermes::DelayedReplyJob)
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
else
create_outgoing_message(conversation, content)
end
end
def handoff_response?(content)
return false if content.blank?
HANDOFF_PATTERNS.any? { |re| content.match?(re) }
end
# Detecta loop: a resposta atual do Hermes é muito parecida com a anterior
# outgoing dele na mesma conv (Jaccard de tokens >= 0.50). Sinaliza que o
# agente está repetindo pergunta/resposta sem progredir — geralmente
# cliente fora do escopo (operadora telefonia, banco, suporte de outro
# app, etc) OU fluxo travado.
def looped_response?(conversation, content)
prev = conversation.messages
.where(message_type: :outgoing)
.where("content_attributes ->> 'external_source' = ?", 'hermes_callback')
.reorder(created_at: :desc)
.limit(1)
.pick(:content)
return false if prev.blank?
return true if similarity(content, prev) >= LOOP_SIMILARITY_THRESHOLD
repeated_question?(content, prev)
end
def similarity(text_a, text_b)
set_a = tokenize(text_a)
set_b = tokenize(text_b)
return 0.0 if set_a.empty? || set_b.empty?
intersection = (set_a & set_b).size
union = (set_a | set_b).size
intersection.to_f / union
end
# Pergunta/confirmação reformulada sobre o mesmo tópico. Detecta tanto "?"
# quanto formas imperativas comuns ("me confirma", "qual", "quer").
def repeated_question?(text_a, text_b)
return false unless inquisitive?(text_a) && inquisitive?(text_b)
keywords_a = tokenize(text_a) - LOOP_STOPWORDS
keywords_b = tokenize(text_b) - LOOP_STOPWORDS
(keywords_a & keywords_b).size >= LOOP_TOPIC_KEYWORD_OVERLAP
end
INQUISITIVE_REGEX = /(\?|\bme\s+confirm|\bvoce\s+(prefere|quer)|\bqual\s+(prefere|deseja|seria)|\bquer\s+(que|saber|ver|um|uma))/i
def inquisitive?(text)
INQUISITIVE_REGEX.match?(ActiveSupport::Inflector.transliterate(text.to_s))
end
def tokenize(text)
normalized = ActiveSupport::Inflector.transliterate(text.to_s.downcase)
normalized.scan(/[a-z0-9]+/).reject { |w| w.length < 3 }.to_set
end
def mark_for_human_triage(conversation, reason: nil)
current = conversation.label_list
conversation.update_labels((current + %w[triagem_humana]).uniq)
Rails.logger.info("[Hermes::Callback] conv #{conversation.display_id} → triagem_humana (#{reason})")
end
def fetch_inbox
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
if inbox_id.present?
@inbox = Inbox.find_by(id: inbox_id)
elsif (slug = params[:slug].presence)
# Resolve via slug (hermes_profile_name) — admin pode re-apontar a
# inbox pra qualquer agente Hermes sem mexer em URL de callback.
asst = Captain::Assistant.find_by(hermes_profile_name: slug, engine: 'hermes')
ci = asst&.captain_inboxes&.first
@inbox = ci&.inbox
end
head :not_found if @inbox.blank?
end
def verify_signature
secret = Captain::Hermes.callback_signing_secret
return true if secret.blank? # validação desabilitada (PoC sem secret)
signature = request.headers['X-Hermes-Callback-Signature'].to_s
return head :unauthorized if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
true
end
def recent_conversation_for(inbox)
inbox.conversations
.where('updated_at >= ?', RECENT_WINDOW.ago)
.where(status: %w[pending open])
.reorder(updated_at: :desc)
.first
end
def log_no_conversation_and_ack
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
head :ok
end
def extract_content
normalize_for_whatsapp(params[:content].to_s.strip)
end
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
# **negrito** -> *negrito*
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
def normalize_for_whatsapp(content)
return content if content.blank?
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
end
def log_reply(conversation, content)
Rails.logger.info(
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
)
end
def create_outgoing_message(conversation, content)
assistant = conversation.inbox.captain_assistant
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: sender,
content: content,
content_attributes: {
external_source: 'hermes_callback'
}
)
end
end

View File

@ -1,110 +0,0 @@
# Endpoint MCP (Model Context Protocol) HTTP do Captain.
#
# POST /webhooks/captain/mcp
#
# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do
# Captain (add_label, faq_lookup, generate_pix, etc).
#
# Conexão pelo Hermes:
# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp
#
# Auth: aceita 2 modos (qualquer um basta):
# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer <CAPTAIN_MCP_SECRET>`
# É o que `hermes mcp add --auth header` usa nativamente.
# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=<hex>`
# Para clientes que preferem assinar o body inteiro.
# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio,
# validação é desabilitada (PoC/dev).
#
# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id,
# inbox_id, account_id) num campo de extensão chamado `_captain_context`
# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem
# esse contexto pra resolver a conversa correta.
class Webhooks::Captain::McpController < ApplicationController
skip_before_action :verify_authenticity_token, raise: false
before_action :verify_signature
def process_payload
request_body = parse_request_body
return head :bad_request if request_body.blank?
response = Captain::Mcp::Server.handle(
request_body,
context: extract_context(request_body)
)
return head :ok if response.nil? # MCP notifications
render json: response
rescue StandardError => e
Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}"
Rails.logger.error e.backtrace.first(5).join("\n")
render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error
end
private
def parse_request_body
JSON.parse(request.raw_post)
rescue JSON::ParserError
nil
end
def verify_signature
secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil)
return true if secret.blank?
return true if bearer_token_matches?(secret)
return true if hmac_signature_matches?(secret)
head :unauthorized
end
def bearer_token_matches?(secret)
auth_header = request.headers['Authorization'].to_s
return false unless auth_header.start_with?('Bearer ')
token = auth_header.delete_prefix('Bearer ').strip
ActiveSupport::SecurityUtils.secure_compare(token, secret)
end
def hmac_signature_matches?(secret)
signature = request.headers['X-Hub-Signature-256'].to_s
return false if signature.blank?
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
ActiveSupport::SecurityUtils.secure_compare(signature, expected)
end
# Cliente MCP pode mandar contexto multi-tenant em params._captain_context.
# Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation
# é (já que MCP em si é stateless entre client/server).
#
# Fallback: cada profile do Hermes está atrelado a uma unidade
# (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos
# contexto via headers HTTP fixos no config.yaml do profile:
# X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id.
# Body wins se houver conflito (override por chamada).
def extract_context(request_body)
params = request_body['params'] || {}
body_ctx = params['_captain_context'] || {}
body_ctx = {} unless body_ctx.is_a?(Hash)
extract_header_context.merge(body_ctx.symbolize_keys)
end
def extract_header_context
{
account_id: header_int('X-Captain-Account-Id'),
assistant_id: header_int('X-Captain-Assistant-Id'),
inbox_id: header_int('X-Captain-Inbox-Id')
}.compact
end
def header_int(name)
value = request.headers[name].to_s
return nil if value.blank?
value.to_i
end
end

View File

@ -5,7 +5,6 @@ import NetworkNotification from './components/NetworkNotification.vue';
import UpdateBanner from './components/app/UpdateBanner.vue';
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import AggressiveConversationBanner from './components/app/AggressiveConversationBanner.vue';
import vueActionCable from './helper/actionCable';
import { useRouter } from 'vue-router';
import { useStore } from 'dashboard/composables/store';
@ -31,7 +30,6 @@ export default {
PaymentPendingBanner,
WootSnackbarBox,
PendingEmailVerificationBanner,
AggressiveConversationBanner,
},
setup() {
const router = useRouter();
@ -136,7 +134,6 @@ export default {
class="flex flex-col w-full h-screen min-h-0 bg-n-background"
:dir="isRTL ? 'rtl' : 'ltr'"
>
<AggressiveConversationBanner />
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />

View File

@ -1,38 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class HermesBuilder extends ApiClient {
constructor() {
super('captain/hermes_builder', { accountScoped: true });
}
fetchMessages() {
return axios.get(this.url);
}
sendMessage(text) {
return axios.post(this.url, { text });
}
start() {
return axios.post(`${this.url}/start`);
}
reset() {
return axios.delete(`${this.url}/reset`);
}
fetchAssistants() {
return axios.get(`${this.url}/assistants`);
}
validate(slug) {
return axios.get(`${this.url}/validate`, { params: { slug } });
}
repair(slug, repairId) {
return axios.post(`${this.url}/repair`, { slug, repair_id: repairId });
}
}
export default new HermesBuilder();

View File

@ -0,0 +1,29 @@
import ApiClient from '../ApiClient';
class NotificationTemplatesAPI extends ApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
getAll(inboxId) {
return this.get(`${inboxId}/notification_templates`);
}
create(inboxId, data) {
return this.post(`${inboxId}/notification_templates`, {
notification_template: data,
});
}
update(inboxId, id, data) {
return this.patch(`${inboxId}/notification_templates/${id}`, {
notification_template: data,
});
}
delete(inboxId, id) {
return this.delete(`${inboxId}/notification_templates/${id}`);
}
}
export default new NotificationTemplatesAPI();

View File

@ -91,57 +91,23 @@ class ReportsAPI extends ApiClient {
});
}
getBotMetrics({ from, to, inboxId } = {}) {
getBotMetrics({ from, to } = {}) {
return axios.get(`${this.url}/bot_metrics`, {
params: { since: from, until: to, inbox_id: inboxId },
params: { since: from, until: to },
});
}
getBotSummary({ from, to, groupBy, businessHours, type, id } = {}) {
getBotSummary({ from, to, groupBy, businessHours } = {}) {
return axios.get(`${this.url}/bot_summary`, {
params: {
since: from,
until: to,
type: type || 'account',
id,
type: 'account',
group_by: groupBy,
business_hours: businessHours,
},
});
}
getInboxLeadsSummary({ inboxId, from, to, groupBy } = {}) {
return axios.get(`${this.url}/inbox_leads_summary`, {
params: {
inbox_id: inboxId,
since: from,
until: to,
group_by: groupBy,
timezone_offset: getTimeOffset(),
},
});
}
getConversionFunnel({ inboxId, from, to } = {}) {
return axios.get(`${this.url}/conversion_funnel`, {
params: {
inbox_id: inboxId,
since: from,
until: to,
timezone_offset: getTimeOffset(),
},
});
}
getInboxBenchmarking({ from, to } = {}) {
return axios.get(`${this.url}/inbox_benchmarking`, {
params: {
since: from,
until: to,
timezone_offset: getTimeOffset(),
},
});
}
}
export default new ReportsAPI();

View File

@ -26,10 +26,6 @@ const props = defineProps({
type: Number,
required: true,
},
engine: {
type: String,
default: 'captain_interno',
},
});
const emit = defineEmits(['action']);
@ -80,27 +76,11 @@ const handleAction = ({ action, value }) => {
<template>
<CardLayout>
<div class="flex justify-between w-full gap-1">
<div class="flex items-center gap-2 min-w-0">
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<span
v-if="engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11 shrink-0"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
</div>
<h6
class="text-base font-normal text-n-slate-12 line-clamp-1 hover:underline transition-colors"
>
{{ name }}
</h6>
<div class="flex items-center gap-2">
<div
v-on-clickaway="() => toggleDropdown(false)"

View File

@ -10,7 +10,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
import Policy from 'dashboard/components/policy.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
const props = defineProps({
id: {
@ -72,7 +71,6 @@ const emit = defineEmits(['action', 'navigate', 'select', 'hover']);
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const modelValue = computed({
get: () => props.isSelected,
@ -144,7 +142,7 @@ const handleDocumentableClick = () => {
<div v-if="!compact && showMenu" class="flex items-center gap-2">
<Policy
v-on-clickaway="() => toggleDropdown(false)"
:permissions="responseManagePermissions"
:permissions="['administrator']"
class="relative flex items-center group"
>
<Button
@ -170,7 +168,7 @@ const handleDocumentableClick = () => {
v-if="!compact"
class="flex items-start justify-between flex-col-reverse md:flex-row gap-3"
>
<Policy v-if="showActions" :permissions="responseManagePermissions">
<Policy v-if="showActions" :permissions="['administrator']">
<div class="flex items-center gap-2 sm:gap-5 w-full">
<Button
v-if="status === 'pending'"

View File

@ -6,7 +6,6 @@ import Button from 'dashboard/components-next/button/Button.vue';
import ResponseCard from 'dashboard/components-next/captain/assistant/ResponseCard.vue';
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
import { responsesList } from 'dashboard/components-next/captain/pageComponents/emptyStates/captainEmptyStateContent.js';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { computed } from 'vue';
@ -29,7 +28,6 @@ const isPending = computed(() => props.variant === 'pending');
const { isOnChatwootCloud } = useAccount();
const { replaceInstallationName } = useBranding();
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const onClick = () => {
emit('click');
@ -58,7 +56,7 @@ const onClearFilters = () => {
: $t('CAPTAIN.RESPONSES.EMPTY_STATE.TITLE')
"
:subtitle="isApproved ? $t('CAPTAIN.RESPONSES.EMPTY_STATE.SUBTITLE') : ''"
:action-perms="responseManagePermissions"
:action-perms="['administrator']"
:show-backdrop="isApproved"
>
<template v-if="isApproved" #empty-state-item>

View File

@ -2,7 +2,7 @@
import { reactive, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useVuelidate } from '@vuelidate/core';
import { required, minLength, maxLength } from '@vuelidate/validators';
import { required, minLength } from '@vuelidate/validators';
import { useMapGetter } from 'dashboard/composables/store';
import Input from 'dashboard/components-next/input/Input.vue';
@ -39,17 +39,11 @@ const initialState = {
assistant_id: '',
};
const QUESTION_MAX_LENGTH = 255;
const state = reactive({ ...initialState });
const validationRules = computed(() => {
const rules = {
question: {
required,
minLength: minLength(1),
maxLength: maxLength(QUESTION_MAX_LENGTH),
},
question: { required, minLength: minLength(1) },
answer: { required, minLength: minLength(1) },
};
@ -129,7 +123,6 @@ watch(
:placeholder="t('CAPTAIN.RESPONSES.FORM.QUESTION.PLACEHOLDER')"
:message="formErrors.question"
:message-type="formErrors.question ? 'error' : 'info'"
:maxlength="QUESTION_MAX_LENGTH"
/>
<Editor
v-model="state.answer"

View File

@ -6,7 +6,6 @@ import { useMapGetter, useStore } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Policy from 'dashboard/components/policy.vue';
const emit = defineEmits(['close', 'createAssistant']);
@ -106,16 +105,14 @@ const openCreateAssistantDialog = () => {
{{ t('CAPTAIN.ASSISTANT_SWITCHER.SWITCH_ASSISTANT') }}
</p>
</div>
<Policy :permissions="['administrator']">
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</Policy>
<Button
:label="t('CAPTAIN.ASSISTANT_SWITCHER.NEW_ASSISTANT')"
color="slate"
icon="i-lucide-plus"
size="sm"
class="!bg-n-alpha-2 hover:!bg-n-alpha-3"
@click="openCreateAssistantDialog"
/>
</div>
<div v-if="assistants.length > 0" class="flex flex-col gap-2 px-4">
<Button
@ -133,20 +130,6 @@ const openCreateAssistantDialog = () => {
<span class="text-sm font-medium truncate text-n-slate-12">
{{ assistant.name || '' }}
</span>
<span
v-if="assistant.engine === 'hermes'"
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-amber-3 text-n-amber-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_HERMES') }}
</span>
<span
v-else
class="text-[10px] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-n-slate-3 text-n-slate-11"
:title="t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO_TOOLTIP')"
>
{{ t('CAPTAIN.ASSISTANT_SWITCHER.ENGINE_INTERNO') }}
</span>
<Avatar
v-if="assistant"
:name="assistant.name"

View File

@ -424,12 +424,6 @@ const menuItems = computed(() => {
activeOn: ['captain_roleta_index'],
to: accountScopedRoute('captain_roleta_index'),
},
{
name: 'HermesBuilder',
label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'),
activeOn: ['captain_hermes_builder_index'],
to: accountScopedRoute('captain_hermes_builder_index'),
},
{
name: 'Funnel',
label: t('SIDEBAR.CAPTAIN_FUNNEL'),
@ -442,6 +436,12 @@ const menuItems = computed(() => {
activeOn: ['captain_settings_reports'],
to: accountScopedRoute('captain_settings_reports'),
},
{
name: 'Notifications',
label: t('SIDEBAR.CAPTAIN_NOTIFICATIONS'),
activeOn: ['captain_settings_notifications'],
to: accountScopedRoute('captain_settings_notifications'),
},
],
},
{
@ -556,11 +556,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.REPORTS_BOT'),
to: accountScopedRoute('bot_reports'),
},
{
name: 'Reports Directory Dashboard',
label: t('SIDEBAR.REPORTS_DIRECTORY_DASHBOARD'),
to: accountScopedRoute('directory_dashboard_reports'),
},
],
},
{

View File

@ -1,340 +0,0 @@
<script>
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',
data() {
return {
alerts: [],
maxLevel: null,
};
},
computed: {
...mapGetters({
currentAccountId: 'getCurrentAccountId',
allConversations: 'getAllConversations',
currentUser: 'getCurrentUser',
}),
allowedInboxIds() {
// null sem filtro (todas); array só essas.
const raw =
this.currentUser &&
this.currentUser.ui_settings &&
this.currentUser.ui_settings.aggressive_alert_inbox_ids;
if (raw == null) return null;
if (!Array.isArray(raw)) return null;
return raw.map(id => Number(id));
},
hasAlerts() {
return this.alerts.length > 0;
},
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.HEADLINE_5',
{ minutes: a.minutes },
`${a.minutes} min sem resposta`
);
}
return this.$t(
'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.'
);
},
},
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) {
const allowed = this.allowedInboxIds;
const filtered =
allowed === null
? conversations
: (conversations || []).filter(c =>
allowed.includes(Number(c && c.inbox_id))
);
inactivityAlertTracker.hydrateFromConversations(filtered);
},
immediate: true,
},
},
mounted() {
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.refreshAlerts();
},
beforeUnmount() {
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_TRIGGER, this.refreshAlerts);
emitter.off(BUS_EVENTS.AGGRESSIVE_ALERT_DISMISS, this.refreshAlerts);
},
methods: {
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).
// Param tem que ser `conversation_id` (snake_case, como
// declarado no path da rota); camelCase faz Vue Router não casar
// e cair em "selecione uma conversa".
aggressiveAlert.dismiss(alert.id);
if (!this.currentAccountId) return;
this.$router.push({
name: 'inbox_conversation',
params: {
accountId: this.currentAccountId,
conversation_id: alert.id,
},
});
},
dismissOne(alert) {
aggressiveAlert.dismiss(alert.id);
},
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="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="alertItemClass(alert)"
>
<button
type="button"
class="aggressive-banner__open"
@click="openConversation(alert)"
>
<span class="aggressive-banner__contact">{{
alert.contactName || '—'
}}</span>
<span v-if="alert.inboxName" class="aggressive-banner__inbox">
· {{ alert.inboxName }}
</span>
<span class="aggressive-banner__context">
· {{ alertContextLabel(alert) }}
</span>
</button>
<button
type="button"
class="aggressive-banner__close"
:aria-label="
$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.HIDE_ICON', '×') }}
</button>
</li>
</ul>
</div>
</template>
<style lang="scss" scoped>
@keyframes aggressive-pulse-yellow {
0%,
100% {
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;
}
}
.aggressive-banner {
position: sticky;
top: 0;
z-index: 9999;
width: 100%;
color: #ffffff;
padding: 14px 20px;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
font-weight: 700;
}
.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__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__explanation {
font-size: 13px;
font-weight: 500;
opacity: 0.92;
margin-bottom: 10px;
}
.aggressive-banner__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.aggressive-banner__item {
display: flex;
align-items: center;
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: inherit;
border: none;
padding: 8px 12px;
cursor: pointer;
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: 800;
}
.aggressive-banner__inbox,
.aggressive-banner__context {
opacity: 0.9;
font-weight: 500;
}
.aggressive-banner__close {
background: transparent;
color: inherit;
border: none;
border-left: 1px solid rgba(255, 255, 255, 0.25);
padding: 8px 12px;
cursor: pointer;
font-size: 20px;
line-height: 1;
font-weight: 800;
}
.aggressive-banner__close:hover {
background: rgba(255, 255, 255, 0.2);
}
</style>

View File

@ -1,8 +1,6 @@
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';
@ -109,127 +107,16 @@ 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 inboxId = conversation && conversation.inbox_id;
if (!this.isInboxAllowedForUser(inboxId)) return;
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'](inboxId)
: 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;
};
// Filtra alertas por inbox conforme a preferência do user.
// ui_settings.aggressive_alert_inbox_ids:
// - null/undefined → todas as inboxes (default, legado)
// - [] (vazio) → nenhuma inbox (silenciou tudo)
// - [1, 2, 3] → só essas inboxes
isInboxAllowedForUser = inboxId => {
if (inboxId == null) return true;
const user = this.app.$store.getters.getCurrentUser;
const allowed =
user && user.ui_settings && user.ui_settings.aggressive_alert_inbox_ids;
if (allowed == null) return true;
if (!Array.isArray(allowed)) return true;
// Inbox ids podem vir como number no evento e string no ui_settings.
return allowed.some(id => Number(id) === Number(inboxId));
};
// eslint-disable-next-line class-methods-use-this
onReload = () => window.location.reload();
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 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;
if (!this.isInboxAllowedForUser(data.inbox_id)) return;
const store = this.app.$store;
const contactName =
data.meta && data.meta.sender ? data.meta.sender.name : '';
const inbox = store.getters['inboxes/getInbox']
? store.getters['inboxes/getInbox'](data.inbox_id)
: null;
const inboxName = inbox && inbox.name ? inbox.name : '';
aggressiveAlert.trigger({
conversationId: data.id,
level: 'red',
kind: 'reopened',
contactName,
inboxName,
});
};
onConversationUpdated = data => {
this.app.$store.dispatch('updateConversation', data);
this.fetchConversationStats();

View File

@ -1,303 +0,0 @@
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] - 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;

View File

@ -1,245 +0,0 @@
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;
// Logs opt-in. Ativar no DevTools: window.__AGGRESSIVE_DEBUG__ = true
// Serve pra investigar porque o banner de inatividade não dispara numa
// conversa específica sem ter que tornar logs permanentes em prod.
const debug = (...args) => {
if (
typeof window !== 'undefined' &&
// eslint-disable-next-line no-underscore-dangle
window.__AGGRESSIVE_DEBUG__
) {
// eslint-disable-next-line no-console
console.info('[aggressive-alert]', ...args);
}
};
function findLastNonActivityMessage(conv) {
// 1) Preferir o campo dedicado do payload da listagem — o serializer
// já filtra atividades (`non_activity_messages`) antes de setar aqui.
if (conv.last_non_activity_message) return conv.last_non_activity_message;
// 2) Fallback pro array `messages` (só tem a última mensagem, e pode ser
// uma activity — filtra pra garantir).
if (conv.messages && conv.messages.length) {
const nonActivity = conv.messages.filter(
m => m && m.message_type !== 2 && m.message_type !== 'activity'
);
if (nonActivity.length) return nonActivity[nonActivity.length - 1];
}
return 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 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 || '',
});
debug('onClientMessage', { conversationId, contactName, 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()) {
debug('tick skip: disabled');
return;
}
if (this.conversations.size === 0) {
debug('tick: empty map, stopping interval');
this.stop();
return;
}
const now = Date.now();
debug('tick', { size: this.conversations.size });
Array.from(this.conversations.entries()).forEach(
([conversationId, entry]) => {
const elapsedMin = (now - entry.lastClientAt) / 60000;
debug('tick entry', {
conversationId,
elapsedMin: elapsedMin.toFixed(2),
fired: Array.from(entry.firedMinutes),
});
THRESHOLDS.forEach(t => {
if (elapsedMin < t.minutes) return;
if (entry.firedMinutes.has(t.minutes)) return;
entry.firedMinutes.add(t.minutes);
debug('THRESHOLD HIT', {
conversationId,
minutes: t.minutes,
level: t.level,
});
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 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()) {
debug('hydrate skip: disabled');
return;
}
if (!Array.isArray(conversations) || conversations.length === 0) {
debug('hydrate skip: empty list');
return;
}
debug('hydrate start', { total: conversations.length });
let hydrated = 0;
let skippedNotOpen = 0;
let skippedNoMsg = 0;
let skippedAgentLast = 0;
let skippedNoTs = 0;
conversations.forEach(conv => {
if (!conv || conv.status !== 'open') {
skippedNotOpen += 1;
return;
}
const lastMsg = findLastNonActivityMessage(conv);
if (!lastMsg) {
skippedNoMsg += 1;
debug('hydrate skip (no last msg)', { id: conv.id });
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);
}
skippedAgentLast += 1;
return;
}
const lastClientAt = parseCreatedAt(lastMsg.created_at);
if (!lastClientAt) {
skippedNoTs += 1;
debug('hydrate skip (bad ts)', {
id: conv.id,
raw: lastMsg.created_at,
});
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;
});
debug('hydrate done', {
hydrated,
skippedNotOpen,
skippedNoMsg,
skippedAgentLast,
skippedNoTs,
mapSize: this.conversations.size,
});
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;

View File

@ -94,13 +94,6 @@
"ADMIN_SUCCESS_MESSAGE": "An email with reset password instructions has been sent to the agent",
"SUCCESS_MESSAGE": "Agent password reset successfully",
"ERROR_MESSAGE": "Could not connect to Woot Server, Please try again later"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Aggressive alert — inboxes",
"DESCRIPTION": "Choose which inboxes will trigger the reopened/inactivity banner for this agent.",
"ALL_INBOXES": "All inboxes",
"PICK_INBOXES": "Select inboxes",
"NONE_WARNING": "No inbox selected — this agent will not see the aggressive alert."
}
},
"SEARCH": {

View File

@ -1,15 +0,0 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"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

@ -433,6 +433,46 @@
"LABEL": "Available for agent sending"
}
}
},
"NOTIFICATIONS": {
"TITLE": "Automatic Notifications",
"DESCRIPTION": "Configure messages sent automatically before or after the guest's arrival.",
"LOADING": "Loading notifications...",
"ADD": "Add notification",
"ACTIVE": "Active",
"INACTIVE": "Inactive",
"DIRECTION": {
"BEFORE": "before",
"AFTER": "after",
"OF_ARRIVAL": "arrival"
},
"FORM": {
"LABEL_PLACEHOLDER": "Template name (e.g. Arrival Instructions)",
"CONTENT_PLACEHOLDER": "Message to send... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
"SEND": "Send",
"MINUTES": "min",
"CANCEL": "Cancel",
"SAVE": "Save"
},
"CREATE": {
"SUCCESS": "Notification created successfully!",
"ERROR": "Error creating notification. Please try again."
},
"UPDATE": {
"SUCCESS": "Notification updated!",
"ERROR": "Error updating notification."
},
"DELETE": {
"SUCCESS": "Notification removed.",
"ERROR": "Error removing notification."
},
"INBOX_LABEL": "Select inbox",
"NO_CAPTAIN_INBOXES": "No inboxes with Captain configured.",
"SELECT_INBOX_HINT": "Click an inbox above to view and configure its templates.",
"EMPTY": {
"TITLE": "No templates configured",
"DESC": "Create automatic message templates for this inbox."
}
}
},
"CAPTAIN": {
@ -891,49 +931,5 @@
"RESERVATION_ID": "Reservation #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Agent Builder",
"DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.",
"HEADER_TITLE": "Agent Builder",
"HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.",
"RESET": "Clear conversation",
"RESET_CONFIRM": "Clear current conversation with the Builder?",
"EMPTY_STATE": "Ready to create a new Hermes agent? Click \"Start creation\" and the Builder will guide you.",
"PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)",
"SEND": "Send",
"SESSION_LABEL": "Session:",
"SEND_FAILED": "Send failed: {message}",
"RESET_FAILED": "Failed to clear session.",
"START": "Start creation",
"TAB_CHAT": "Chat (Builder)",
"TAB_VERIFY": "Verification",
"VERIFY": {
"TITLE": "Agent verification",
"DESCRIPTION": "Runs health checks (database, routing, pricing, MCP) for a Hermes agent. For each failure with a Repair button, the UI attempts an automatic fix. Other failures need hermes-provision on the VPS.",
"NO_ASSISTANTS": "No Hermes agents registered",
"RUN": "Run check",
"RUNNING": "Checking...",
"REPAIR": "Repair",
"REPAIRING": "Repairing...",
"OK_LABEL": "OK",
"FAILS_LABEL": "failures",
"WARN_LABEL": "warnings",
"OF_TOTAL": "of {total} checks",
"VERDICT_PASS": "Ready to ship",
"VERDICT_FAIL": "Critical failures — fix first",
"EMPTY": "Select an agent and click Run check to start verification.",
"EMPTY_RESULTS": "No checks returned — agent removed?",
"REPAIR_FAILED": "Failed: {message}",
"REPAIR_OK": "Repaired: {message}",
"FETCH_FAILED": "Error loading assistants: {message}",
"VALIDATE_FAILED": "Validation failed: {message}",
"CATEGORY_DB": "Database",
"CATEGORY_PRICING": "Pricing",
"CATEGORY_ROUTING": "Captain → Hermes routing",
"CATEGORY_HUMANIZATION": "Humanization (typing/delay/gallery)",
"CATEGORY_MCP": "Registered MCP tools",
"CATEGORY_OTHER": "Other"
}
}
}
}

View File

@ -104,14 +104,6 @@
"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

@ -1,5 +1,4 @@
import advancedFilters from './advancedFilters.json';
import aggressiveBanner from './aggressiveBanner.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@ -45,7 +44,6 @@ import yearInReview from './yearInReview.json';
export default {
...advancedFilters,
...aggressiveBanner,
...agentBots,
...agentMgmt,
...attributesMgmt,

View File

@ -385,11 +385,7 @@
"ASSISTANTS": "Assistants",
"SWITCH_ASSISTANT": "Switch between assistants",
"NEW_ASSISTANT": "Create Assistant",
"EMPTY_LIST": "No assistants found, please create one to get started",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Assistant operated by the Hermes Agent (external LLM)",
"ENGINE_INTERNO": "Internal",
"ENGINE_INTERNO_TOOLTIP": "Assistant operated by the internal Captain orchestrator"
"EMPTY_LIST": "No assistants found, please create one to get started"
},
"COPILOT": {
"TITLE": "Copilot",

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Resolved by bot",
"NAME": "Resolution Count",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Transferred to human",
"NAME": "Handoff Count",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -281,35 +281,6 @@
"FILTER_DROPDOWN_LABEL": "Select Inbox",
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"TABS": {
"OVERVIEW": "Overview",
"LEADS": "New × Returning"
},
"LEADS": {
"TITLE": "New × Returning",
"INBOX_LABEL": "Inbox:",
"EMPTY": "No conversations in this period.",
"TOTAL": "Total conversations in the period: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "New leads",
"INFO": "Conversations from contacts who never spoke to any inbox of the network before."
},
"RETURNING": {
"LABEL": "Returning",
"INFO": "Conversations from contacts whose most recent prior conversation was resolved more than 24h ago."
},
"OTHERS": {
"LABEL": "Others",
"INFO": "Conversations from contacts whose prior conversation is still open or was resolved less than 24h ago."
}
},
"CHART": {
"NEW_LEADS": "New",
"RETURNING": "Returning",
"OTHERS": "Others"
}
},
"FILTERS": {
"INPUT_PLACEHOLDER": {
"INBOXES": "Search inboxes"
@ -534,32 +505,20 @@
"HEADER": "Bot Reports",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "Conversations",
"TOOLTIP": "Total number of conversations handled by the bot in the period"
"LABEL": "No. of Conversations",
"TOOLTIP": "Total number of conversations handled by the bot"
},
"TOTAL_RESPONSES": {
"LABEL": "Outgoing messages",
"TOOLTIP": "Total number of outgoing messages — includes the bot AND humans (Chatwoot UI or WhatsApp echo)"
"LABEL": "Total Responses",
"TOOLTIP": "Total number of responses sent by the bot"
},
"RESOLUTION_RATE": {
"LABEL": "Resolved by bot %",
"TOOLTIP": "Conversations the bot resolved alone (no human reply, via UI or WhatsApp) ÷ total conversations × 100"
"LABEL": "Resolution Rate",
"TOOLTIP": "Total number of conversations resolved by the bot / Total number of conversations handled by the bot * 100"
},
"HANDOFF_RATE": {
"LABEL": "Transferred to human %",
"TOOLTIP": "Conversations transferred to human (auto by Jasmine + manual takeover) ÷ total conversations × 100. Together with the resolution rate, the gear closes the math (the rest are still open, snoozed, or abandoned)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolved by bot",
"TOOLTIP": "Absolute count: conversations the bot closed alone, with no human reply (UI or WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Auto handoff (Jasmine)",
"TOOLTIP": "Conversations where Jasmine explicitly called bot_handoff! — typically tool loop, timeout, max turns reached or LLM intent classified as handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Manual takeover (agent)",
"TOOLTIP": "Conversations where a human replied (Chatwoot UI or WhatsApp echo) without Jasmine triggering bot_handoff! first — the agent took over silently"
"LABEL": "Handoff Rate",
"TOOLTIP": "Total number of conversations handed off to agents / Total number of conversations handled by the bot * 100"
}
}
},
@ -687,47 +646,5 @@
"AVG_REPLY_TIME": "Avg. Customer Waiting Time",
"RESOLUTION_COUNT": "Resolution Count",
"CONVERSATIONS": "No. of conversations"
},
"DIRECTORY_DASHBOARD": {
"HEADER": "Directory Dashboard",
"BANNER": {
"TITLE": "Channel adoption — not the full picture.",
"BODY": "These numbers measure the digital channel only (Jasmine + reservations created via app). Conversations attended manually that closed at the reception are not yet captured (manual marking is in progress)."
},
"HEADLINE_NUMBERS": "Headline numbers",
"METRICS": {
"LEADS_TOTAL": {
"LABEL": "Leads (total)",
"TOOLTIP": "All conversations created in the period (new + returning)"
},
"LEADS_NEW": {
"LABEL": "New leads",
"TOOLTIP": "First-ever conversation of the contact in any inbox of the network"
},
"LEADS_RETURNING": {
"LABEL": "Returning leads",
"TOOLTIP": "Contact had at least one prior conversation"
},
"CONVERSION_RATE": {
"LABEL": "Lead → Paid reservation",
"TOOLTIP": "Paid reservations ÷ total leads × 100. Adoption proxy, not full operation."
}
},
"FUNNEL": {
"TITLE": "Funnel",
"STAGE_LEADS": "Leads",
"STAGE_RESERVATIONS": "Reservations created",
"STAGE_PAID": "Paid"
},
"BENCHMARK": {
"TITLE": "Inbox benchmarking by brand",
"BRAND_AVG": "brand avg.",
"COL_INBOX": "Inbox",
"COL_LEADS": "Leads",
"COL_CREATED": "Created",
"COL_PAID": "Paid",
"COL_RATE": "Conv. rate",
"COL_VS_BRAND": "vs brand"
}
}
}

View File

@ -35,12 +35,6 @@
}
}
},
"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.",
@ -121,15 +115,6 @@
"RESET_SUCCESS": "Access token regenerated successfully",
"RESET_ERROR": "Unable to regenerate access token. Please try again"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Stalled conversation alert",
"NOTE": "Red banner that appears at the top of the panel when a conversation has been waiting for a reply for 5+ minutes.",
"DESCRIPTION": "Red banner shown when a conversation has no reply for 5+ minutes. Useful to avoid losing customers, but can be intrusive if you don't handle every inbox.",
"ENABLED": "Enable stalled conversation alert",
"APPLY_TO_ALL": "Apply to all inboxes",
"INBOX_HINT": "Pick the inboxes where you want to receive the alert:",
"NO_INBOXES": "No inboxes registered."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Audio Alerts",
"NOTE": "Enable audio alerts in dashboard for new messages and conversations.",
@ -359,10 +344,10 @@
"CAPTAIN_GALLERY": "Gallery",
"CAPTAIN_RESERVATIONS": "Reservations",
"CAPTAIN_ROLETA": "Roulette — Redeem",
"CAPTAIN_HERMES_BUILDER": "Builder (Hermes)",
"CAPTAIN_FUNNEL": "Conversion Funnel",
"CAPTAIN_LIFECYCLE": "Customer Journey",
"CAPTAIN_REPORTS": "AI Reports",
"CAPTAIN_NOTIFICATIONS": "Automatic Notifications",
"HOME": "Home",
"AGENTS": "Agents",
"AGENT_BOTS": "Bots",
@ -398,7 +383,6 @@
"ONE_OFF": "One off",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Bot",
"REPORTS_DIRECTORY_DASHBOARD": "Directory Dashboard",
"REPORTS_AGENT": "Agents",
"REPORTS_LABEL": "Labels",
"REPORTS_INBOX": "Inbox",

View File

@ -94,13 +94,6 @@
"ADMIN_SUCCESS_MESSAGE": "Um e-mail com instruções de redefinição de senha foi enviado para o agente",
"SUCCESS_MESSAGE": "Senha do agente redefinida com sucesso",
"ERROR_MESSAGE": "Não foi possível conectar ao servidor Woot, por favor tente novamente mais tarde"
},
"AGGRESSIVE_ALERT": {
"LABEL": "Alerta agressivo — caixas de entrada",
"DESCRIPTION": "Escolha em quais caixas de entrada este agente verá o banner de conversa reaberta e de inatividade.",
"ALL_INBOXES": "Em todas as caixas de entrada",
"PICK_INBOXES": "Selecione as caixas de entrada",
"NONE_WARNING": "Nenhuma caixa selecionada — este agente não verá o alerta agressivo."
}
},
"SEARCH": {

View File

@ -1,15 +0,0 @@
{
"AGGRESSIVE_CONVERSATION_BANNER": {
"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

@ -45,7 +45,7 @@
"HIDE": "Ocultar filtros"
},
"KPI": {
"TOTAL": "Total filtrado",
"TOTAL": "Total na página",
"PENDING_PIX": "Aguardando PIX",
"CHECKIN_TODAY": "Check-in hoje",
"REVENUE_TODAY": "Receita hoje"
@ -434,6 +434,47 @@
"LABEL": "Disponível para envio pelos agentes"
}
}
},
"NOTIFICATIONS": {
"TITLE": "Notificações Automáticas",
"DESCRIPTION": "Configure mensagens automáticas enviadas antes ou depois da chegada do hóspede.",
"LOADING": "Carregando notificações...",
"ADD": "Adicionar notificação",
"ACTIVE": "Ativo",
"INACTIVE": "Inativo",
"DIRECTION": {
"BEFORE": "antes",
"AFTER": "depois",
"OF_ARRIVAL": "da chegada"
},
"TIMING_LABEL": "da chegada",
"FORM": {
"LABEL_PLACEHOLDER": "Nome do template (ex: Orientações de Chegada)",
"CONTENT_PLACEHOLDER": "Mensagem a enviar... Use {{guest_name}}, {{check_in_time}}, {{suite_name}}",
"SEND": "Enviar",
"MINUTES": "min",
"CANCEL": "Cancelar",
"SAVE": "Salvar"
},
"CREATE": {
"SUCCESS": "Notificação criada com sucesso!",
"ERROR": "Erro ao criar notificação. Tente novamente."
},
"UPDATE": {
"SUCCESS": "Notificação atualizada!",
"ERROR": "Erro ao atualizar notificação."
},
"DELETE": {
"SUCCESS": "Notificação removida.",
"ERROR": "Erro ao remover notificação."
},
"INBOX_LABEL": "Selecione a caixa de entrada",
"NO_CAPTAIN_INBOXES": "Nenhuma caixa de entrada com Captain configurado.",
"SELECT_INBOX_HINT": "Clique em uma caixa de entrada acima para ver e configurar os templates.",
"EMPTY": {
"TITLE": "Nenhum template configurado",
"DESC": "Configure as permissões das informações que o sistema utiliza. Por ex.: Quais imagens enviar durante as aproximações."
}
}
},
"CAPTAIN": {
@ -892,49 +933,5 @@
"RESERVATION_ID": "Reserva #"
}
}
},
"CAPTAIN_HERMES_BUILDER": {
"TITLE": "Construtor de Agentes",
"DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.",
"HEADER_TITLE": "Construtor de Agentes",
"HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.",
"RESET": "Limpar conversa",
"RESET_CONFIRM": "Limpar conversa atual com o Construtor?",
"EMPTY_STATE": "Pronto pra criar um novo agente Hermes? Clica em \"Iniciar criação\" e o Construtor te guia.",
"PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)",
"SEND": "Enviar",
"SESSION_LABEL": "Sessão:",
"SEND_FAILED": "Erro ao enviar: {message}",
"RESET_FAILED": "Falha ao limpar sessão.",
"START": "Iniciar criação",
"TAB_CHAT": "Chat (Construtor)",
"TAB_VERIFY": "Verificação",
"VERIFY": {
"TITLE": "Verificação de agente",
"DESCRIPTION": "Roda os checks de saúde (banco, roteamento, preços, MCP) de um agente Hermes. Para cada falha com botão Refazer, a UI tenta corrigir automaticamente. Demais falhas precisam de hermes-provision na VPS.",
"NO_ASSISTANTS": "Nenhum agente Hermes cadastrado",
"RUN": "Conferir agora",
"RUNNING": "Conferindo...",
"REPAIR": "Refazer",
"REPAIRING": "Reparando...",
"OK_LABEL": "OK",
"FAILS_LABEL": "falhas",
"WARN_LABEL": "atenção",
"OF_TOTAL": "de {total} checks",
"VERDICT_PASS": "Pode soltar",
"VERDICT_FAIL": "Há falhas críticas — corrija antes",
"EMPTY": "Selecione um agente e clique em Conferir agora pra rodar a verificação.",
"EMPTY_RESULTS": "Sem checks retornados — o agente foi removido?",
"REPAIR_FAILED": "Falha: {message}",
"REPAIR_OK": "Reparado: {message}",
"FETCH_FAILED": "Erro carregando assistentes: {message}",
"VALIDATE_FAILED": "Falha ao validar: {message}",
"CATEGORY_DB": "Banco de dados",
"CATEGORY_PRICING": "Preços",
"CATEGORY_ROUTING": "Roteamento Captain → Hermes",
"CATEGORY_HUMANIZATION": "Humanização (typing/delay/galeria)",
"CATEGORY_MCP": "Tools MCP registradas",
"CATEGORY_OTHER": "Outros"
}
}
}

View File

@ -104,14 +104,6 @@
"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

@ -1,5 +1,4 @@
import advancedFilters from './advancedFilters.json';
import aggressiveBanner from './aggressiveBanner.json';
import agentBots from './agentBots.json';
import agentMgmt from './agentMgmt.json';
import attributesMgmt from './attributesMgmt.json';
@ -41,7 +40,6 @@ import whatsappTemplates from './whatsappTemplates.json';
export default {
...advancedFilters,
...aggressiveBanner,
...agentBots,
...agentMgmt,
...attributesMgmt,

View File

@ -366,11 +366,7 @@
"ASSISTANTS": "Assistentes",
"SWITCH_ASSISTANT": "Alternar entre assistentes",
"NEW_ASSISTANT": "Criar Assistente",
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar",
"ENGINE_HERMES": "Hermes",
"ENGINE_HERMES_TOOLTIP": "Atendente operada pelo Hermes Agent (LLM externo)",
"ENGINE_INTERNO": "Interno",
"ENGINE_INTERNO_TOOLTIP": "Atendente operada pelo orquestrador interno do Captain"
"EMPTY_LIST": "Nenhum assistente encontrado, crie um para começar"
},
"COPILOT": {
"TITLE": "Copiloto",

View File

@ -36,11 +36,11 @@
"DESC": "( Total )"
},
"BOT_RESOLUTION_COUNT": {
"NAME": "Resolvidas pelo bot",
"NAME": "Contagem de Resolução",
"DESC": "( Total )"
},
"BOT_HANDOFF_COUNT": {
"NAME": "Transferidas para humano",
"NAME": "Contagem de transferências",
"DESC": "( Total )"
},
"REPLY_TIME": {
@ -269,37 +269,8 @@
"NO_ENOUGH_DATA": "Não existem dados suficientes para gerar o relatório. Tente novamente mais tarde.",
"DOWNLOAD_INBOX_REPORTS": "Baixar relatórios de entrada",
"FILTER_DROPDOWN_LABEL": "Selecionar caixa de entrada",
"ALL_INBOXES": "Todas as caixas",
"SEARCH_INBOX": "Buscar caixa",
"TABS": {
"OVERVIEW": "Visão Geral",
"LEADS": "Novas × Retorno"
},
"LEADS": {
"TITLE": "Novas × Retorno",
"INBOX_LABEL": "Caixa de entrada:",
"EMPTY": "Sem conversas no período.",
"TOTAL": "Total de conversas no período: {count}",
"METRICS": {
"NEW_LEADS": {
"LABEL": "Leads novos",
"INFO": "Conversas de contatos que nunca falaram em nenhuma caixa da rede antes."
},
"RETURNING": {
"LABEL": "Retorno",
"INFO": "Conversas de contatos cuja conversa anterior mais recente foi resolvida há mais de 24h."
},
"OTHERS": {
"LABEL": "Outras",
"INFO": "Conversas de contatos cuja conversa anterior ainda está aberta ou foi resolvida há menos de 24h."
}
},
"CHART": {
"NEW_LEADS": "Novas",
"RETURNING": "Retorno",
"OTHERS": "Outras"
}
},
"ALL_INBOXES": "All Inboxes",
"SEARCH_INBOX": "Search Inbox",
"METRICS": {
"CONVERSATIONS": {
"NAME": "Conversas",
@ -466,32 +437,20 @@
"HEADER": "Relatórios do Bot",
"METRIC": {
"TOTAL_CONVERSATIONS": {
"LABEL": "Conversas",
"TOOLTIP": "Total de conversas atendidas pelo bot no período"
"LABEL": "Nº de Conversas",
"TOOLTIP": "Número total de conversas tratadas pelo bot"
},
"TOTAL_RESPONSES": {
"LABEL": "Mensagens enviadas",
"TOOLTIP": "Total de mensagens enviadas — inclui o bot E humanos (via Chatwoot ou eco do WhatsApp)"
"LABEL": "Total de respostas",
"TOOLTIP": "Número total de respostas enviadas pelo bot"
},
"RESOLUTION_RATE": {
"LABEL": "Resolvidas pelo bot %",
"TOOLTIP": "Conversas que o bot resolveu sozinho (sem humano respondendo, via Chatwoot ou WhatsApp) ÷ total de conversas × 100"
"LABEL": "Tempo de resolução",
"TOOLTIP": "Número total de conversas resolvidas pelo bot / número total de conversas manipuladas pelo bot * 100"
},
"HANDOFF_RATE": {
"LABEL": "Transferidas pra humano %",
"TOOLTIP": "Conversas transferidas pra humano (auto pela Jasmine + tomada manual) ÷ total de conversas × 100. Junto com a taxa de resolução fecha a engrenagem (o resto está aberto, em snooze ou abandonado)."
},
"BOT_RESOLUTIONS": {
"LABEL": "Resolvidas pelo bot",
"TOOLTIP": "Contagem absoluta: conversas que o bot fechou sozinho, sem humano respondendo (via Chatwoot ou WhatsApp)"
},
"AUTO_HANDOFFS": {
"LABEL": "Transferência automática (Jasmine)",
"TOOLTIP": "Conversas em que a Jasmine chamou bot_handoff! explicitamente — geralmente loop de ferramenta, timeout, limite de turnos ou intent do LLM classificado como handoff"
},
"MANUAL_TAKEOVERS": {
"LABEL": "Tomada manual (agente)",
"TOOLTIP": "Conversas em que um humano respondeu (Chatwoot ou eco do WhatsApp) SEM a Jasmine ter chamado bot_handoff! antes — o agente assumiu silenciosamente"
"LABEL": "Taxa de entrega",
"TOOLTIP": "Número total de conversas entregues a agentes / número total de conversas mantidas pelo bot * 100"
}
}
},
@ -619,47 +578,5 @@
"AVG_REPLY_TIME": "Tempo Médio de Espera do Cliente",
"RESOLUTION_COUNT": "Contagem de Resolução",
"CONVERSATIONS": "Nº de Conversas"
},
"DIRECTORY_DASHBOARD": {
"HEADER": "Painel Diretoria",
"BANNER": {
"TITLE": "Adoção do canal digital — não é a operação completa.",
"BODY": "Esses números medem só o canal digital (Jasmine + reservas via app). Conversas atendidas manualmente que fecharam na recepção ainda não estão capturadas (marcação manual em construção)."
},
"HEADLINE_NUMBERS": "Números principais",
"METRICS": {
"LEADS_TOTAL": {
"LABEL": "Leads (total)",
"TOOLTIP": "Todas as conversas criadas no período (novos + retorno)"
},
"LEADS_NEW": {
"LABEL": "Leads novos",
"TOOLTIP": "Primeira conversa do contato em qualquer caixa da rede"
},
"LEADS_RETURNING": {
"LABEL": "Leads de retorno",
"TOOLTIP": "Contato com pelo menos uma conversa anterior"
},
"CONVERSION_RATE": {
"LABEL": "Lead → Reserva paga",
"TOOLTIP": "Reservas pagas ÷ total de leads × 100. Proxy de adoção, não retrato da operação."
}
},
"FUNNEL": {
"TITLE": "Funil",
"STAGE_LEADS": "Leads",
"STAGE_RESERVATIONS": "Reservas criadas",
"STAGE_PAID": "Pagas"
},
"BENCHMARK": {
"TITLE": "Comparativo entre unidades por marca",
"BRAND_AVG": "média da marca",
"COL_INBOX": "Caixa de Entrada",
"COL_LEADS": "Leads",
"COL_CREATED": "Criadas",
"COL_PAID": "Pagas",
"COL_RATE": "Taxa conv.",
"COL_VS_BRAND": "vs marca"
}
}
}

View File

@ -35,12 +35,6 @@
}
}
},
"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.",
@ -121,15 +115,6 @@
"RESET_SUCCESS": "Token de acesso gerado novamente com sucesso",
"RESET_ERROR": "Não foi possível regerar o token de acesso. Por favor, tente novamente"
},
"AGGRESSIVE_ALERT_SECTION": {
"TITLE": "Alerta de conversa parada",
"NOTE": "Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.",
"DESCRIPTION": "Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.",
"ENABLED": "Ativar alerta de conversa parada",
"APPLY_TO_ALL": "Aplicar em todas as caixas de entrada",
"INBOX_HINT": "Selecione as caixas onde você quer receber o alerta:",
"NO_INBOXES": "Nenhuma caixa de entrada cadastrada."
},
"AUDIO_NOTIFICATIONS_SECTION": {
"TITLE": "Alertas de áudio",
"NOTE": "Habilitar notificações de áudio no painel para novas mensagens e conversas.",
@ -358,10 +343,10 @@
"CAPTAIN_GALLERY": "Galeria",
"CAPTAIN_RESERVATIONS": "Reservas",
"CAPTAIN_ROLETA": "Roleta — Resgate",
"CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)",
"CAPTAIN_FUNNEL": "Funil de Conversão",
"CAPTAIN_LIFECYCLE": "Jornada do Cliente",
"CAPTAIN_REPORTS": "Relatórios IA",
"CAPTAIN_NOTIFICATIONS": "Notificações Automáticas",
"HOME": "Principal",
"AGENTS": "Agentes",
"AGENT_BOTS": "Robôs",
@ -397,7 +382,6 @@
"ONE_OFF": "Única",
"REPORTS_SLA": "SLA",
"REPORTS_BOT": "Robôs",
"REPORTS_DIRECTORY_DASHBOARD": "Painel Diretoria",
"REPORTS_AGENT": "Agentes",
"REPORTS_LABEL": "Etiquetas",
"REPORTS_INBOX": "Caixa de Entrada",

View File

@ -18,8 +18,6 @@ import NextButton from 'dashboard/components-next/button/Button.vue';
import Editor from 'dashboard/components-next/Editor/Editor.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
const FAQ_QUESTION_MAX_LENGTH = 255;
export default {
components: {
AddCannedModal,
@ -95,11 +93,6 @@ export default {
this.message.content_attributes ?? this.message.contentAttributes
);
},
faqQuestion() {
return (this.plainTextContent || '')
.trim()
.slice(0, FAQ_QUESTION_MAX_LENGTH);
},
},
methods: {
handleEnterKey(e) {
@ -258,7 +251,7 @@ export default {
ref="faqDialog"
type="create"
:selected-response="{
question: faqQuestion,
question: plainTextContent,
assistant_id: copilotAssistant?.id,
}"
@close="hideFaqModal"

View File

@ -1,362 +0,0 @@
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
} from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const messages = ref([]);
const input = ref('');
const sending = ref(false);
const polling = ref(null);
const scrollContainer = ref(null);
const sessionId = ref(null);
const lastMessageRole = computed(() => messages.value.at(-1)?.role || null);
const isWaiting = computed(
() => sending.value || lastMessageRole.value === 'user'
);
const scrollToBottom = () => {
const el = scrollContainer.value;
if (el) el.scrollTop = el.scrollHeight;
};
const fetchMessages = async () => {
try {
const { data } = await hermesBuilderApi.fetchMessages();
messages.value = data.messages || [];
sessionId.value = data.session_id;
await nextTick();
scrollToBottom();
} catch (e) {
// silencioso polling repete
}
};
const sendMessage = async () => {
const text = input.value.trim();
if (!text || sending.value) return;
sending.value = true;
messages.value.push({
role: 'user',
content: text,
created_at: new Date().toISOString(),
});
input.value = '';
await nextTick();
scrollToBottom();
try {
await hermesBuilderApi.sendMessage(text);
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const handleKeydown = e => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const resetSession = async () => {
// eslint-disable-next-line no-alert
if (!window.confirm(t('CAPTAIN_HERMES_BUILDER.RESET_CONFIRM'))) return;
try {
await hermesBuilderApi.reset();
messages.value = [];
} catch (e) {
useAlert(t('CAPTAIN_HERMES_BUILDER.RESET_FAILED'));
}
};
const startSession = async () => {
if (sending.value) return;
sending.value = true;
try {
await hermesBuilderApi.start();
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.SEND_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
sending.value = false;
}
};
const formatTime = iso => {
if (!iso) return '';
const d = new Date(iso);
return d.toLocaleTimeString('pt-BR', { hour: '2-digit', minute: '2-digit' });
};
onMounted(() => {
fetchMessages();
polling.value = setInterval(fetchMessages, 2000);
});
onBeforeUnmount(() => {
if (polling.value) clearInterval(polling.value);
});
watch(messages, () => nextTick().then(scrollToBottom), { deep: true });
</script>
<template>
<div class="builder-wrapper">
<header class="builder-header">
<div>
<h2>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.HEADER_DESCRIPTION') }}</p>
</div>
<Button variant="ghost" size="sm" @click="resetSession">
{{ t('CAPTAIN_HERMES_BUILDER.RESET') }}
</Button>
</header>
<section ref="scrollContainer" class="messages">
<div v-if="!messages.length" class="empty-state">
<p>{{ t('CAPTAIN_HERMES_BUILDER.EMPTY_STATE') }}</p>
<button
type="button"
class="start-button"
:disabled="sending"
@click="startSession"
>
{{ t('CAPTAIN_HERMES_BUILDER.START') }}
</button>
</div>
<div
v-for="(msg, idx) in messages"
:key="idx"
class="msg"
:class="[`msg--${msg.role}`]"
>
<div class="msg__bubble">
<div class="msg__content">{{ msg.content }}</div>
<div class="msg__meta">{{ formatTime(msg.created_at) }}</div>
</div>
</div>
<div v-if="isWaiting" class="msg msg--construtor">
<div class="msg__bubble msg__bubble--typing">
<span class="dot" /><span class="dot" /><span class="dot" />
</div>
</div>
</section>
<footer class="composer">
<textarea
v-model="input"
rows="2"
:placeholder="t('CAPTAIN_HERMES_BUILDER.PLACEHOLDER')"
:disabled="sending"
@keydown="handleKeydown"
/>
<Button
variant="primary"
:disabled="!input.trim() || sending"
@click="sendMessage"
>
{{ t('CAPTAIN_HERMES_BUILDER.SEND') }}
</Button>
</footer>
<p v-if="sessionId" class="session-debug">
{{ t('CAPTAIN_HERMES_BUILDER.SESSION_LABEL') }} {{ sessionId }}
</p>
</div>
</template>
<style scoped lang="scss">
.builder-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
height: calc(100vh - 260px);
max-width: 900px;
margin: 0 auto;
}
.builder-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
}
}
.messages {
flex: 1;
overflow-y: auto;
padding: 16px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-state {
margin: auto;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
text-align: center;
display: flex;
flex-direction: column;
gap: 16px;
align-items: center;
p {
margin: 0;
}
}
.start-button {
background: var(--color-woot-500, #1f93ff);
color: #fff;
border: none;
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-600, #1976d2);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.msg {
display: flex;
&--user {
justify-content: flex-end;
}
&--construtor {
justify-content: flex-start;
}
}
.msg__bubble {
max-width: 70%;
padding: 10px 14px;
border-radius: 14px;
background: var(--color-background-light, #f3f4f6);
font-size: 14px;
.msg--user & {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
}
.msg__content {
white-space: pre-wrap;
word-break: break-word;
}
.msg__meta {
font-size: 11px;
margin-top: 4px;
opacity: 0.7;
}
.msg__bubble--typing {
display: flex;
gap: 4px;
padding: 12px 16px;
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--color-text-light, #6b7280);
animation: typing 1.4s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes typing {
0%,
60%,
100% {
opacity: 0.3;
transform: translateY(0);
}
30% {
opacity: 1;
transform: translateY(-3px);
}
}
.composer {
display: flex;
gap: 8px;
padding: 12px;
background: var(--color-background, #fff);
border-radius: 12px;
border: 1px solid var(--color-border, #e5e7eb);
textarea {
flex: 1;
border: none;
resize: none;
outline: none;
font: inherit;
background: transparent;
color: inherit;
}
}
.session-debug {
font-size: 11px;
color: var(--color-text-light, #9ca3af);
text-align: right;
margin: 0;
}
</style>

View File

@ -1,443 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import hermesBuilderApi from 'dashboard/api/captain/hermesBuilder';
const { t } = useI18n();
const assistants = ref([]);
const selectedSlug = ref('');
const checks = ref([]);
const summary = ref(null);
const loading = ref(false);
const repairing = ref({});
const groupedChecks = computed(() => {
const groups = {};
checks.value.forEach(c => {
const cat = c.category || 'outros';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(c);
});
return groups;
});
const categoryLabel = cat => {
const map = {
db: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_DB',
pricing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_PRICING',
routing: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_ROUTING',
humanization: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_HUMANIZATION',
mcp: 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_MCP',
};
return t(map[cat] || 'CAPTAIN_HERMES_BUILDER.VERIFY.CATEGORY_OTHER');
};
const fetchAssistants = async () => {
try {
const { data } = await hermesBuilderApi.fetchAssistants();
assistants.value = data.assistants || [];
if (assistants.value.length && !selectedSlug.value) {
selectedSlug.value = assistants.value[0].slug;
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.FETCH_FAILED', {
message: e.message || 'unknown',
})
);
}
};
const runValidation = async () => {
if (!selectedSlug.value || loading.value) return;
loading.value = true;
checks.value = [];
summary.value = null;
try {
const { data } = await hermesBuilderApi.validate(selectedSlug.value);
checks.value = data.results || [];
summary.value = data;
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.VALIDATE_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
loading.value = false;
}
};
const runRepair = async check => {
if (!check.repair_id) return;
repairing.value[check.repair_id] = true;
try {
const { data } = await hermesBuilderApi.repair(
selectedSlug.value,
check.repair_id
);
if (data.ok) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_OK', {
message: data.message || 'OK',
})
);
await runValidation();
} else {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: data.error || 'unknown',
})
);
}
} catch (e) {
useAlert(
t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR_FAILED', {
message: e.response?.data?.error || e.message || 'unknown',
})
);
} finally {
repairing.value[check.repair_id] = false;
}
};
const statusIcon = status => {
if (status === 'PASS') return '✓';
if (status === 'FAIL') return '✗';
if (status === 'WARN') return '⚠';
return '?';
};
const statusClass = status => {
if (status === 'PASS') return 'badge--pass';
if (status === 'FAIL') return 'badge--fail';
if (status === 'WARN') return 'badge--warn';
return 'badge--unknown';
};
onMounted(() => {
fetchAssistants();
});
</script>
<template>
<div class="verification-wrapper">
<header class="verification-header">
<h2>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.TITLE') }}</h2>
<p>{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.DESCRIPTION') }}</p>
</header>
<div class="controls">
<select
v-model="selectedSlug"
class="select"
:disabled="!assistants.length || loading"
>
<option v-if="!assistants.length" value="">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.NO_ASSISTANTS') }}
</option>
<option v-for="a in assistants" :key="a.id" :value="a.slug">
{{ a.name }} {{ a.slug }}
</option>
</select>
<Button
variant="primary"
:disabled="!selectedSlug || loading"
@click="runValidation"
>
{{
loading
? t('CAPTAIN_HERMES_BUILDER.VERIFY.RUNNING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.RUN')
}}
</Button>
</div>
<div v-if="summary" class="summary">
<span class="summary__item summary__item--pass">
{{ summary.pass }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.OK_LABEL') }}
</span>
<span v-if="summary.fail" class="summary__item summary__item--fail">
{{ summary.fail }}
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.FAILS_LABEL') }}
</span>
<span v-if="summary.warn" class="summary__item summary__item--warn">
{{ summary.warn }} {{ t('CAPTAIN_HERMES_BUILDER.VERIFY.WARN_LABEL') }}
</span>
<span class="summary__total">
{{
t('CAPTAIN_HERMES_BUILDER.VERIFY.OF_TOTAL', { total: summary.total })
}}
</span>
<span v-if="summary.ok" class="summary__verdict summary__verdict--pass">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_PASS') }}
</span>
<span v-else class="summary__verdict summary__verdict--fail">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.VERDICT_FAIL') }}
</span>
</div>
<section v-if="checks.length" class="checks-section">
<div v-for="(items, cat) in groupedChecks" :key="cat" class="check-group">
<h3 class="check-group__title">
{{ categoryLabel(cat) }}
</h3>
<ul class="check-list">
<li
v-for="(check, idx) in items"
:key="idx"
class="check-item"
:class="`check-item--${check.status.toLowerCase()}`"
>
<span class="check-item__badge" :class="statusClass(check.status)">
{{ statusIcon(check.status) }}
</span>
<div class="check-item__body">
<div class="check-item__label">{{ check.label }}</div>
<div v-if="check.detail" class="check-item__detail">
{{ check.detail }}
</div>
</div>
<button
v-if="
check.repair_id &&
(check.status === 'FAIL' || check.status === 'WARN')
"
type="button"
class="repair-btn"
:disabled="repairing[check.repair_id]"
@click="runRepair(check)"
>
{{
repairing[check.repair_id]
? t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIRING')
: t('CAPTAIN_HERMES_BUILDER.VERIFY.REPAIR')
}}
</button>
</li>
</ul>
</div>
</section>
<p v-else-if="!loading && summary" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY_RESULTS') }}
</p>
<p v-else-if="!loading" class="empty-state">
{{ t('CAPTAIN_HERMES_BUILDER.VERIFY.EMPTY') }}
</p>
</div>
</template>
<style scoped lang="scss">
.verification-wrapper {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 1000px;
margin: 0 auto;
height: calc(100vh - 260px);
overflow-y: auto;
padding-right: 8px;
}
.verification-header {
padding: 16px 20px;
background: var(--color-background-light, #f7f8fa);
border-radius: 12px;
h2 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
}
p {
margin: 0;
color: var(--color-text-light, #6b7280);
font-size: 13px;
line-height: 1.5;
}
}
.controls {
display: flex;
gap: 12px;
align-items: center;
.select {
flex: 1;
padding: 10px 12px;
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
background: var(--color-background, #fff);
font-size: 14px;
outline: none;
&:focus {
border-color: var(--color-woot-500, #1f93ff);
}
}
}
.summary {
display: flex;
gap: 16px;
align-items: center;
padding: 12px 16px;
background: var(--color-background, #fff);
border-radius: 8px;
border: 1px solid var(--color-border, #e5e7eb);
font-size: 13px;
flex-wrap: wrap;
&__item {
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
&--warn {
color: #d97706;
}
}
&__total {
color: var(--color-text-light, #6b7280);
}
&__verdict {
margin-left: auto;
font-weight: 600;
&--pass {
color: #16a34a;
}
&--fail {
color: #dc2626;
}
}
}
.checks-section {
display: flex;
flex-direction: column;
gap: 16px;
}
.check-group {
background: var(--color-background, #fff);
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 12px;
padding: 12px 16px;
&__title {
margin: 0 0 8px;
font-size: 13px;
font-weight: 600;
color: var(--color-text-light, #6b7280);
text-transform: uppercase;
letter-spacing: 0.04em;
}
}
.check-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.check-item {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 8px 4px;
border-radius: 6px;
font-size: 13px;
&--fail {
background: #fef2f2;
}
&--warn {
background: #fffbeb;
}
}
.check-item__badge {
flex-shrink: 0;
width: 22px;
height: 22px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 700;
font-size: 12px;
color: #fff;
&.badge--pass {
background: #16a34a;
}
&.badge--fail {
background: #dc2626;
}
&.badge--warn {
background: #d97706;
}
}
.check-item__body {
flex: 1;
min-width: 0;
}
.check-item__label {
font-weight: 500;
}
.check-item__detail {
margin-top: 2px;
color: var(--color-text-light, #6b7280);
font-size: 12px;
word-break: break-word;
}
.repair-btn {
flex-shrink: 0;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--color-woot-500, #1f93ff);
background: var(--color-background, #fff);
color: var(--color-woot-500, #1f93ff);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
&:hover:not(:disabled) {
background: var(--color-woot-500, #1f93ff);
color: #fff;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
.empty-state {
text-align: center;
color: var(--color-text-light, #9ca3af);
font-size: 14px;
padding: 32px;
margin: 0;
}
</style>

View File

@ -1,51 +0,0 @@
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import BuilderChat from './BuilderChat.vue';
import BuilderVerification from './BuilderVerification.vue';
const { t } = useI18n();
const tabs = computed(() => [
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_CHAT'), key: 'chat' },
{ label: t('CAPTAIN_HERMES_BUILDER.TAB_VERIFY'), key: 'verification' },
]);
const activeIndex = ref(0);
const handleTabChanged = tab => {
activeIndex.value = tabs.value.findIndex(item => item.key === tab.key);
};
</script>
<template>
<PageLayout
:title="t('CAPTAIN_HERMES_BUILDER.TITLE')"
:description="t('CAPTAIN_HERMES_BUILDER.DESCRIPTION')"
>
<div class="builder-tabs">
<TabBar
:tabs="tabs"
:initial-active-tab="activeIndex"
@tab-changed="handleTabChanged"
/>
</div>
<div class="builder-panels">
<BuilderChat v-show="activeIndex === 0" />
<BuilderVerification v-show="activeIndex === 1" />
</div>
</PageLayout>
</template>
<style scoped lang="scss">
.builder-tabs {
margin-bottom: 16px;
}
.builder-panels {
display: flex;
flex-direction: column;
}
</style>

View File

@ -1,6 +1,5 @@
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { INSTALLATION_TYPES } from 'dashboard/constants/installationTypes';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import { frontendURL } from '../../../helper/URLHelper';
import CaptainPageRouteView from './pages/CaptainPageRouteView.vue';
@ -19,7 +18,6 @@ import ResponsesPendingIndex from './responses/Pending.vue';
import CustomToolsIndex from './tools/Index.vue';
import ReservationsIndex from './reservations/Index.vue';
import RoletaIndex from './roleta/Index.vue';
import HermesBuilderIndex from './builder/Index.vue';
import FunnelIndex from './funnel/Index.vue';
import LifecycleIndex from './lifecycle/Index.vue';
import LifecycleRules from './lifecycle/Rules.vue';
@ -32,11 +30,6 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const knowledgeBaseMeta = {
...meta,
permissions: ['administrator', 'agent', PORTAL_PERMISSIONS],
};
const metaV2 = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@ -48,13 +41,13 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs'),
component: ResponsesIndex,
name: 'captain_assistants_responses_index',
meta: knowledgeBaseMeta,
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/documents'),
component: DocumentsIndex,
name: 'captain_assistants_documents_index',
meta: knowledgeBaseMeta,
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
@ -84,7 +77,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/faqs/pending'),
component: ResponsesPendingIndex,
name: 'captain_assistants_responses_pending',
meta: knowledgeBaseMeta,
meta,
},
{
path: frontendURL('accounts/:accountId/captain/:assistantId/settings'),
@ -125,7 +118,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:navigationPath'),
component: AssistantsIndexPage,
name: 'captain_assistants_index',
meta: knowledgeBaseMeta,
meta,
},
];
@ -156,19 +149,6 @@ export const routes = [
name: 'captain_roleta_index',
meta,
},
{
path: frontendURL('accounts/:accountId/captain/hermes-builder'),
component: HermesBuilderIndex,
name: 'captain_hermes_builder_index',
meta: {
permissions: ['administrator'],
featureFlag: FEATURE_FLAGS.CAPTAIN,
installationTypes: [
INSTALLATION_TYPES.CLOUD,
INSTALLATION_TYPES.ENTERPRISE,
],
},
},
{
path: frontendURL('accounts/:accountId/captain/funnel'),
component: FunnelIndex,

View File

@ -102,7 +102,7 @@ const groupedReservations = computed(() => {
return groups;
});
const pageStatusCounts = computed(() => {
const statusCounts = computed(() => {
const counts = {
all: reservations.value.length,
draft: 0,
@ -117,23 +117,6 @@ const pageStatusCounts = computed(() => {
return counts;
});
const statusCounts = computed(() => {
const metaCounts = reservationsMeta.value.statusCounts || {};
return {
all: Number(
metaCounts.all ??
reservationsMeta.value.totalCount ??
pageStatusCounts.value.all
),
draft: Number(metaCounts.draft ?? pageStatusCounts.value.draft),
pending_payment: Number(
metaCounts.pending_payment ?? pageStatusCounts.value.pending_payment
),
confirmed: Number(metaCounts.confirmed ?? pageStatusCounts.value.confirmed),
cancelled: Number(metaCounts.cancelled ?? pageStatusCounts.value.cancelled),
};
});
const todayRevenue = computed(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);

View File

@ -6,8 +6,6 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Banner from 'dashboard/components-next/banner/Banner.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@ -26,7 +24,6 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
@ -41,10 +38,6 @@ const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const selectedAssistantId = computed(() => Number(route.params.assistantId));
@ -213,7 +206,7 @@ onMounted(() => {
<PageLayout
:total-count="responseMeta.totalCount"
:current-page="responseMeta.page"
:button-policy="responseManagePermissions"
:button-policy="['administrator']"
:header-title="$t('CAPTAIN.RESPONSES.HEADER')"
:button-label="$t('CAPTAIN.RESPONSES.ADD_NEW')"
:is-fetching="isFetching"
@ -254,7 +247,6 @@ onMounted(() => {
<template #subHeader>
<BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds"
:all-items="responses"
:select-all-label="buildSelectedCountLabel"
@ -301,11 +293,8 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:show-menu="canManageResponses && !bulkSelectedIds.has(response.id)"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="!bulkSelectedIds.has(response.id)"
:show-actions="false"
@action="handleAction"
@navigate="handleNavigationAction"

View File

@ -7,8 +7,6 @@ import { useRouter, useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { debounce } from '@chatwoot/utils';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import { PORTAL_PERMISSIONS } from 'dashboard/constants/permissions';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
@ -27,7 +25,6 @@ const router = useRouter();
const route = useRoute();
const store = useStore();
const { isOnChatwootCloud } = useAccount();
const { checkPermissions } = usePolicy();
const uiFlags = useMapGetter('captainResponses/getUIFlags');
const responseMeta = useMapGetter('captainResponses/getMeta');
const responses = useMapGetter('captainResponses/getRecords');
@ -43,10 +40,6 @@ const searchQuery = ref('');
const { t } = useI18n();
const createDialog = ref(null);
const responseManagePermissions = ['administrator', PORTAL_PERMISSIONS];
const canManageResponses = computed(() =>
checkPermissions(responseManagePermissions)
);
const backUrl = computed(() => ({
name: 'captain_assistants_responses_index',
@ -293,7 +286,6 @@ onMounted(() => {
<template #subHeader>
<BulkSelectBar
v-if="canManageResponses"
v-model="bulkSelectedIds"
:all-items="filteredResponses"
:select-all-label="buildSelectedCountLabel"
@ -346,14 +338,9 @@ onMounted(() => {
:created-at="response.created_at"
:updated-at="response.updated_at"
:is-selected="bulkSelectedIds.has(response.id)"
:selectable="
canManageResponses &&
(hoveredCard === response.id || bulkSelectedIds.size > 0)
"
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
:show-menu="false"
:show-actions="
canManageResponses && !bulkSelectedIds.has(response.id)
"
:show-actions="!bulkSelectedIds.has(response.id)"
@action="handleAction"
@navigate="handleNavigationAction"
@select="handleCardSelect"

View File

@ -15,7 +15,6 @@ 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 {
@ -26,7 +25,6 @@ export default {
BuildInfo,
AccountDelete,
AudioTranscription,
AggressiveAlertSetting,
SectionLayout,
WithLabel,
NextInput,
@ -234,7 +232,6 @@ 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

@ -1,49 +0,0 @@
<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

@ -6,7 +6,6 @@ import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import Button from 'dashboard/components-next/button/Button.vue';
import Multiselect from 'vue-multiselect';
import Auth from '../../../../api/auth';
import wootConstants from 'dashboard/constants/globals';
@ -39,10 +38,6 @@ const props = defineProps({
type: Number,
default: null,
},
uiSettings: {
type: Object,
default: () => ({}),
},
});
const emit = defineEmits(['close']);
@ -57,27 +52,6 @@ const agentAvailability = ref(props.availability);
const selectedRoleId = ref(props.customRoleId || props.type);
const agentCredentials = ref({ email: props.email });
// --- Alerta agressivo por inbox -------------------------------------------
// ui_settings.aggressive_alert_inbox_ids:
// null/undefined todas (default, legado)
// [] nenhuma (silenciou tudo)
// [1, 2, 3] só essas
const initialInboxIds = props.uiSettings?.aggressive_alert_inbox_ids;
const alertAllInboxes = ref(
initialInboxIds === null ||
initialInboxIds === undefined ||
!Array.isArray(initialInboxIds)
);
const inboxes = useMapGetter('inboxes/getInboxes');
const selectedAlertInboxes = ref(
Array.isArray(initialInboxIds) && inboxes.value
? inboxes.value.filter(i =>
initialInboxIds.map(id => Number(id)).includes(Number(i.id))
)
: []
);
const rules = {
agentName: { required, minLength: minLength(1) },
selectedRoleId: { required },
@ -161,12 +135,6 @@ const editAgent = async () => {
payload.custom_role_id = null;
}
payload.ui_settings = {
aggressive_alert_inbox_ids: alertAllInboxes.value
? null
: selectedAlertInboxes.value.map(i => Number(i.id)),
};
await store.dispatch('agents/update', payload);
useAlert(t('AGENT_MGMT.EDIT.API.SUCCESS_MESSAGE'));
emit('close');
@ -236,47 +204,6 @@ const resetPassword = async () => {
</label>
</div>
<div class="w-full">
<div class="mt-2 border-t pt-3 border-n-weak">
<span class="block font-medium mb-1">
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.LABEL') }}
</span>
<p class="text-xs text-n-slate-11 mb-2">
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.DESCRIPTION') }}
</p>
<label class="flex items-center gap-2 mb-2">
<input v-model="alertAllInboxes" type="checkbox" class="!m-0" />
<span>
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.ALL_INBOXES') }}
</span>
</label>
<div v-if="!alertAllInboxes">
<Multiselect
v-model="selectedAlertInboxes"
:options="inboxes || []"
track-by="id"
label="name"
multiple
:close-on-select="false"
:clear-on-select="false"
hide-selected
:placeholder="$t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.PICK_INBOXES')"
selected-label
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
:deselect-label="$t('FORMS.MULTISELECT.ENTER_TO_REMOVE')"
/>
<p
v-if="selectedAlertInboxes.length === 0"
class="text-xs text-n-slate-11 mt-1"
>
{{ $t('AGENT_MGMT.EDIT.AGGRESSIVE_ALERT.NONE_WARNING') }}
</p>
</div>
</div>
</div>
<div class="flex flex-row justify-start w-full gap-2 px-0 py-2">
<div class="w-[50%] ltr:text-left rtl:text-right">
<Button

View File

@ -266,7 +266,6 @@ const confirmDeletion = () => {
:email="currentAgent.email"
:availability="currentAgent.availability_status"
:custom-role-id="currentAgent.custom_role_id"
:ui-settings="currentAgent.ui_settings || {}"
@close="hideEditPopup"
/>
</woot-modal>

View File

@ -8,6 +8,7 @@ import UnitEdit from './units/Edit.vue';
import GalleryIndex from './gallery/Index.vue';
import GalleryEdit from './gallery/Edit.vue';
const ReportsIndex = () => import('./reports/Index.vue');
const NotificationsIndex = () => import('./notifications/Index.vue');
export default {
routes: [
@ -58,7 +59,7 @@ export default {
name: 'captain_settings_gallery',
component: GalleryIndex,
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator'],
},
},
{
@ -66,7 +67,7 @@ export default {
name: 'captain_settings_gallery_edit',
component: GalleryEdit,
meta: {
permissions: ['administrator', 'agent'],
permissions: ['administrator'],
},
},
{
@ -77,6 +78,14 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'notifications',
name: 'captain_settings_notifications',
component: NotificationsIndex,
meta: {
permissions: ['administrator'],
},
},
],
},
],

View File

@ -0,0 +1,443 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import SettingsLayout from '../../SettingsLayout.vue';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
import Button from 'dashboard/components-next/button/Button.vue';
const { t } = useI18n();
const store = useStore();
const inboxes = useMapGetter('inboxes/getInboxes');
const templates = useMapGetter('captainNotificationTemplates/getRecords');
const uiFlags = useMapGetter('captainNotificationTemplates/getUIFlags');
const selectedInboxId = ref(null);
const editingId = ref(null);
const showNewForm = ref(false);
// Todas as inboxes da conta (o usuário escolhe em qual configurar)
const captainInboxes = computed(() => inboxes.value || []);
const hasInboxes = computed(() => captainInboxes.value.length > 0);
// Formulários
const emptyForm = () => ({
label: '',
content: '',
timing_minutes: 10,
timing_direction: 'before',
active: true,
});
const newForm = ref(emptyForm());
const editForm = ref(emptyForm());
const VARIABLES = [
'{{guest_name}}',
'{{check_in_time}}',
'{{check_out_time}}',
'{{suite_name}}',
'{{unit_name}}',
];
// Carregamento
onMounted(async () => {
await store.dispatch('inboxes/get');
});
watch(selectedInboxId, async id => {
if (id) {
await store.dispatch('captainNotificationTemplates/fetch', id);
showNewForm.value = false;
editingId.value = null;
}
});
// Novo template
const openNewForm = () => {
newForm.value = emptyForm();
showNewForm.value = true;
};
const cancelNew = () => {
showNewForm.value = false;
newForm.value = emptyForm();
};
const saveNew = async () => {
if (!newForm.value.label || !newForm.value.content) return;
try {
await store.dispatch('captainNotificationTemplates/create', {
inboxId: selectedInboxId.value,
payload: newForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.SUCCESS'));
showNewForm.value = false;
newForm.value = emptyForm();
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.CREATE.ERROR'));
}
};
// Edição
const startEdit = template => {
editingId.value = template.id;
editForm.value = { ...template };
};
const cancelEdit = () => {
editingId.value = null;
editForm.value = emptyForm();
};
const saveEdit = async () => {
try {
await store.dispatch('captainNotificationTemplates/update', {
inboxId: selectedInboxId.value,
id: editingId.value,
payload: editForm.value,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.SUCCESS'));
editingId.value = null;
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
}
};
// Toggle ativo
const toggleActive = async template => {
try {
await store.dispatch('captainNotificationTemplates/update', {
inboxId: selectedInboxId.value,
id: template.id,
payload: { active: !template.active },
});
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.UPDATE.ERROR'));
}
};
// Exclusão
const deleteTemplate = async template => {
try {
await store.dispatch('captainNotificationTemplates/delete', {
inboxId: selectedInboxId.value,
id: template.id,
});
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.SUCCESS'));
} catch {
useAlert(t('CAPTAIN_SETTINGS.NOTIFICATIONS.DELETE.ERROR'));
}
};
// Variáveis
const insertVariable = (variable, target) => {
if (target === 'new') {
newForm.value.content += variable;
} else {
editForm.value.content += variable;
}
};
const directionLabel = direction =>
direction === 'before'
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER');
const timingDisplay = template =>
`${template.timing_minutes} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')} ${directionLabel(template.timing_direction)} ${t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')}`;
</script>
<template>
<SettingsLayout
:is-loading="uiFlags.isFetching"
:loading-message="t('CAPTAIN_SETTINGS.NOTIFICATIONS.LOADING')"
>
<template #header>
<BaseSettingsHeader
:title="t('CAPTAIN_SETTINGS.NOTIFICATIONS.TITLE')"
:description="t('CAPTAIN_SETTINGS.NOTIFICATIONS.DESCRIPTION')"
>
<template #actions>
<Button
v-if="selectedInboxId && !showNewForm"
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<div class="flex flex-col gap-6 px-6 pb-8">
<!-- Seletor de inbox -->
<div class="flex flex-col gap-2">
<label class="text-sm font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.INBOX_LABEL') }}
</label>
<div v-if="!hasInboxes" class="text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.NO_CAPTAIN_INBOXES') }}
</div>
<div v-else class="flex flex-wrap gap-2">
<button
v-for="inbox in captainInboxes"
:key="inbox.id"
class="flex items-center gap-2 rounded-lg border px-4 py-2 text-sm transition-colors"
:class="
selectedInboxId === inbox.id
? 'border-w-500 bg-w-50 text-w-700 font-medium'
: 'border-n-weak text-n-slate-11 hover:border-n-300'
"
@click="selectedInboxId = inbox.id"
>
<span class="i-lucide-message-circle w-4 h-4" />
{{ inbox.name }}
</button>
</div>
</div>
<!-- Conteúdo: aparece após selecionar inbox -->
<div v-if="selectedInboxId" class="flex flex-col gap-3">
<!-- Template list -->
<div
v-for="template in templates"
:key="template.id"
class="rounded-lg border border-n-75 bg-white p-4"
>
<!-- View mode -->
<div
v-if="editingId !== template.id"
class="flex items-start justify-between gap-3"
>
<div class="flex flex-col gap-1 flex-1 min-w-0">
<span class="text-sm font-semibold text-n-slate-12">{{
template.label
}}</span>
<span class="text-sm text-n-slate-11 whitespace-pre-line">{{
template.content
}}</span>
<span class="text-xs text-n-slate-10 mt-1">
{{ timingDisplay(template) }}
</span>
</div>
<div class="flex items-center gap-2 shrink-0">
<button
class="text-xs px-2 py-1 rounded"
:class="
template.active
? 'bg-n-teal-2 text-n-teal-11'
: 'bg-n-slate-3 text-n-slate-11'
"
@click="toggleActive(template)"
>
{{
template.active
? t('CAPTAIN_SETTINGS.NOTIFICATIONS.ACTIVE')
: t('CAPTAIN_SETTINGS.NOTIFICATIONS.INACTIVE')
}}
</button>
<button
class="text-n-slate-10 hover:text-n-slate-12"
@click="startEdit(template)"
>
<span class="i-lucide-pencil w-4 h-4" />
</button>
<button
class="text-n-ruby-9 hover:text-n-ruby-11"
@click="deleteTemplate(template)"
>
<span class="i-lucide-trash-2 w-4 h-4" />
</button>
</div>
</div>
<!-- Edit mode -->
<div v-else class="flex flex-col gap-3">
<input
v-model="editForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
"
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="editForm.content"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
"
rows="3"
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/>
<!-- Variable chips -->
<div class="flex flex-wrap gap-1">
<button
v-for="v in VARIABLES"
:key="v"
class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="insertVariable(v, 'edit')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="editForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="editForm.timing_direction"
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelEdit"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isSaving"
@click="saveEdit"
/>
</div>
</div>
</div>
<!-- New form -->
<div
v-if="showNewForm"
class="rounded-lg border border-w-300 bg-w-25 p-4 flex flex-col gap-3"
>
<input
v-model="newForm.label"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.LABEL_PLACEHOLDER')
"
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500"
/>
<textarea
v-model="newForm.content"
:placeholder="
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CONTENT_PLACEHOLDER')
"
rows="3"
class="w-full rounded border border-n-weak px-3 py-2 text-sm focus:outline-none focus:border-w-500 resize-none"
/>
<!-- Variable chips -->
<div class="flex flex-wrap gap-1">
<button
v-for="v in VARIABLES"
:key="v"
class="text-xs bg-n-slate-3 text-n-slate-11 px-2 py-0.5 rounded hover:bg-n-slate-4"
@click="insertVariable(v, 'new')"
>
{{ v }}
</button>
</div>
<!-- Timing row -->
<div class="flex items-center gap-2 text-sm flex-wrap">
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SEND')
}}</span>
<input
v-model.number="newForm.timing_minutes"
type="number"
min="1"
class="w-16 rounded border border-n-weak px-2 py-1 text-sm text-center focus:outline-none focus:border-w-500"
/>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.MINUTES')
}}</span>
<select
v-model="newForm.timing_direction"
class="rounded border border-n-weak px-2 py-1 text-sm focus:outline-none focus:border-w-500"
>
<option value="before">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.BEFORE') }}
</option>
<option value="after">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.AFTER') }}
</option>
</select>
<span class="text-n-slate-11">{{
t('CAPTAIN_SETTINGS.NOTIFICATIONS.DIRECTION.OF_ARRIVAL')
}}</span>
</div>
<div class="flex gap-2 justify-end">
<Button
variant="clear"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.CANCEL')"
@click="cancelNew"
/>
<Button
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.FORM.SAVE')"
:is-loading="uiFlags.isSaving"
@click="saveNew"
/>
</div>
</div>
<!-- Empty state (sem templates, sem form aberto) -->
<div
v-if="!templates.length && !showNewForm"
class="flex flex-col items-center justify-center gap-4 py-16 text-center"
>
<div
class="size-14 rounded-full bg-n-slate-3 flex items-center justify-center"
>
<span class="i-lucide-bell w-6 h-6 text-n-slate-10" />
</div>
<div class="flex flex-col gap-1">
<p class="mb-0 text-base font-medium text-n-slate-12">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.TITLE') }}
</p>
<p class="mb-0 max-w-sm text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.EMPTY.DESC') }}
</p>
</div>
<Button
icon="i-lucide-plus"
:label="t('CAPTAIN_SETTINGS.NOTIFICATIONS.ADD')"
@click="openNewForm"
/>
</div>
</div>
<!-- Estado inicial: nenhuma inbox selecionada -->
<div
v-else-if="hasInboxes"
class="flex flex-col items-center justify-center gap-3 py-16 text-center"
>
<span class="i-lucide-mouse-pointer-click w-8 h-8 text-n-slate-9" />
<p class="mb-0 text-sm text-n-slate-10">
{{ t('CAPTAIN_SETTINGS.NOTIFICATIONS.SELECT_INBOX_HINT') }}
</p>
</div>
</div>
</template>
</SettingsLayout>
</template>

View File

@ -253,22 +253,12 @@ const fetchLpStats = async () => {
let pollInterval = null;
const insightFilterParams = () => {
const { period_start, period_end } = getPeriodDates(selectedPeriod.value);
return {
...(selectedInboxId.value && { inbox_id: selectedInboxId.value }),
...(period_start && period_end && { period_start, period_end }),
};
};
const fetchInsightsForSelectedFilters = async () => {
await store.dispatch('captainReports/fetchInsights', insightFilterParams());
};
const startPolling = () => {
if (pollInterval) return;
pollInterval = setInterval(async () => {
await fetchInsightsForSelectedFilters();
await store.dispatch('captainReports/fetchInsights', {
inbox_id: selectedInboxId.value,
});
}, 10000);
};
@ -299,7 +289,6 @@ watch(activeTab, async tab => {
watch([customStartDate, customEndDate], async () => {
if (selectedPeriod.value !== 'custom') return;
if (!customStartDate.value || !customEndDate.value) return;
await fetchInsightsForSelectedFilters();
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
@ -319,7 +308,7 @@ watch(
onMounted(async () => {
await store.dispatch('inboxes/get');
await store.dispatch('captainAssistants/get');
await fetchInsightsForSelectedFilters();
await store.dispatch('captainReports/fetchInsights', {});
if (hasProcessingInsights.value) startPolling();
await fetchLpStats();
});
@ -331,7 +320,9 @@ onUnmounted(() => {
const onFilterChange = async event => {
const value = event.target.value;
selectedInboxId.value = value ? Number(value) : null;
await fetchInsightsForSelectedFilters();
await store.dispatch('captainReports/fetchInsights', {
inbox_id: selectedInboxId.value,
});
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();
@ -339,7 +330,6 @@ const onFilterChange = async event => {
const onPeriodChange = async event => {
selectedPeriod.value = event.target.value;
await fetchInsightsForSelectedFilters();
if (activeTab.value === 'landing_pages') await fetchLpStats();
if (activeTab.value === 'operational') await fetchOperational();
if (activeTab.value === 'executive') await fetchExecutive();

View File

@ -1,37 +0,0 @@
<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

@ -1,233 +0,0 @@
<script setup>
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { computed, ref, watch } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const getters = useStoreGetters();
const { uiSettings, updateUISettings } = useUISettings();
const inboxes = computed(() => getters['inboxes/getInboxes'].value || []);
// Modelo: ui_settings.aggressive_alert_inbox_ids
// undefined / null todas as inboxes (default histórico)
// [] desligado pra esse usuário
// [id, id, ...] apenas essas inboxes
const enabled = ref(true);
const selectedInboxIds = ref([]);
const applyToAll = ref(true);
const initFromSettings = settings => {
const raw = settings?.aggressive_alert_inbox_ids;
if (Array.isArray(raw)) {
if (raw.length === 0) {
enabled.value = false;
applyToAll.value = true;
selectedInboxIds.value = [];
} else {
enabled.value = true;
applyToAll.value = false;
selectedInboxIds.value = raw.map(id => Number(id));
}
} else {
enabled.value = true;
applyToAll.value = true;
selectedInboxIds.value = [];
}
};
watch(
uiSettings,
value => {
initFromSettings(value);
},
{ immediate: true }
);
const persist = async () => {
let value;
if (!enabled.value) {
value = [];
} else if (applyToAll.value) {
value = null;
} else {
value = selectedInboxIds.value.map(id => Number(id));
}
try {
await updateUISettings({ aggressive_alert_inbox_ids: value });
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_SUCCESS'));
} catch (e) {
useAlert(t('PROFILE_SETTINGS.FORM.API.UPDATE_ERROR'));
}
};
const handleEnabledChange = event => {
enabled.value = event.target.checked;
persist();
};
const handleApplyToAllChange = event => {
applyToAll.value = event.target.checked;
if (applyToAll.value) {
selectedInboxIds.value = [];
}
persist();
};
const handleInboxToggle = inboxId => {
const id = Number(inboxId);
if (selectedInboxIds.value.includes(id)) {
selectedInboxIds.value = selectedInboxIds.value.filter(i => i !== id);
} else {
selectedInboxIds.value = [...selectedInboxIds.value, id];
}
persist();
};
const isInboxSelected = inboxId =>
selectedInboxIds.value.includes(Number(inboxId));
</script>
<template>
<div class="aggressive-alert-settings flex flex-col gap-4">
<p class="description">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.DESCRIPTION',
'Banner vermelho que aparece quando uma conversa fica sem resposta há 5+ minutos. Útil pra não perder cliente, mas pode ser intrusivo se você não atende todas as inboxes.'
)
}}
</p>
<label class="toggle-row">
<input
type="checkbox"
:checked="enabled"
class="toggle-input"
@change="handleEnabledChange"
/>
<span class="toggle-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.ENABLED',
'Ativar alerta de conversa parada'
)
}}
</span>
</label>
<div v-if="enabled" class="scope-section">
<label class="toggle-row">
<input
type="checkbox"
:checked="applyToAll"
class="toggle-input"
@change="handleApplyToAllChange"
/>
<span class="toggle-label">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.APPLY_TO_ALL',
'Aplicar em todas as caixas de entrada'
)
}}
</span>
</label>
<div v-if="!applyToAll" class="inbox-list">
<p class="hint">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.INBOX_HINT',
'Selecione as caixas onde você quer receber o alerta:'
)
}}
</p>
<label v-for="inbox in inboxes" :key="inbox.id" class="inbox-row">
<input
type="checkbox"
:checked="isInboxSelected(inbox.id)"
class="toggle-input"
@change="handleInboxToggle(inbox.id)"
/>
<span>{{ inbox.name }}</span>
</label>
<p v-if="!inboxes.length" class="empty">
{{
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NO_INBOXES',
'Nenhuma caixa de entrada cadastrada.'
)
}}
</p>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.aggressive-alert-settings {
max-width: 480px;
}
.description {
color: var(--color-text-light, #6b7280);
font-size: 13px;
line-height: 1.5;
margin: 0;
}
.toggle-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.toggle-input {
cursor: pointer;
}
.toggle-label {
font-size: 14px;
font-weight: 500;
}
.scope-section {
margin-left: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.inbox-list {
margin-left: 24px;
display: flex;
flex-direction: column;
gap: 6px;
padding-top: 4px;
}
.inbox-row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
}
.hint {
font-size: 12px;
color: var(--color-text-light, #6b7280);
margin: 0;
}
.empty {
font-size: 12px;
color: var(--color-text-light, #9ca3af);
font-style: italic;
}
</style>

View File

@ -17,11 +17,9 @@ import HotKeyCard from './HotKeyCard.vue';
import ChangePassword from './ChangePassword.vue';
import NotificationPreferences from './NotificationPreferences.vue';
import AudioNotifications from './AudioNotifications.vue';
import AggressiveAlertSettings from './AggressiveAlertSettings.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,10 +39,8 @@ export default {
ChangePassword,
NotificationPreferences,
AudioNotifications,
AggressiveAlertSettings,
AccessToken,
MfaSettingsCard,
AggressiveAlertProfileSetting,
},
setup() {
const { isEditorHotKeyEnabled, updateUISettings } = useUISettings();
@ -246,12 +242,6 @@ 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="
@ -338,22 +328,6 @@ export default {
<AudioNotifications />
</FormSection>
</Policy>
<FormSection
:title="
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.TITLE',
'Alerta de conversa parada'
)
"
:description="
$t(
'PROFILE_SETTINGS.FORM.AGGRESSIVE_ALERT_SECTION.NOTE',
'Banner vermelho que aparece no topo do painel quando uma conversa fica sem resposta há 5+ minutos.'
)
"
>
<AggressiveAlertSettings />
</FormSection>
<Policy :permissions="notificationPermissions">
<FormSection :title="$t('PROFILE_SETTINGS.FORM.NOTIFICATIONS.TITLE')">
<NotificationPreferences />

View File

@ -20,7 +20,6 @@ export default {
from: 0,
to: 0,
groupBy: GROUP_BY_FILTER[1],
inboxId: null,
reportKeys: {
BOT_RESOLUTION_COUNT: 'bot_resolutions_count',
BOT_HANDOFF_COUNT: 'bot_handoffs_count',
@ -33,21 +32,14 @@ export default {
return {
from: this.from,
to: this.to,
inboxId: this.inboxId,
};
},
},
mounted() {
this.fetchInboxes();
},
methods: {
fetchAllData() {
this.fetchBotSummary();
this.fetchChartData();
},
fetchInboxes() {
this.$store.dispatch('inboxes/get');
},
fetchBotSummary() {
try {
this.$store.dispatch('fetchBotSummary', this.getRequestPayload());
@ -68,35 +60,24 @@ export default {
});
},
getRequestPayload() {
const { from, to, groupBy, businessHours, inboxId } = this;
const payload = {
const { from, to, groupBy, businessHours } = this;
return {
from,
to,
groupBy: groupBy?.period,
businessHours,
};
if (inboxId) {
payload.type = 'inbox';
payload.id = inboxId;
}
return payload;
},
onFilterChange({ from, to, groupBy, businessHours, inboxes }) {
onFilterChange({ from, to, groupBy, businessHours }) {
this.from = from;
this.to = to;
this.groupBy = groupBy;
this.businessHours = businessHours;
this.inboxId = inboxes?.id || null;
this.fetchAllData();
useTrack(REPORTS_EVENTS.FILTER_REPORT, {
filterValue: {
from,
to,
groupBy,
businessHours,
inboxId: this.inboxId,
},
filterValue: { from, to, groupBy, businessHours },
reportType: 'bots',
});
},
@ -108,10 +89,9 @@ export default {
<ReportHeader :header-title="$t('BOT_REPORTS.HEADER')" />
<div class="flex flex-col gap-4">
<ReportFilters
filter-type="inboxes"
:show-entity-filter="false"
show-group-by
:show-business-hours="false"
:navigate-on-entity-filter="false"
@filter-change="onFilterChange"
/>

View File

@ -1,296 +0,0 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import ReportsAPI from 'dashboard/api/reports';
import ReportFilters from './components/ReportFilters.vue';
import ReportMetricCard from './components/ReportMetricCard.vue';
import ReportHeader from './components/ReportHeader.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const filters = ref({ from: 0, to: 0, inboxId: null });
const isLoadingFunnel = ref(false);
const isLoadingBenchmark = ref(false);
const funnel = ref({
leads: { total: 0, new: 0, returning: 0 },
reservations: { created: 0, paid: 0 },
conversion_rates: {
lead_to_paid_reservation: 0,
lead_to_any_reservation: 0,
created_to_paid: 0,
},
});
const benchmark = ref([]);
const fetchFunnel = () => {
if (!filters.value.from || !filters.value.to) return;
isLoadingFunnel.value = true;
ReportsAPI.getConversionFunnel(filters.value)
.then(({ data }) => {
funnel.value = data;
})
.finally(() => {
isLoadingFunnel.value = false;
});
};
const fetchBenchmark = () => {
if (!filters.value.from || !filters.value.to) return;
isLoadingBenchmark.value = true;
ReportsAPI.getInboxBenchmarking({
from: filters.value.from,
to: filters.value.to,
})
.then(({ data }) => {
benchmark.value = data;
})
.finally(() => {
isLoadingBenchmark.value = false;
});
};
const onFilterChange = ({ from, to, inboxes }) => {
filters.value = {
from,
to,
inboxId: inboxes?.id || null,
};
fetchFunnel();
fetchBenchmark();
};
const benchmarkGrouped = computed(() => {
const groups = new Map();
benchmark.value.forEach(row => {
const brand = row.brand_name || '— Sem marca —';
if (!groups.has(brand)) groups.set(brand, []);
groups.get(brand).push(row);
});
return [...groups.entries()].map(([brand, rows]) => {
const totalLeads = rows.reduce((s, r) => s + r.leads_total, 0);
const totalPaid = rows.reduce((s, r) => s + r.reservations_paid, 0);
const brandRate = totalLeads === 0 ? 0 : (totalPaid / totalLeads) * 100;
return { brand, rows, brandRate: Math.round(brandRate * 10) / 10 };
});
});
const formatNumber = n => Number(n || 0).toLocaleString();
const formatPct = n => `${Number(n || 0).toFixed(1)}%`;
const variationFromBrand = (rowRate, brandRate) => {
if (brandRate === 0) return null;
return Math.round((rowRate - brandRate) * 10) / 10;
};
onMounted(() => {});
</script>
<template>
<ReportHeader :header-title="$t('DIRECTORY_DASHBOARD.HEADER')" />
<div
class="bg-n-amber-3 border border-n-amber-7 rounded-lg p-3 mb-4 text-sm text-n-slate-12"
>
<strong>{{ $t('DIRECTORY_DASHBOARD.BANNER.TITLE') }}</strong>
{{ $t('DIRECTORY_DASHBOARD.BANNER.BODY') }}
</div>
<div class="flex flex-col gap-4">
<ReportFilters
filter-type="inboxes"
:show-group-by="false"
:show-business-hours="false"
:navigate-on-entity-filter="false"
@filter-change="onFilterChange"
/>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.HEADLINE_NUMBERS') }}
</h3>
<Spinner v-if="isLoadingFunnel" />
<div v-else class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.LABEL')"
:value="formatNumber(funnel.leads.total)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_TOTAL.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.LABEL')"
:value="formatNumber(funnel.leads.new)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_NEW.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.LABEL')"
:value="formatNumber(funnel.leads.returning)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.LEADS_RETURNING.TOOLTIP')"
/>
<ReportMetricCard
:label="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.LABEL')"
:value="formatPct(funnel.conversion_rates.lead_to_paid_reservation)"
:info-text="$t('DIRECTORY_DASHBOARD.METRICS.CONVERSION_RATE.TOOLTIP')"
/>
</div>
</div>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.TITLE') }}
</h3>
<Spinner v-if="isLoadingFunnel" />
<div v-else class="space-y-3">
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_LEADS') }}
</div>
<div class="flex-1 bg-n-blue-9 text-white px-4 py-3 rounded-lg">
<span class="text-lg font-semibold">{{
formatNumber(funnel.leads.total)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(100) }}
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_RESERVATIONS') }}
</div>
<div
class="flex-1 bg-n-blue-7 text-white px-4 py-3 rounded-lg"
:style="{
maxWidth:
funnel.leads.total === 0
? '100%'
: `${Math.max(20, (funnel.reservations.created / Math.max(1, funnel.leads.total)) * 100)}%`,
}"
>
<span class="text-lg font-semibold">{{
formatNumber(funnel.reservations.created)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(funnel.conversion_rates.lead_to_any_reservation) }}
</div>
</div>
<div class="flex items-center gap-4">
<div class="w-32 text-sm text-n-slate-11">
{{ $t('DIRECTORY_DASHBOARD.FUNNEL.STAGE_PAID') }}
</div>
<div
class="flex-1 bg-n-teal-9 text-white px-4 py-3 rounded-lg"
:style="{
maxWidth:
funnel.leads.total === 0
? '100%'
: `${Math.max(20, (funnel.reservations.paid / Math.max(1, funnel.leads.total)) * 100)}%`,
}"
>
<span class="text-lg font-semibold">{{
formatNumber(funnel.reservations.paid)
}}</span>
</div>
<div class="w-24 text-sm text-n-slate-11 text-right">
{{ formatPct(funnel.conversion_rates.lead_to_paid_reservation) }}
</div>
</div>
</div>
</div>
<div
class="bg-n-solid-2 shadow outline-1 outline outline-n-container rounded-xl px-6 py-5"
>
<h3 class="text-base font-semibold m-0 mb-4">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.TITLE') }}
</h3>
<Spinner v-if="isLoadingBenchmark" />
<div v-else class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="text-left text-n-slate-11 border-b border-n-weak">
<th class="py-2 pr-4">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_INBOX') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_LEADS') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_CREATED') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_PAID') }}
</th>
<th class="py-2 px-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_RATE') }}
</th>
<th class="py-2 pl-2 text-right">
{{ $t('DIRECTORY_DASHBOARD.BENCHMARK.COL_VS_BRAND') }}
</th>
</tr>
</thead>
<template v-for="group in benchmarkGrouped" :key="group.brand">
<tbody>
<tr class="bg-n-slate-3">
<td colspan="6" class="py-2 px-2 font-semibold text-n-slate-12">
{{ group.brand }}
<span class="text-n-slate-11 font-normal text-xs ms-2">
({{ $t('DIRECTORY_DASHBOARD.BENCHMARK.BRAND_AVG') }}
{{ formatPct(group.brandRate) }})
</span>
</td>
</tr>
<tr
v-for="row in group.rows"
:key="row.inbox_id"
class="border-b border-n-weak"
>
<td class="py-2 pr-4 text-n-slate-12">{{ row.inbox_name }}</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.leads_total) }}
</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.reservations_created) }}
</td>
<td class="py-2 px-2 text-right">
{{ formatNumber(row.reservations_paid) }}
</td>
<td class="py-2 px-2 text-right font-medium">
{{ formatPct(row.conversion_rate) }}
</td>
<td class="py-2 pl-2 text-right">
<span
v-if="
variationFromBrand(row.conversion_rate, group.brandRate) >
0
"
class="text-n-teal-11"
>
+{{
variationFromBrand(row.conversion_rate, group.brandRate)
}}
</span>
<span
v-else-if="
variationFromBrand(row.conversion_rate, group.brandRate) <
0
"
class="text-n-ruby-11"
>
{{
variationFromBrand(row.conversion_rate, group.brandRate)
}}
</span>
<span v-else class="text-n-slate-11"></span>
</td>
</tr>
</tbody>
</template>
</table>
</div>
</div>
</div>
</template>

View File

@ -1,73 +1,26 @@
<script setup>
import { computed, ref } from 'vue';
import { useRoute } from 'vue-router';
import { useFunctionGetter } from 'dashboard/composables/store';
import WootReports from './components/WootReports.vue';
import InboxLeadsReport from './components/InboxLeadsReport.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const route = useRoute();
const inboxIdParam = computed(() => route.params.id);
const inbox = useFunctionGetter('inboxes/getInboxById', inboxIdParam);
const TABS = {
OVERVIEW: 'overview',
LEADS: 'leads',
};
const activeTab = ref(TABS.OVERVIEW);
const inbox = useFunctionGetter('inboxes/getInboxById', route.params.id);
</script>
<template>
<div v-if="inbox.id" class="flex flex-col w-full">
<div class="flex items-center gap-6 px-6 pt-4 border-b border-n-weak">
<button
type="button"
class="py-3 text-sm font-medium border-b-2 transition-colors"
:class="
activeTab === TABS.OVERVIEW
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
"
@click="activeTab = TABS.OVERVIEW"
>
{{ $t('INBOX_REPORTS.TABS.OVERVIEW') }}
</button>
<button
type="button"
class="py-3 text-sm font-medium border-b-2 transition-colors"
:class="
activeTab === TABS.LEADS
? 'border-n-brand text-n-brand'
: 'border-transparent text-n-slate-11 hover:text-n-slate-12'
"
@click="activeTab = TABS.LEADS"
>
{{ $t('INBOX_REPORTS.TABS.LEADS') }}
</button>
</div>
<div class="px-6 py-4">
<WootReports
v-if="activeTab === TABS.OVERVIEW"
:key="`overview-${inbox.id}`"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:selected-item="inbox"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
:report-title="$t('INBOX_REPORTS.HEADER')"
has-back-button
/>
<InboxLeadsReport
v-else-if="activeTab === TABS.LEADS"
:key="`leads-${inbox.id}`"
:inbox-id="inbox.id"
:inbox-name="inbox.name"
/>
</div>
</div>
<WootReports
v-if="inbox.id"
:key="inbox.id"
type="inbox"
getter-key="inboxes/getInboxes"
action-key="inboxes/get"
:selected-item="inbox"
:download-button-label="$t('INBOX_REPORTS.DOWNLOAD_INBOX_REPORTS')"
:report-title="$t('INBOX_REPORTS.HEADER')"
has-back-button
/>
<div v-else class="w-full py-20">
<Spinner class="mx-auto" />
</div>

View File

@ -12,11 +12,8 @@ const props = defineProps({
const conversationCount = ref('0');
const messageCount = ref('0');
const botResolutionRate = ref('0');
const humanTransferRate = ref('0');
const botResolutionsCount = ref('0');
const autoHandoffsCount = ref('0');
const manualTakeoversCount = ref('0');
const resolutionRate = ref('0');
const handoffRate = ref('0');
const formatToPercent = value => {
return value ? `${value}%` : '--';
@ -29,17 +26,8 @@ const fetchMetrics = () => {
ReportsAPI.getBotMetrics(props.filters).then(response => {
conversationCount.value = response.data.conversation_count.toLocaleString();
messageCount.value = response.data.message_count.toLocaleString();
botResolutionRate.value = response.data.resolution_rate.toString();
humanTransferRate.value = response.data.handoff_rate.toString();
botResolutionsCount.value = (
response.data.bot_resolutions_count || 0
).toLocaleString();
autoHandoffsCount.value = (
response.data.auto_handoffs_count || 0
).toLocaleString();
manualTakeoversCount.value = (
response.data.manual_takeovers_count || 0
).toLocaleString();
resolutionRate.value = response.data.resolution_rate.toString();
handoffRate.value = response.data.handoff_rate.toString();
});
};
@ -49,57 +37,32 @@ onMounted(fetchMetrics);
</script>
<template>
<div class="flex flex-col gap-4">
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
:value="conversationCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="messageCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
:value="formatToPercent(botResolutionRate)"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
:value="formatToPercent(humanTransferRate)"
class="flex-1"
/>
</div>
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.BOT_RESOLUTIONS.TOOLTIP')"
:value="botResolutionsCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.AUTO_HANDOFFS.TOOLTIP')"
:value="autoHandoffsCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.MANUAL_TAKEOVERS.TOOLTIP')"
:value="manualTakeoversCount"
class="flex-1"
/>
</div>
<div
class="flex flex-wrap mx-0 shadow outline-1 outline outline-n-container rounded-xl bg-n-solid-2 px-6 py-5"
>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_CONVERSATIONS.TOOLTIP')"
:value="conversationCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.TOTAL_RESPONSES.TOOLTIP')"
:value="messageCount"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.RESOLUTION_RATE.TOOLTIP')"
:value="formatToPercent(resolutionRate)"
class="flex-1"
/>
<ReportMetricCard
:label="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.LABEL')"
:info-text="$t('BOT_REPORTS.METRIC.HANDOFF_RATE.TOOLTIP')"
:value="formatToPercent(handoffRate)"
class="flex-1"
/>
</div>
</template>

View File

@ -1,172 +0,0 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useStore } from 'vuex';
import { useI18n } from 'vue-i18n';
import { format } from 'date-fns';
import ReportFilters from './ReportFilters.vue';
import ReportMetricCard from './ReportMetricCard.vue';
import BarChart from 'shared/components/charts/BarChart.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const props = defineProps({
inboxId: {
type: [Number, String],
required: true,
},
inboxName: {
type: String,
default: '',
},
});
const store = useStore();
const { t } = useI18n();
const filters = ref({
from: null,
to: null,
groupBy: { id: 1, period: 'day' },
});
const isFetching = computed(() => store.getters.getInboxLeadsSummaryFetching);
const rows = computed(() => store.getters.getInboxLeadsSummary || []);
const totals = computed(() => {
return rows.value.reduce(
(acc, row) => {
acc.new_leads += row.new_leads || 0;
acc.returning += row.returning || 0;
acc.others += row.others || 0;
return acc;
},
{ new_leads: 0, returning: 0, others: 0 }
);
});
const totalConversations = computed(
() => totals.value.new_leads + totals.value.returning + totals.value.others
);
const formatPeriodLabel = (iso, period) => {
const date = new Date(iso);
if (period === 'month') return format(date, 'MMM/yy');
if (period === 'week') return format(date, "'S'II/yy");
return format(date, 'dd/MM');
};
const chartCollection = computed(() => {
const period = filters.value.groupBy?.period || 'day';
return {
labels: rows.value.map(r => formatPeriodLabel(r.period, period)),
datasets: [
{
label: t('INBOX_REPORTS.LEADS.CHART.NEW_LEADS'),
backgroundColor: '#10B981',
data: rows.value.map(r => r.new_leads),
},
{
label: t('INBOX_REPORTS.LEADS.CHART.RETURNING'),
backgroundColor: '#3B82F6',
data: rows.value.map(r => r.returning),
},
{
label: t('INBOX_REPORTS.LEADS.CHART.OTHERS'),
backgroundColor: '#9CA3AF',
data: rows.value.map(r => r.others),
},
],
};
});
const chartOptions = {
plugins: {
legend: { display: true, position: 'bottom' },
},
scales: {
x: { stacked: true, grid: { drawOnChartArea: false } },
y: { stacked: true, beginAtZero: true, ticks: { stepSize: 1 } },
},
};
const fetchData = () => {
if (!filters.value.from || !filters.value.to || !props.inboxId) return;
store.dispatch('fetchInboxLeadsSummary', {
inboxId: props.inboxId,
from: filters.value.from,
to: filters.value.to,
groupBy: filters.value.groupBy?.period || 'day',
});
};
const onFilterChange = payload => {
filters.value = {
from: payload.from,
to: payload.to,
groupBy: payload.groupBy || filters.value.groupBy,
};
fetchData();
};
watch(
() => props.inboxId,
() => fetchData()
);
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex items-center justify-between">
<div>
<h2 class="text-base font-semibold text-n-slate-12 m-0">
{{ $t('INBOX_REPORTS.LEADS.TITLE') }}
</h2>
<p v-if="inboxName" class="text-sm text-n-slate-11 mt-1 mb-0">
<span>{{ $t('INBOX_REPORTS.LEADS.INBOX_LABEL') }}</span>
<span class="font-medium text-n-slate-12 ms-1">{{ inboxName }}</span>
</p>
</div>
</div>
<ReportFilters
filter-type="inboxes"
:selected-item="{ id: Number(inboxId) }"
:show-business-hours="false"
:show-entity-filter="false"
@filter-change="onFilterChange"
/>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.LABEL')"
:value="String(totals.new_leads)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.NEW_LEADS.INFO')"
/>
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.LABEL')"
:value="String(totals.returning)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.RETURNING.INFO')"
/>
<ReportMetricCard
:label="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.LABEL')"
:value="String(totals.others)"
:info-text="$t('INBOX_REPORTS.LEADS.METRICS.OTHERS.INFO')"
/>
</div>
<div
class="bg-n-solid-1 border border-n-weak rounded-lg p-4 min-h-[320px] flex items-center justify-center"
>
<Spinner v-if="isFetching" />
<div v-else-if="rows.length === 0" class="text-sm text-n-slate-11">
{{ $t('INBOX_REPORTS.LEADS.EMPTY') }}
</div>
<div v-else class="w-full h-[320px]">
<BarChart :collection="chartCollection" :chart-options="chartOptions" />
</div>
</div>
<div class="text-xs text-n-slate-11">
{{ $t('INBOX_REPORTS.LEADS.TOTAL', { count: totalConversations }) }}
</div>
</div>
</template>

View File

@ -12,9 +12,7 @@ import ToggleSwitch from 'dashboard/components-next/switch/Switch.vue';
import { GROUP_BY_FILTER } from '../constants';
import { DATE_RANGE_TYPES } from 'dashboard/components/ui/DatePicker/helpers/DatePickerHelper';
import {
generateFilterURLParams,
generateReportURLParams,
parseFilterURLParams,
parseReportURLParams,
} from '../helpers/reportFilterHelper';
@ -42,10 +40,6 @@ const props = defineProps({
type: Boolean,
default: true,
},
navigateOnEntityFilter: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['filterChange']);
@ -182,11 +176,8 @@ const updateURLParams = () => {
groupBy: isGroupByPossible.value ? groupBy.value.id : null,
range: selectedDateRange.value,
});
const filterParams = props.showEntityFilter
? generateFilterURLParams(appliedFilters.value)
: {};
router.replace({ query: { ...params, ...filterParams } });
router.replace({ query: { ...params } });
};
const emitChange = () => {
@ -244,10 +235,6 @@ const addFilter = item => {
agents: 'agent_reports_show',
};
if (!props.navigateOnEntityFilter) {
return;
}
const routeName = routeNameMap[props.filterType];
if (routeName) {
router.push({
@ -321,12 +308,6 @@ const initializeFromURL = () => {
if (props.showEntityFilter && route.params.id) {
const filterKey = getFilterKey();
appliedFilters.value[filterKey] = Number(route.params.id);
} else if (props.showEntityFilter) {
const filterKey = getFilterKey();
const filterParams = parseFilterURLParams(route.query);
if (filterParams[filterKey]) {
appliedFilters.value[filterKey] = filterParams[filterKey];
}
}
};

View File

@ -23,7 +23,6 @@ import CsatResponses from './CsatResponses.vue';
import BotReports from './BotReports.vue';
import LiveReports from './LiveReports.vue';
import SLAReports from './SLAReports.vue';
import DirectoryDashboard from './DirectoryDashboard.vue';
const meta = {
featureFlag: FEATURE_FLAGS.REPORTS,
@ -169,12 +168,6 @@ export default {
meta,
component: BotReports,
},
{
path: 'directory_dashboard',
name: 'directory_dashboard_reports',
meta,
component: DirectoryDashboard,
},
],
},
],

View File

@ -0,0 +1,84 @@
import notificationTemplatesAPI from '../../api/captain/notificationTemplates';
const state = {
records: [],
uiFlags: {
isFetching: false,
isSaving: false,
},
};
const getters = {
getRecords: $state => $state.records,
getUIFlags: $state => $state.uiFlags,
};
const actions = {
async fetch({ commit }, inboxId) {
commit('SET_UI_FLAG', { isFetching: true });
try {
const { data } = await notificationTemplatesAPI.getAll(inboxId);
commit('SET_RECORDS', data);
} finally {
commit('SET_UI_FLAG', { isFetching: false });
}
},
async create({ commit }, { inboxId, payload }) {
commit('SET_UI_FLAG', { isSaving: true });
try {
const { data } = await notificationTemplatesAPI.create(inboxId, payload);
commit('ADD_RECORD', data);
return data;
} finally {
commit('SET_UI_FLAG', { isSaving: false });
}
},
async update({ commit }, { inboxId, id, payload }) {
commit('SET_UI_FLAG', { isSaving: true });
try {
const { data } = await notificationTemplatesAPI.update(
inboxId,
id,
payload
);
commit('UPDATE_RECORD', data);
return data;
} finally {
commit('SET_UI_FLAG', { isSaving: false });
}
},
async delete({ commit }, { inboxId, id }) {
await notificationTemplatesAPI.delete(inboxId, id);
commit('DELETE_RECORD', id);
},
};
const mutations = {
SET_RECORDS($state, records) {
$state.records = records;
},
ADD_RECORD($state, record) {
$state.records.push(record);
},
UPDATE_RECORD($state, record) {
const idx = $state.records.findIndex(r => r.id === record.id);
if (idx !== -1) $state.records.splice(idx, 1, record);
},
DELETE_RECORD($state, id) {
$state.records = $state.records.filter(r => r.id !== id);
},
SET_UI_FLAG($state, flags) {
$state.uiFlags = { ...$state.uiFlags, ...flags };
},
};
export default {
namespaced: true,
state,
getters,
actions,
mutations,
};

View File

@ -5,16 +5,6 @@ import { throwErrorMessage } from 'dashboard/store/utils/api';
export default createStore({
name: 'CaptainReservation',
API: CaptainReservationsAPI,
mutations: {
SET_CAPTAINRESERVATION_META(state, meta) {
state.meta = {
...state.meta,
totalCount: Number(meta.total_count),
page: Number(meta.page),
statusCounts: meta.status_counts || meta.statusCounts || {},
};
},
},
actions: mutations => ({
fetchRevenue: async function fetchRevenue(_, params = {}) {
try {

View File

@ -66,6 +66,7 @@ import captainLifecycleDeliveries from './captain/lifecycleDeliveries';
import captainUnits from './modules/captainUnits';
import captainGalleryItems from './modules/captainGalleryItems';
import captainReports from './modules/captainReports';
import captainNotificationTemplates from './captain/notificationTemplates';
const plugins = [];
@ -137,6 +138,7 @@ export default createStore({
captainUnits,
captainGalleryItems,
captainReports,
captainNotificationTemplates,
},
plugins,
});

View File

@ -67,10 +67,6 @@ const state = {
agentConversationMetric: [],
teamConversationMetric: [],
},
inboxLeadsSummary: {
isFetching: false,
data: [],
},
};
const getters = {
@ -107,12 +103,6 @@ const getters = {
getOverviewUIFlags($state) {
return $state.overview.uiFlags;
},
getInboxLeadsSummary(_state) {
return _state.inboxLeadsSummary.data;
},
getInboxLeadsSummaryFetching(_state) {
return _state.inboxLeadsSummary.isFetching;
},
};
export const actions = {
@ -180,8 +170,6 @@ export const actions = {
to: reportObj.to,
groupBy: reportObj.groupBy,
businessHours: reportObj.businessHours,
type: reportObj.type,
id: reportObj.id,
})
.then(botSummary => {
commit(types.default.SET_BOT_SUMMARY, botSummary.data);
@ -298,20 +286,6 @@ export const actions = {
console.error(error);
});
},
fetchInboxLeadsSummary({ commit }, reportObj) {
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, true);
return Report.getInboxLeadsSummary(reportObj)
.then(response => {
commit(types.default.SET_INBOX_LEADS_SUMMARY, response.data || []);
})
.catch(error => {
console.error(error);
commit(types.default.SET_INBOX_LEADS_SUMMARY, []);
})
.finally(() => {
commit(types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING, false);
});
},
downloadAccountConversationHeatmap(_, reportObj) {
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
.then(response => {
@ -383,12 +357,6 @@ const mutations = {
[types.default.TOGGLE_TEAM_CONVERSATION_METRIC_LOADING](_state, flag) {
_state.overview.uiFlags.isFetchingTeamConversationMetric = flag;
},
[types.default.SET_INBOX_LEADS_SUMMARY](_state, data) {
_state.inboxLeadsSummary.data = data;
},
[types.default.TOGGLE_INBOX_LEADS_SUMMARY_LOADING](_state, flag) {
_state.inboxLeadsSummary.isFetching = flag;
},
};
export default {

View File

@ -209,8 +209,6 @@ export default {
SET_AGENT_CONVERSATION_METRIC: 'SET_AGENT_CONVERSATION_METRIC',
TOGGLE_AGENT_CONVERSATION_METRIC_LOADING:
'TOGGLE_AGENT_CONVERSATION_METRIC_LOADING',
SET_INBOX_LEADS_SUMMARY: 'SET_INBOX_LEADS_SUMMARY',
TOGGLE_INBOX_LEADS_SUMMARY_LOADING: 'TOGGLE_INBOX_LEADS_SUMMARY_LOADING',
// Conversation Metadata
SET_CONVERSATION_METADATA: 'SET_CONVERSATION_METADATA',

View File

@ -13,6 +13,4 @@ export const BUS_EVENTS = {
NEW_CONVERSATION_MODAL: 'newConversationModal',
INSERT_INTO_RICH_EDITOR: 'insertIntoRichEditor',
INSERT_INTO_NORMAL_EDITOR: 'insertIntoNormalEditor',
AGGRESSIVE_ALERT_TRIGGER: 'AGGRESSIVE_ALERT_TRIGGER',
AGGRESSIVE_ALERT_DISMISS: 'AGGRESSIVE_ALERT_DISMISS',
};

View File

@ -2,30 +2,6 @@ class DeleteObjectJob < ApplicationJob
queue_as :low
BATCH_SIZE = 5_000
INBOX_DEPENDENT_TABLES = %i[
captain_feedback_logs
captain_lifecycle_deliveries
captain_reminders
captain_reservations
captain_gallery_items
captain_inbox_automations
captain_inbox_reminder_settings
captain_inboxes
captain_notification_templates
captain_pricing_inboxes
captain_tool_configs
captain_unit_inboxes
jasmine_inbox_collections
jasmine_inbox_settings
jasmine_tool_configs
].freeze
INBOX_NULLIFY_TARGETS = [
[:captain_conversation_insights, :inbox_id],
[:captain_pricings, :inbox_id],
[:jasmine_collections, :owner_inbox_id],
[:captain_units, :inbox_id],
[:captain_units, :concierge_inbox_id]
].freeze
def perform(object, user = nil, ip = nil)
# Pre-purge heavy associations for large objects to avoid
@ -47,8 +23,6 @@ class DeleteObjectJob < ApplicationJob
end
def purge_heavy_associations(object)
purge_inbox_blocking_associations(object) if object.is_a?(Inbox)
klass = heavy_associations.keys.find { |k| object.is_a?(k) }
return unless klass
@ -64,71 +38,6 @@ class DeleteObjectJob < ApplicationJob
batch.each(&:destroy!)
end
end
def purge_inbox_blocking_associations(inbox)
inbox_id = inbox.id
reservation_ids = select_ids(:captain_reservations, :inbox_id, inbox_id)
purge_reservation_children(reservation_ids)
# fazer.ai/Captain tables hold hard FKs to inboxes. If these survive, the
# async delete job fails and the UI shows the inbox again on refresh.
INBOX_DEPENDENT_TABLES.each { |table| delete_by_column(table, :inbox_id, inbox_id) }
nullify_inbox_references(inbox_id)
end
def purge_reservation_children(reservation_ids)
delete_where_in(:captain_lifecycle_deliveries, :captain_reservation_id, reservation_ids)
delete_where_in(:captain_pix_charges, :reservation_id, reservation_ids)
end
def nullify_inbox_references(inbox_id)
INBOX_NULLIFY_TARGETS.each do |table, column|
nullify_by_column(table, column, inbox_id)
end
end
def select_ids(table, column, value)
return [] unless column_available?(table, column)
sql = "SELECT id FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}"
db.select_values(sql).map(&:to_i)
end
def delete_where_in(table, column, values)
return if values.blank?
return unless column_available?(table, column)
db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} IN (#{values.map { |v| Integer(v) }.join(',')})")
end
def delete_by_column(table, column, value)
return unless column_available?(table, column)
db.execute("DELETE FROM #{quote_table(table)} WHERE #{quote_column(column)} = #{Integer(value)}")
end
def nullify_by_column(table, column, value)
return unless column_available?(table, column)
db.execute("UPDATE #{quote_table(table)} SET #{quote_column(column)} = NULL WHERE #{quote_column(column)} = #{Integer(value)}")
end
def column_available?(table, column)
db.data_source_exists?(table.to_s) && db.column_exists?(table.to_s, column.to_s)
end
def quote_table(table)
db.quote_table_name(table)
end
def quote_column(column)
db.quote_column_name(column)
end
def db
ActiveRecord::Base.connection
end
end
DeleteObjectJob.prepend_mod_with('DeleteObjectJob')

View File

@ -133,22 +133,11 @@ class ReportingEventListener < BaseListener
def create_bot_resolved_event(conversation, reporting_event)
return unless conversation.inbox.active_bot?
# We don't want to create a bot_resolved event if there is human interaction on the conversation.
# Human interaction = outgoing message either from a User (replied via Chatwoot UI) OR from a
# nil sender (replied directly via the connected WhatsApp app — webhook echo with IsFromMe=true,
# see app/services/whatsapp/incoming_message_wuzapi_service.rb#build_message).
# The bot itself uses sender_type 'Captain::Assistant' (or 'AgentBot'), so it stays excluded from this filter.
return if human_outgoing_messages?(conversation)
# We don't want to create a bot_resolved event if there is user interaction on the conversation
return if conversation.messages.exists?(message_type: :outgoing, sender_type: 'User')
bot_resolved_event = reporting_event.dup
bot_resolved_event.name = 'conversation_bot_resolved'
bot_resolved_event.save!
end
def human_outgoing_messages?(conversation)
conversation.messages
.where(message_type: :outgoing)
.where('sender_type = ? OR sender_type IS NULL', 'User')
.any?
end
end

View File

@ -90,7 +90,6 @@ 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

View File

@ -4,8 +4,6 @@
#
# id :bigint not null, primary key
# concierge_config :jsonb not null
# currency :string default("BRL"), not null
# extra_person_fee :decimal(10, 2) default(0.0), not null
# inter_account_number :string
# inter_cert_content :text
# inter_cert_path :string
@ -34,9 +32,6 @@
# inbox_id :bigint
# inter_client_id :string
# plug_play_id :string
# supabase_marca_id :uuid
# supabase_tenant_id :bigint default(1)
# supabase_unit_id :uuid
#
# Indexes
#
@ -44,7 +39,6 @@
# index_captain_units_on_captain_brand_id (captain_brand_id)
# index_captain_units_on_concierge_inbox_id (concierge_inbox_id)
# index_captain_units_on_inbox_id (inbox_id)
# index_captain_units_on_supabase_unit_id (supabase_unit_id) UNIQUE WHERE (supabase_unit_id IS NOT NULL)
#
# Foreign Keys
#

View File

@ -77,7 +77,6 @@ class Inbox < ApplicationRecord
has_many :conversations, dependent: :destroy_async
has_many :messages, dependent: :destroy_async
has_many :scheduled_messages, dependent: :destroy_async
has_many :reporting_events, dependent: :nullify
has_one :inbox_assignment_policy, dependent: :destroy
has_one :assignment_policy, through: :inbox_assignment_policy

View File

@ -14,7 +14,7 @@ class Conversations::MessageWindowService
private
def messaging_window
def messaging_window # rubocop:disable Metrics/CyclomaticComplexity
case @conversation.inbox.channel_type
when 'Channel::Api'
api_messaging_window
@ -25,10 +25,7 @@ class Conversations::MessageWindowService
when 'Channel::Tiktok'
tiktok_messaging_window
when 'Channel::Whatsapp'
# Providers via WhatsApp Web (baileys, zapi, wuzapi, evolution) não
# estão sujeitos à janela de 24h da Meta Cloud API — Web permite
# mensagem livre a qualquer momento.
return if %w[baileys zapi wuzapi evolution].include?(@conversation.inbox.channel.provider)
return if %w[baileys zapi].include?(@conversation.inbox.channel.provider)
MESSAGING_WINDOW_24_HOURS
when 'Channel::TwilioSms'

View File

@ -158,9 +158,9 @@ class Whatsapp::IncomingMessageWuzapiService < Whatsapp::IncomingMessageBaseServ
# Se a mensagem vier do celular (outgoing) e a assinatura estiver ativa,
# e o conteúdo não parecer já ter uma assinatura (evita duplicar em ecos)
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*')
if is_outgoing && inbox_obj.message_signature_enabled? && content.present? && !content.start_with?('*[') && !content.start_with?('*')
signature_name = inbox_obj.shift_signature_name
content = "*#{signature_name}*\n#{content}" if signature_name.present?
content = "*[ #{signature_name} ]*\n#{content}" if signature_name.present?
end
msg_params = {

View File

@ -26,14 +26,11 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
caption = content_with_signature || message.content
base64_data = attachment.file.blob.open { |tmp| Base64.strict_encode64(tmp.read) }
data_uri = "data:#{mime_type};base64,#{base64_data}"
if mime_type.start_with?('image/')
data_uri = "data:#{mime_type};base64,#{base64_data}"
client.send_image(user_token, phone_number, data_uri, caption)
else
# Wuzapi `/chat/send/document` exige prefixo `application/octet-stream`
# no data URI; o tipo real é inferido pelo FileName.
data_uri = "data:application/octet-stream;base64,#{base64_data}"
client.send_file(user_token, phone_number, data_uri, attachment.file.filename.to_s)
end
end
@ -178,7 +175,7 @@ class Whatsapp::Providers::WuzapiService < Whatsapp::Providers::BaseService
return content unless message.inbox.message_signature_enabled?
name = sender_name_for(message)
name.present? ? "*#{name}*\n#{content}" : content
name.present? ? "*[ #{name} ]*\n#{content}" : content
end
def reply_params(message)

View File

@ -56,22 +56,13 @@ class Wuzapi::Client # rubocop:disable Metrics/ClassLength
end
def send_file(user_token, phone_number, base64_data, filename)
# Wuzapi (asternic) `/chat/send/document` espera o campo `Document`
# (data URI base64). `Body`/`Filename` ficam só pra fallback de versões
# mais antigas que aceitavam isso.
payload = {
'Phone' => phone_number,
'Document' => base64_data,
'FileName' => filename,
'Body' => base64_data,
'Filename' => filename
}
payload = { 'Phone' => phone_number, 'Body' => base64_data, 'Filename' => filename }
request(
:post,
'/chat/send/document',
'/chat/send/file',
payload,
user_auth_headers(user_token),
fallback_paths: ['/send/document', '/chat/send/file', '/send/file'],
fallback_paths: ['/send/file'],
allow_base_fallback: true
)
end
@ -233,9 +224,7 @@ class Wuzapi::Client # rubocop:disable Metrics/ClassLength
begin
http.request(request_obj)
rescue SocketError, Errno::ECONNREFUSED, Errno::ECONNRESET, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
# ECONNRESET surge quando proxy intermediário corta antes do servidor
# responder — tratar como ConnectionError pra ativar fallback de path/base.
rescue SocketError, Errno::ECONNREFUSED, Errno::EHOSTUNREACH, Errno::ENETUNREACH, Net::OpenTimeout, Net::ReadTimeout => e
raise ConnectionError, "Could not connect to Wuzapi: #{e.message}"
end
end

View File

@ -11,5 +11,4 @@ json.custom_attributes resource.custom_attributes if resource.custom_attributes.
json.name resource.name
json.role resource.role
json.thumbnail resource.avatar_url
json.ui_settings resource.ui_settings
json.custom_role_id resource.current_account_user&.custom_role_id if ChatwootApp.enterprise?

View File

@ -1,401 +0,0 @@
#!/usr/bin/env bash
# hermes-provision — provisiona um novo agente Hermes ponta-a-ponta.
#
# Uso:
# hermes-provision [--dry-run] [--rollback <slug>] < spec.json
#
# Spec JSON esperado (stdin):
# {
# "slug": "lara",
# "name": "Lara",
# "account_id": 1,
# "marca": "Hotel 1001 Noites Prime",
# "unit_name": "PrimeVL",
# "city": "Brasília/DF",
# "captain_unit_id": null, // opcional — se null, cria nova Unit
# "parent_assistant_id": null, // opcional — se setado, MCP usa data do parent
# "soul_md": "<conteúdo SOUL.md inteiro>",
# "skill_name": "primevl-reservas",
# "skill_md": "<conteúdo SKILL.md>",
# "categories": [
# {
# "key": "standard",
# "aliases": ["standard", "comum"],
# "extra_person_starts_at": 3,
# "amounts": [
# {"period": "3h", "day_bucket": null, "amount": 50.0},
# {"period": "pernoite_promo", "day_bucket": "mon_wed", "amount": 100.0}
# ]
# }
# ],
# "extra_person_fee": 0.0,
# "humanization": {
# "mode": "typing_simulation",
# "chars_per_second": 25,
# "min_seconds": 1.5,
# "max_seconds": 6.0
# }
# }
#
# Saída: JSON com {ok, assistant_id, port, secret, errors}
#
# Quem chama: Construtor Hermes via terminal skill nativa.
# Pré-requisitos na VPS: jq, openssl, docker, systemctl, git, hermes binary.
set -uo pipefail
PROFILES_DIR="/root/.hermes/profiles"
TEMPLATE_PROFILE="$PROFILES_DIR/valentina"
PORT_RANGE_START=8650
PORT_RANGE_END=8699
SYSTEMD_DIR="/etc/systemd/system"
GIT_BACKUP_REPO="/root/hermes_profiles_backup"
DOCKER_APP_FILTER="iachat_iachat_app"
DRY_RUN=0
ROLLBACK_SLUG=""
while [[ $# -gt 0 ]]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--rollback) ROLLBACK_SLUG="$2"; shift 2 ;;
*) echo "{\"ok\":false,\"error\":\"unknown flag: $1\"}" >&2; exit 1 ;;
esac
done
log() { echo "[$(date -u +%H:%M:%S)] $*" >&2; }
fail() { echo "{\"ok\":false,\"error\":\"$1\"}"; exit 1; }
require_cmd() { command -v "$1" >/dev/null || fail "missing required command: $1"; }
require_cmd jq
require_cmd openssl
require_cmd docker
require_cmd systemctl
require_cmd hermes
# === ROLLBACK ===
if [[ -n "$ROLLBACK_SLUG" ]]; then
log "rolling back $ROLLBACK_SLUG"
systemctl stop "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
systemctl disable "hermes@$ROLLBACK_SLUG.service" 2>/dev/null || true
rm -rf "${PROFILES_DIR:?}/$ROLLBACK_SLUG"
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
docker exec "$CID" bundle exec rails runner "
asst = Captain::Assistant.find_by(hermes_profile_name: '$ROLLBACK_SLUG')
if asst
asst.captain_inboxes.destroy_all
asst.destroy!
puts 'destroyed assistant ' + asst.id.to_s
else
puts 'no assistant for slug $ROLLBACK_SLUG'
end
" 2>&1 | grep -v RubyLLM | tail -3
echo "{\"ok\":true,\"action\":\"rollback\",\"slug\":\"$ROLLBACK_SLUG\"}"
exit 0
fi
# === READ + VALIDATE SPEC ===
SPEC=$(cat)
[[ -z "$SPEC" ]] && fail "empty spec on stdin"
echo "$SPEC" | jq empty 2>/dev/null || fail "invalid JSON"
SLUG=$(echo "$SPEC" | jq -r '.slug // empty')
NAME=$(echo "$SPEC" | jq -r '.name // empty')
ACCOUNT_ID=$(echo "$SPEC" | jq -r '.account_id // empty')
MARCA=$(echo "$SPEC" | jq -r '.marca // empty')
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name // empty')
CAPTAIN_UNIT_ID=$(echo "$SPEC" | jq -r '.captain_unit_id // empty')
PARENT_ASSISTANT_ID=$(echo "$SPEC" | jq -r '.parent_assistant_id // empty')
SOUL_MD=$(echo "$SPEC" | jq -r '.soul_md // empty')
CITY=$(echo "$SPEC" | jq -r '.city // ""')
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name // empty')
SKILL_MD=$(echo "$SPEC" | jq -r '.skill_md // empty')
EXTRA_PERSON_FEE=$(echo "$SPEC" | jq -r '.extra_person_fee // 0')
# Slug validation
[[ ! "$SLUG" =~ ^[a-z][a-z0-9_]{1,29}$ ]] && fail "invalid slug '$SLUG' (regex: ^[a-z][a-z0-9_]{1,29}\$)"
[[ -z "$NAME" ]] && fail "name required"
[[ -z "$ACCOUNT_ID" ]] && fail "account_id required"
[[ -z "$MARCA" ]] && fail "marca required"
[[ -z "$UNIT_NAME" ]] && fail "unit_name required"
[[ -z "$SOUL_MD" ]] && fail "soul_md content required"
[[ -z "$SKILL_NAME" ]] && fail "skill_name required"
[[ ! "$SKILL_NAME" =~ ^[a-z][a-z0-9_-]{1,40}$ ]] && fail "invalid skill_name '$SKILL_NAME'"
[[ -z "$SKILL_MD" ]] && fail "skill_md content required"
# Categories validation: structure + amount sanity
CATEGORIES_COUNT=$(echo "$SPEC" | jq '.categories | length')
[[ "$CATEGORIES_COUNT" -lt 1 ]] && fail "at least 1 category required"
INVALID_AMOUNTS=$(echo "$SPEC" | jq '
[.categories[] |
.amounts[] |
select(.amount <= 0 or .amount > 5000 or
(.period | IN("2h","3h","4h","5h","pernoite_promo","pernoite_integral","diaria") | not) or
(.day_bucket != null and (.day_bucket | IN("mon_wed","thu_sun") | not)))
] | length
')
[[ "$INVALID_AMOUNTS" -gt 0 ]] && fail "$INVALID_AMOUNTS amounts inválidos (preço fora 0..5000, período/bucket inválido)"
# Profile already exists?
if [[ -d "$PROFILES_DIR/$SLUG" ]]; then
log "profile $SLUG já existe, será re-validado mas não recriado (idempotente)"
fi
# === ALLOCATE PORT ===
allocate_port() {
for ((p=PORT_RANGE_START; p<=PORT_RANGE_END; p++)); do
if ! ss -tnlH "( sport = :$p )" | grep -q .; then
echo "$p"; return 0
fi
done
return 1
}
# Reuse port if profile exists, else allocate fresh
if [[ -f "$PROFILES_DIR/$SLUG/config.yaml" ]]; then
PORT=$(awk '/^ port:/ {print $2}' "$PROFILES_DIR/$SLUG/config.yaml" | head -1)
log "reusing existing port $PORT for $SLUG"
else
PORT=$(allocate_port) || fail "no free port in range $PORT_RANGE_START..$PORT_RANGE_END"
log "allocated port $PORT for $SLUG"
fi
# === GENERATE OR REUSE HMAC SECRET ===
if [[ -f "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" ]]; then
SECRET=$(jq -r ".\"captain-inbox-${SLUG}\".secret // empty" "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" 2>/dev/null)
[[ -z "$SECRET" ]] && SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
else
SECRET=$(openssl rand -base64 32 | tr -d '/+=' | cut -c1-43)
fi
# === DRY RUN STOPS HERE ===
if [[ "$DRY_RUN" == "1" ]]; then
echo "$SPEC" | jq --arg port "$PORT" --arg secret "${SECRET:0:8}..." \
'{ok: true, dry_run: true, slug: .slug, name: .name, port: $port, secret_preview: $secret, categories: (.categories | length)}'
exit 0
fi
# === DB OPERATIONS via docker exec ===
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
[[ -z "$CID" ]] && fail "iachat_iachat_app container not running"
# Persist spec to a tmp file inside container so the runner reads it back
TMP_SPEC="/tmp/hermes_spec_${SLUG}_$$.json"
echo "$SPEC" > "/tmp/hermes_spec_${SLUG}_$$.json"
docker cp "$TMP_SPEC" "$CID:$TMP_SPEC"
DB_RESULT=$(docker exec "$CID" bundle exec rails runner "
spec = JSON.parse(File.read('$TMP_SPEC'))
account_id = spec['account_id']
brand = Captain::Brand.find_by(account_id: account_id, name: spec['marca'])
raise \"brand not found: #{spec['marca']}\" if brand.nil?
unit = if spec['captain_unit_id']
Captain::Unit.find(spec['captain_unit_id'])
else
Captain::Unit.find_or_create_by!(account_id: account_id, captain_brand_id: brand.id, name: spec['unit_name']) do |u|
u.status = 'active'
u.extra_person_fee = (spec['extra_person_fee'] || 0).to_f
u.currency = 'BRL'
end
end
spec['categories'].each do |cat|
pricing_cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: cat['key'])
pricing_cat.aliases = cat['aliases'] || []
pricing_cat.extra_person_starts_at = cat['extra_person_starts_at'] || 3
pricing_cat.save!
cat['amounts'].each do |a|
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: pricing_cat.id, period: a['period'], day_bucket: a['day_bucket']
)
row.amount = a['amount']
row.save!
end
end
# CHAVE = hermes_profile_name (slug). Nome é cosmético e PODE colidir com
# captain_interno do mesmo nome — nesse caso auto-renomeamos com sufixo
# ' · Hermes' pra evitar sobrescrever.
asst = Captain::Assistant.find_or_initialize_by(account_id: account_id, hermes_profile_name: spec['slug'])
desired_name = spec['name'].to_s.strip
if asst.new_record?
collision = Captain::Assistant.where(account_id: account_id, name: desired_name).where.not(hermes_profile_name: spec['slug']).exists?
desired_name = desired_name + ' · Hermes' if collision && !desired_name.include?('Hermes')
end
asst.name = desired_name
asst.description ||= 'Atendente Hermes ' + desired_name
asst.engine = 'hermes'
asst.hermes_profile_name = spec['slug']
asst.hermes_webhook_base_url = 'http://172.17.0.1:' + $PORT.to_s
asst.hermes_subscription_secret = '$SECRET'
asst.hermes_port = $PORT
asst.parent_assistant_id = spec['parent_assistant_id']
asst.captain_unit_id = unit.id
if spec['humanization']
asst.config['response_delay'] = spec['humanization']
end
asst.save!
puts 'OK ' + asst.id.to_s + ' ' + unit.id.to_s
" 2>&1 | grep -v 'RubyLLM\|ip_lookup\|WARN' | tail -3)
ASSISTANT_ID=$(echo "$DB_RESULT" | grep '^OK' | awk '{print $2}')
[[ -z "$ASSISTANT_ID" ]] && fail "DB step failed: $DB_RESULT"
log "DB step OK: assistant_id=$ASSISTANT_ID"
# === FILESYSTEM: profile directory ===
mkdir -p "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/references"
# Copy template files (config base, plugins, auth, generic skills)
if [[ -f "$TEMPLATE_PROFILE/config.yaml" ]]; then
cp "$TEMPLATE_PROFILE/config.yaml" "$PROFILES_DIR/$SLUG/config.yaml"
cp -r "$TEMPLATE_PROFILE/plugins" "$PROFILES_DIR/$SLUG/" 2>/dev/null || true
cp "$TEMPLATE_PROFILE/.env" "$PROFILES_DIR/$SLUG/.env" 2>/dev/null || true
cp "$TEMPLATE_PROFILE/auth.json" "$PROFILES_DIR/$SLUG/auth.json" 2>/dev/null || true
for s in "$TEMPLATE_PROFILE/skills"/*/; do
name=$(basename "$s")
[[ "$name" == "dolce-amore-reservas" ]] && continue
[[ "$name" == "$SKILL_NAME" ]] && continue
cp -r "$s" "$PROFILES_DIR/$SLUG/skills/" 2>/dev/null || true
done
fi
# Patch config.yaml: port + X-Captain-Assistant-Id + DESLIGA memória
# (Hermes-level memory_enabled e user_profile_enabled vazam contexto entre
# agentes que compartilham OAuth Codex; manter desligado pra evitar
# contaminação cross-unit).
#
# X-Captain-Assistant-Id usa o id PRÓPRIO do Hermes assistant (não do
# parent). Caso contrário tools como faq_lookup buscam dados do parent
# (Captain interno, com FAQs antigos) — vazou senha errada do Wi-Fi em
# 2026-05-02 porque parent.id=1 tinha "presencial" enquanto own.id=10
# tinha a senha real "Prime2025".
sed -i "s/port: 8645/port: $PORT/" "$PROFILES_DIR/$SLUG/config.yaml"
sed -i "s/X-Captain-Assistant-Id: '6'/X-Captain-Assistant-Id: '$ASSISTANT_ID'/" "$PROFILES_DIR/$SLUG/config.yaml"
# memory_enabled / user_profile_enabled ficam LIGADOS (default da Valentina
# template). Antes desligávamos achando que evitaria contaminação cross-unit
# — mas a contaminação real vinha do X-Captain-Assistant-Id apontando pro
# parent (já corrigido). Memória off mata UX (cliente repete nome/CPF a
# cada turn), e cada Hermes tem session isolada por chat_id, então memória
# de uma conv não vaza pra outra naturalmente.
# SOUL.md: clona a da Valentina (template canônico) e substitui identidade.
# Tudo que NÃO for identidade/marca/categoria — tom, formatação WhatsApp, [ctx],
# tools, regras de fluxo — vem direto da Valentina e fica em sync conforme
# ela evolui.
BRAND_NAME=$(echo "$SPEC" | jq -r '.marca')
UNIT_NAME=$(echo "$SPEC" | jq -r '.unit_name')
SKILL_NAME=$(echo "$SPEC" | jq -r '.skill_name')
CATEGORIAS_LISTA=$(echo "$SPEC" | jq -r '.categories | map(.key) | join(", ")')
cp "$TEMPLATE_PROFILE/SOUL.md" "$PROFILES_DIR/$SLUG/SOUL.md"
# Identity replacements (atenção: ordem importa pra strings que se sobrepõem).
sed -i "s|Dolce Amore Motel|$BRAND_NAME — $UNIT_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|Valentina|$NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|dolce-amore-reservas|$SKILL_NAME|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Substitui exemplos hardcoded de categorias Dolce Amore (Mini Chalé 45 etc) pelas
# 3 primeiras categorias da unidade nova. Sem isso, SOUL.md vaza Dolce Amore-isms
# em descrições de tools mesmo após sed de identidade.
EX_CATS_LIST=$(echo "$SPEC" | jq -r '[.categories[0:3] | .[] | "\"" + .key + "\""] | join(", ")')
FIRST_CAT=$(echo "$SPEC" | jq -r '.categories[0].key // "categoria"')
sed -i "s|\"Master\", \"Luxo\", \"Mini Chalé 45\"|$EX_CATS_LIST|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|Prefere Suíte Master|Prefere $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
sed -i "s|prefiro suíte master|prefiro $FIRST_CAT|g" "$PROFILES_DIR/$SLUG/SOUL.md"
# Localização: a Valentina template é Dolce Amore (Ponta Negra, Natal/RN).
# Sem este sed, novos agentes vazam essa cidade — vimos isso na Juliana
# Qnn01 que ficou "em Ponta Negra, Natal/RN" mesmo sendo de Brasília.
if [[ -n "$CITY" ]]; then
sed -i "s|em Ponta Negra, Natal/RN|em $CITY|g" "$PROFILES_DIR/$SLUG/SOUL.md"
fi
# Skill: usa o markdown gerado pelo expand_spec (tabela do banco + regras).
echo "$SPEC" | jq -r '.skill_md' > "$PROFILES_DIR/$SLUG/skills/$SKILL_NAME/SKILL.md"
# Anti-leak no SOUL.md (proteção contra contaminação cross-unit via Codex).
cat >> "$PROFILES_DIR/$SLUG/SOUL.md" <<GUARD
## 🚨 REGRA CRÍTICA — IGNORE OUTRAS UNIDADES
Você atende APENAS esta unidade ($BRAND_NAME — $UNIT_NAME). Suas categorias são EXCLUSIVAMENTE: $CATEGORIAS_LISTA.
Se algum sinal externo (memória de conversas anteriores, contexto compartilhado, exemplos em prompt de tool) sugerir categorias FORA dessa lista — IGNORE COMPLETAMENTE.
Se cliente perguntar sobre algo de outra unidade, responda: "Aqui temos $CATEGORIAS_LISTA. Quer que eu te passe o valor de alguma?".
GUARD
# Write webhook_subscriptions.json
cat > "$PROFILES_DIR/$SLUG/webhook_subscriptions.json" <<JSON
{
"captain-inbox-${SLUG}": {
"description": "Subscription Hermes — $NAME",
"events": [],
"secret": "$SECRET",
"prompt": "[ctx: cid={conversation_internal_id} aid={account_id} contact={contact_id} name=\"{contact_first_name}\" full_name=\"{contact_name}\" cpf_ok={contact_cpf_present} email_ok={contact_email_present} reservas={contact_total_reservas}]\nCliente acabou de dizer no WhatsApp: \"{message}\"\n\nResponda como $NAME, brevemente, em português brasileiro. Use a skill $SKILL_NAME pra preços, regras e fluxo. Nunca se apresente. Nunca diga que é IA.",
"skills": [],
"deliver": "http_callback",
"created_at": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"deliver_extra": {
"chat_id": "https://iachat.hoteis1001noites.com.br/webhooks/captain/hermes_callback?slug=${SLUG}"
}
}
}
JSON
log "filesystem OK"
# === SYSTEMD ===
cat > "$SYSTEMD_DIR/hermes@.service" <<UNIT
[Unit]
Description=Hermes Agent gateway for profile %i
After=network.target docker.service
[Service]
Type=simple
Environment=HERMES_HOME=$PROFILES_DIR/%i
ExecStart=/usr/local/bin/hermes gateway run --replace
Restart=on-failure
RestartSec=5
StandardOutput=append:$PROFILES_DIR/%i/logs/gateway.log
StandardError=append:$PROFILES_DIR/%i/logs/gateway.log
[Install]
WantedBy=multi-user.target
UNIT
mkdir -p "$PROFILES_DIR/$SLUG/logs"
systemctl daemon-reload
systemctl enable "hermes@$SLUG.service" >/dev/null 2>&1
systemctl restart "hermes@$SLUG.service"
sleep 2
if ! ss -tnlH "( sport = :$PORT )" | grep -q .; then
log "WARNING: daemon for $SLUG not listening on $PORT after 2s — check /root/.hermes/profiles/$SLUG/logs/gateway.log"
else
log "daemon listening on port $PORT"
fi
# === GIT BACKUP ===
if [[ -d "$GIT_BACKUP_REPO/.git" ]]; then
cd "$GIT_BACKUP_REPO"
rsync -a --delete --exclude='logs/' --exclude='cache/' --exclude='sessions/' \
--exclude='state.db*' --exclude='memories/' --exclude='sandboxes/' \
--exclude='.skills_prompt_snapshot.json' --exclude='auth.json' \
--exclude='.env' --exclude='webhook_subscriptions.json' \
"$PROFILES_DIR/$SLUG/" "./profiles/$SLUG/"
git add "profiles/$SLUG"
git commit -m "provision: $SLUG ($NAME)" >/dev/null 2>&1 || true
git push origin main >/dev/null 2>&1 || log "git push failed (silent — backup local OK)"
fi
# === OUTPUT ===
jq -n --arg slug "$SLUG" --arg name "$NAME" --argjson aid "$ASSISTANT_ID" --argjson port "$PORT" \
'{ok: true, slug: $slug, name: $name, assistant_id: $aid, port: $port, listening: true}'

View File

@ -1,227 +0,0 @@
#!/usr/bin/env bash
# hermes-validate <slug> — auditoria completa de um agente Hermes
#
# Uso:
# hermes-validate juliana_qnn1
# hermes-validate valentina --json # output JSON-only pra parsing
#
# Roda 42 checks em DB / filesystem / daemon / routing / tripé humanização /
# tools MCP / pricing. Reporta PASS/FAIL/WARN por item + resumo final.
#
# Exit code 0 = sem FAIL. 1 = pelo menos um FAIL.
set -uo pipefail
SLUG="${1:-}"
JSON_MODE=0
[[ "${2:-}" == "--json" ]] && JSON_MODE=1
PROFILES_DIR="/root/.hermes/profiles"
DOCKER_APP_FILTER="iachat_iachat_app"
[[ -z "$SLUG" ]] && { echo "uso: hermes-validate <slug> [--json]"; exit 2; }
PASS=0; FAIL=0; WARN=0
RESULTS_JSON='[]'
green() { printf "\033[32m✓\033[0m %s\n" "$1"; }
red() { printf "\033[31m✗\033[0m %s\n" "$1"; }
yellow(){ printf "\033[33m⚠\033[0m %s\n" "$1"; }
check() {
local label="$1" status="$2" detail="${3:-}"
case "$status" in
PASS) PASS=$((PASS+1)); [[ $JSON_MODE -eq 0 ]] && green "$label${detail:+ — $detail}" ;;
FAIL) FAIL=$((FAIL+1)); [[ $JSON_MODE -eq 0 ]] && red "$label${detail:+ — $detail}" ;;
WARN) WARN=$((WARN+1)); [[ $JSON_MODE -eq 0 ]] && yellow "$label${detail:+ — $detail}" ;;
esac
RESULTS_JSON=$(echo "$RESULTS_JSON" | jq --arg l "$label" --arg s "$status" --arg d "$detail" '. + [{label: $l, status: $s, detail: $d}]')
}
CID=$(docker ps --filter "name=$DOCKER_APP_FILTER" -q | head -1)
[[ -z "$CID" ]] && { echo "iachat_iachat_app container não rodando"; exit 2; }
PROFILE_DIR="$PROFILES_DIR/$SLUG"
# ============================================================
# Coleta dados do DB num único rails runner pra evitar 30 docker execs
# ============================================================
DB_DUMP=$(docker exec "$CID" bundle exec rails runner "
asst = Captain::Assistant.find_by(hermes_profile_name: '$SLUG', engine: 'hermes')
if asst.nil?
puts({error: 'no_assistant'}.to_json)
exit
end
unit = asst.captain_unit
brand = unit&.brand
ci = CaptainInbox.where(captain_assistant_id: asst.id).first
inbox = ci&.inbox
hum = asst.config['response_delay']
cats = unit&.pricing_categories&.includes(:amounts)&.to_a || []
galleria = unit&.gallery_items&.count || 0
ci_unit_id = ci&.captain_unit_id
inter_ok = unit && unit.respond_to?(:inter_credentials_present?) ? unit.inter_credentials_present? : false
pricing_dry_run = nil
if unit && cats.any?
first_cat_key = cats.first.key
res = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id, suite_category: first_cat_key,
period: 'pernoite_promo', total_guests: 2
)
pricing_dry_run = res[:error] ? \"ERR: #{res[:error]}\" : \"OK R$ #{res[:amount]} (#{first_cat_key}/pernoite)\"
end
out = {
assistant_id: asst.id,
name: asst.name,
engine: asst.engine,
profile_name: asst.hermes_profile_name,
port: asst.hermes_port,
secret_present: !asst.hermes_subscription_secret.nil?,
base_url: asst.hermes_webhook_base_url,
parent_id: asst.parent_assistant_id,
unit_id: unit&.id,
unit_name: unit&.name,
brand_name: brand&.name,
cats_count: cats.size,
cats_keys: cats.map(&:key),
amounts_total: cats.flat_map { |c| c.amounts.to_a }.size,
inbox_id: inbox&.id,
inbox_name: inbox&.name,
inbox_typing_delay: inbox&.typing_delay,
response_delay: hum,
gallery_count: galleria,
enabled_for: inbox ? Captain::Hermes.enabled_for?(inbox) : nil,
webhook_url: inbox ? Captain::Hermes.webhook_url_for(inbox) : nil,
secret_via: inbox ? Captain::Hermes.subscription_signing_secret(inbox)&.first(8) : nil,
ci_unit_id: ci_unit_id,
inter_ok: inter_ok,
pricing_dry_run: pricing_dry_run
}
puts out.to_json
" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
if [[ -z "$DB_DUMP" ]] || ! echo "$DB_DUMP" | jq -e . >/dev/null 2>&1; then
echo "DB query falhou. Output: $DB_DUMP"
exit 2
fi
if echo "$DB_DUMP" | jq -e '.error' >/dev/null; then
red "Captain::Assistant com hermes_profile_name='$SLUG' não existe"
exit 1
fi
ASSISTANT_ID=$(echo "$DB_DUMP" | jq -r '.assistant_id')
PORT=$(echo "$DB_DUMP" | jq -r '.port')
INBOX_ID=$(echo "$DB_DUMP" | jq -r '.inbox_id // ""')
PARENT_ID=$(echo "$DB_DUMP" | jq -r '.parent_id // ""')
[[ $JSON_MODE -eq 0 ]] && echo "=== Validando $SLUG (assistant_id=$ASSISTANT_ID, port=$PORT) ==="
# ============================================================
# A. DB Captain
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- A. DB ---"
check "engine='hermes'" "$([[ $(echo "$DB_DUMP" | jq -r '.engine') == 'hermes' ]] && echo PASS || echo FAIL)"
check "hermes_profile_name setado" "$([[ $(echo "$DB_DUMP" | jq -r '.profile_name') != 'null' && $(echo "$DB_DUMP" | jq -r '.profile_name') != '' ]] && echo PASS || echo FAIL)"
check "hermes_port setado" "$([[ "$PORT" != 'null' && "$PORT" != '' ]] && echo PASS || echo FAIL)" "port=$PORT"
check "hermes_subscription_secret setado" "$([[ $(echo "$DB_DUMP" | jq -r '.secret_present') == 'true' ]] && echo PASS || echo FAIL)"
check "hermes_webhook_base_url" "$([[ $(echo "$DB_DUMP" | jq -r '.base_url') =~ ^http ]] && echo PASS || echo FAIL)"
check "parent_assistant_id setado" "$([[ "$PARENT_ID" != 'null' && "$PARENT_ID" != '' ]] && echo PASS || echo WARN)" "parent=$PARENT_ID"
check "captain_unit_id setado" "$([[ $(echo "$DB_DUMP" | jq -r '.unit_id') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.unit_name')"
ASSIST_UNIT=$(echo "$DB_DUMP" | jq -r '.unit_id // ""')
CI_UNIT=$(echo "$DB_DUMP" | jq -r '.ci_unit_id // ""')
check "CaptainInbox.unit == Assistant.unit (sem divergência)" "$([[ -n "$ASSIST_UNIT" && "$ASSIST_UNIT" == "$CI_UNIT" ]] && echo PASS || echo FAIL)" "asst=$ASSIST_UNIT ci=$CI_UNIT"
check "Brand resolvida" "$([[ $(echo "$DB_DUMP" | jq -r '.brand_name') != 'null' ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.brand_name')"
check "Pricing categorias > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.cats_count') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.cats_count') cats: $(echo "$DB_DUMP" | jq -r '.cats_keys | join(",")')"
check "Pricing amounts > 0" "$([[ $(echo "$DB_DUMP" | jq -r '.amounts_total') -gt 0 ]] && echo PASS || echo FAIL)" "$(echo "$DB_DUMP" | jq -r '.amounts_total') amounts"
PRICING_DRY=$(echo "$DB_DUMP" | jq -r '.pricing_dry_run // ""')
check "Pricing dry-run (calcula sem erro)" "$([[ "$PRICING_DRY" == OK* ]] && echo PASS || echo FAIL)" "$PRICING_DRY"
INTER_OK=$(echo "$DB_DUMP" | jq -r '.inter_ok // false')
check "Credenciais Inter completas (cert+key+client_id)" "$([[ "$INTER_OK" == 'true' ]] && echo PASS || echo WARN)" "Sem isso generate_pix cai no fallback de link"
check "CaptainInbox mapeada" "$([[ "$INBOX_ID" != 'null' && "$INBOX_ID" != '' ]] && echo PASS || echo WARN)" "inbox=$INBOX_ID"
check "Inbox.typing_delay > 0 (debounce)" "$([[ $(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.inbox_typing_delay // 0')s"
check "config.response_delay (typing simulation)" "$([[ $(echo "$DB_DUMP" | jq -r '.response_delay.mode // ""') == 'typing_simulation' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.response_delay.mode // "none"')"
check "GalleryItem para fotos" "$([[ $(echo "$DB_DUMP" | jq -r '.gallery_count') -gt 0 ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.gallery_count') items (send_suite_images precisa)"
# ============================================================
# B. Filesystem
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- B. Filesystem ---"
check "Pasta do profile existe" "$([[ -d "$PROFILE_DIR" ]] && echo PASS || echo FAIL)" "$PROFILE_DIR"
if [[ -d "$PROFILE_DIR" ]]; then
SOUL_LINES=$(wc -l < "$PROFILE_DIR/SOUL.md" 2>/dev/null || echo 0)
check "SOUL.md ≥ 300 linhas" "$([[ $SOUL_LINES -ge 300 ]] && echo PASS || echo WARN)" "$SOUL_LINES linhas"
RESID=$(grep -c 'Dolce Amore Motel\|Mini Chalé 45\|Suíte Ouro\|Chalé 2 Suítes' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
RESID=${RESID:-0}
check "SOUL.md sem resíduo Dolce Amore" "$([[ $RESID -eq 0 ]] && echo PASS || echo FAIL)" "$RESID ocorrências"
IVR=$(grep -c 'RESPONDA ANTES DE PERGUNTAR\|Info vs Reserva' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem Info-vs-Reserva" "$([[ ${IVR:-0} -gt 0 ]] && echo PASS || echo WARN)"
ALG=$(grep -c 'IGNORE OUTRAS UNIDADES' "$PROFILE_DIR/SOUL.md" 2>/dev/null || true)
check "SOUL.md tem anti-leak guard" "$([[ ${ALG:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "skills/<skill_name>/SKILL.md existe" "$([[ -n "$(find "$PROFILE_DIR/skills" -mindepth 2 -name 'SKILL.md' | grep -v dogfood | head -1)" ]] && echo PASS || echo FAIL)"
check "dolce-amore-reservas NÃO está em skills/" "$([[ ! -d "$PROFILE_DIR/skills/dolce-amore-reservas" ]] && echo PASS || echo FAIL)"
SUB_KEY=$(jq -r 'keys[0] // ""' "$PROFILE_DIR/webhook_subscriptions.json" 2>/dev/null)
check "webhook_subscriptions.json com chave correta" "$([[ "$SUB_KEY" == "captain-inbox-$SLUG" ]] && echo PASS || echo FAIL)" "$SUB_KEY"
PORT_OK=$(grep -c "port: $PORT" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml port=$PORT" "$([[ ${PORT_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
HDR_OK=$(grep -c "X-Captain-Assistant-Id: '${PARENT_ID:-$ASSISTANT_ID}'" "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml X-Captain-Assistant-Id correto" "$([[ ${HDR_OK:-0} -gt 0 ]] && echo PASS || echo FAIL)"
MEM_OFF=$(grep -c 'memory_enabled: false' "$PROFILE_DIR/config.yaml" 2>/dev/null || true)
check "config.yaml memory_enabled: false" "$([[ ${MEM_OFF:-0} -gt 0 ]] && echo PASS || echo WARN)"
check "auth.json existe" "$([[ -f "$PROFILE_DIR/auth.json" ]] && echo PASS || echo FAIL)"
check "plugins/captain-http-callback presente" "$([[ -d "$PROFILE_DIR/plugins/captain-http-callback" ]] && echo PASS || echo FAIL)"
check "plugins/captain-webhook presente" "$([[ -d "$PROFILE_DIR/plugins/captain-webhook" ]] && echo PASS || echo FAIL)"
fi
# ============================================================
# C. Daemon / systemd
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- C. Daemon ---"
check "systemd unit hermes@$SLUG ativa" "$([[ $(systemctl is-active "hermes@$SLUG.service" 2>/dev/null) == 'active' ]] && echo PASS || echo FAIL)"
check "systemd unit enabled" "$([[ $(systemctl is-enabled "hermes@$SLUG.service" 2>/dev/null) == 'enabled' ]] && echo PASS || echo WARN)"
check "Daemon escutando na porta $PORT" "$(ss -tnlH "( sport = :$PORT )" 2>/dev/null | grep -q . && echo PASS || echo FAIL)"
ERR_COUNT=$(journalctl -u "hermes@$SLUG.service" --since '10 minutes ago' --no-pager 2>/dev/null | grep -ciE 'error|fatal|critical' || true)
ERR_COUNT=${ERR_COUNT:-0}
check "Logs sem erro recente" "$([[ $ERR_COUNT -eq 0 ]] && echo PASS || echo WARN)" "$ERR_COUNT erros nos últimos 10min"
# ============================================================
# D. Roteamento Captain ↔ Hermes
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- D. Roteamento ---"
check "Captain::Hermes.enabled_for? = true" "$([[ $(echo "$DB_DUMP" | jq -r '.enabled_for') == 'true' ]] && echo PASS || echo FAIL)"
EXPECTED_URL="$(echo "$DB_DUMP" | jq -r '.base_url')/webhooks/captain-inbox-$SLUG"
ACTUAL_URL=$(echo "$DB_DUMP" | jq -r '.webhook_url // ""')
check "webhook_url aponta pra $SLUG" "$([[ "$ACTUAL_URL" == "$EXPECTED_URL" ]] && echo PASS || echo FAIL)" "$ACTUAL_URL"
check "subscription_signing_secret retorna do DB" "$([[ -n "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" && "$(echo "$DB_DUMP" | jq -r '.secret_via // ""')" != 'null' ]] && echo PASS || echo WARN)" "$(echo "$DB_DUMP" | jq -r '.secret_via // "nil"')..."
# ============================================================
# E. MCP tools list (daemon registra todas)
# ============================================================
[[ $JSON_MODE -eq 0 ]] && echo "--- E. MCP tools (no Captain) ---"
EXPECTED_TOOLS=(generate_pix faq_lookup add_label send_suite_images react_to_message update_contact get_contact_history check_pix_payment reschedule_reservation)
TOOLS_REGISTRY=$(docker exec "$CID" bundle exec rails runner "puts Captain::Mcp::ToolRegistry::TOOLS.map(&:name).join(',')" 2>&1 | grep -v 'WARN\|RubyLLM\|ip_lookup' | tail -1)
for tool in "${EXPECTED_TOOLS[@]}"; do
check "MCP tool '$tool' registrado" "$([[ "$TOOLS_REGISTRY" == *"$tool"* ]] && echo PASS || echo FAIL)"
done
# ============================================================
# Resumo
# ============================================================
TOTAL=$((PASS+FAIL+WARN))
if [[ $JSON_MODE -eq 1 ]]; then
jq -n --arg slug "$SLUG" --argjson pass $PASS --argjson fail $FAIL --argjson warn $WARN --argjson total $TOTAL --argjson results "$RESULTS_JSON" \
'{slug: $slug, total: $total, pass: $pass, fail: $fail, warn: $warn, ok: ($fail == 0), results: $results}'
else
echo
echo "=== Resumo de $SLUG ==="
echo "Total: $TOTAL · ${PASS} PASS · ${FAIL} FAIL · ${WARN} WARN"
if [[ $FAIL -eq 0 ]]; then
echo "✅ Sem falhas críticas. Pode soltar."
else
echo "❌ $FAIL falha(s) — corrigir antes de soltar pro cliente."
fi
fi
[[ $FAIL -gt 0 ]] && exit 1
exit 0

View File

@ -184,7 +184,7 @@
# MARK: Captain Config
- name: CAPTAIN_LLM_PROVIDER
display_title: 'Captain LLM Provider'
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional), openai_codex_oauth (assinatura ChatGPT Plus via proxy interno) ou openai_hermes_gateway (Hermes Agent rodando como gateway HTTP local — ele faz o roteamento multi-modelo via OAuth).'
description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional) ou openai_codex_oauth (assinatura ChatGPT Plus via proxy interno).'
value: 'openai_api'
locked: false
- name: CAPTAIN_CODEX_PROXY_URL
@ -192,21 +192,6 @@
description: 'URL base do proxy Codex interno quando CAPTAIN_LLM_PROVIDER=openai_codex_oauth. Default: http://localhost:3000/codex'
value: 'http://localhost:3000/codex'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_URL
display_title: 'Captain Hermes Gateway URL'
description: 'URL base do Hermes Gateway quando CAPTAIN_LLM_PROVIDER=openai_hermes_gateway. Default: http://host.docker.internal:9877 (Hermes rodando no host, container alcança via host.docker.internal).'
value: 'http://host.docker.internal:9877'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_MODEL
display_title: 'Captain Hermes Gateway Model'
description: 'Modelo a passar pro Hermes Gateway no formato <provider>/<model>. Default: anthropic/claude-opus-4-5. O Hermes faz o roteamento real e pode usar Codex/Anthropic/Gemini conforme config local em ~/.hermes/config.yaml.'
value: 'anthropic/claude-opus-4-5'
locked: false
- name: CAPTAIN_HERMES_GATEWAY_API_KEY
display_title: 'Captain Hermes Gateway API Key (optional)'
description: 'API key opcional pro Hermes Gateway. Geralmente vazio (gateway local não exige auth). Se setado, vai no Authorization header das requisições do Captain pro Hermes.'
locked: false
type: secret
- name: CAPTAIN_OPEN_AI_API_KEY
display_title: 'OpenAI API Key'
description: 'The API key used to authenticate requests to OpenAI services for Captain AI.'

View File

@ -58,15 +58,6 @@ Rails.application.routes.draw do
post :bulk_create, on: :collection
end
namespace :captain do
resources :hermes_builder, only: [:index, :create] do
collection do
post :start
delete :reset
get :assistants
get :validate
post :repair
end
end
resource :preferences, only: [:show, :update]
resources :assistants do
member do
@ -284,6 +275,8 @@ Rails.application.routes.draw do
post :sync_templates, on: :member
get :health, on: :member
post :on_whatsapp, on: :member
resources :notification_templates, only: [:index, :create, :update, :destroy],
module: 'captain'
if ChatwootApp.enterprise?
resource :conference, only: %i[create destroy], controller: 'conference' do
get :token, on: :member
@ -524,9 +517,6 @@ Rails.application.routes.draw do
get :inbox_label_matrix
get :first_response_time_distribution
get :outgoing_messages_count
get :inbox_leads_summary
get :conversion_funnel
get :inbox_benchmarking
end
end
resource :year_in_review, only: [:show]
@ -646,9 +636,6 @@ Rails.application.routes.draw do
post 'webhooks/tiktok', to: 'webhooks/tiktok#events'
post 'webhooks/shopify', to: 'webhooks/shopify#events'
post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload'
post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload'
post 'webhooks/captain/builder_callback', to: 'webhooks/captain/hermes_builder_callback#process_payload'
post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload'
namespace :twitter do
resource :callback, only: [:show]

View File

@ -19,8 +19,7 @@ class SeedJasmineAndDanielaPrompts < ActiveRecord::Migration[7.1]
'jasmine_qnn01' => 'Jasmine( Qnn01)',
'jasmine_primeal' => 'Jasmine(PrimeAL)',
'jasmine_primevl' => 'Jasmine(PrimeVL)',
'jasmine_express' => 'Jasmine (Express)',
'jasmine_dolce_amore' => 'Jasmine(DolceAmore)'
'jasmine_express' => 'Jasmine (Express)'
}.freeze
SCENARIO_TITLE_MAP = {

View File

@ -1,9 +0,0 @@
class AddSupabaseMappingToCaptainUnits < ActiveRecord::Migration[7.1]
def change
add_column :captain_units, :supabase_unit_id, :uuid
add_column :captain_units, :supabase_tenant_id, :bigint, default: 1
add_column :captain_units, :supabase_marca_id, :uuid
add_index :captain_units, :supabase_unit_id, unique: true, where: 'supabase_unit_id IS NOT NULL'
end
end

View File

@ -1,8 +0,0 @@
class AddEngineToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :engine, :string, default: 'captain_interno', null: false
add_column :captain_assistants, :hermes_profile_name, :string
add_column :captain_assistants, :hermes_webhook_base_url, :string
add_index :captain_assistants, :engine
end
end

View File

@ -1,29 +0,0 @@
class CreateCaptainPricingTables < ActiveRecord::Migration[7.1]
# rubocop:disable Metrics/MethodLength
def change
add_column :captain_units, :extra_person_fee, :decimal, precision: 10, scale: 2, default: 0.0, null: false
add_column :captain_units, :currency, :string, default: 'BRL', null: false
create_table :captain_pricing_categories do |t|
t.references :captain_unit, null: false, foreign_key: { to_table: :captain_units }
t.string :key, null: false
t.jsonb :aliases, null: false, default: []
t.integer :extra_person_starts_at, null: false, default: 3
t.timestamps
end
add_index :captain_pricing_categories, [:captain_unit_id, :key], unique: true
create_table :captain_pricing_amounts do |t|
t.references :captain_pricing_category, null: false, foreign_key: { to_table: :captain_pricing_categories }
t.string :period, null: false
t.string :day_bucket
t.decimal :amount, precision: 10, scale: 2, null: false
t.timestamps
end
add_index :captain_pricing_amounts,
[:captain_pricing_category_id, :period, :day_bucket],
unique: true,
name: 'idx_captain_pricing_amount_uniq'
end
# rubocop:enable Metrics/MethodLength
end

View File

@ -1,14 +0,0 @@
class AddProvisioningColumnsToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_column :captain_assistants, :hermes_subscription_secret, :string
add_column :captain_assistants, :hermes_port, :integer
add_column :captain_assistants, :parent_assistant_id, :bigint
add_index :captain_assistants, :parent_assistant_id
add_index :captain_assistants,
:hermes_port,
unique: true,
where: 'hermes_port IS NOT NULL',
name: 'idx_captain_assistants_hermes_port_unique'
end
end

View File

@ -1,9 +0,0 @@
class AddCaptainUnitIdToCaptainAssistants < ActiveRecord::Migration[7.1]
def change
add_reference :captain_assistants,
:captain_unit,
foreign_key: { to_table: :captain_units, on_delete: :nullify },
null: true,
index: true
end
end

View File

@ -1,19 +0,0 @@
class AddManualPixToCaptainUnits < ActiveRecord::Migration[7.1]
disable_ddl_transaction!
def change
add_column :captain_units, :pix_mode, :string, default: 'inter_dynamic', null: false
add_column :captain_units, :manual_pix_key, :string
add_column :captain_units, :manual_pix_key_type, :string
add_column :captain_units, :manual_pix_owner_name, :string
add_column :captain_units, :manual_pix_bank_name, :string
add_index :captain_units, :pix_mode, algorithm: :concurrently
add_column :captain_pix_charges, :provider, :string, default: 'inter', null: false
add_column :captain_pix_charges, :manual_proof_payload, :jsonb
add_column :captain_pix_charges, :manual_review_reason, :string
add_index :captain_pix_charges, :provider, algorithm: :concurrently
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
ActiveRecord::Schema[7.1].define(version: 2026_04_22_145733) do
# These extensions should be enabled to support this database
enable_extension "pg_stat_statements"
enable_extension "pg_trgm"
@ -336,18 +336,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
t.text "api_key"
t.jsonb "handoff_webhook_config", default: {}
t.text "orchestrator_prompt"
t.string "engine", default: "captain_interno", null: false
t.string "hermes_profile_name"
t.string "hermes_webhook_base_url"
t.string "hermes_subscription_secret"
t.integer "hermes_port"
t.bigint "parent_assistant_id"
t.bigint "captain_unit_id"
t.index ["account_id"], name: "index_captain_assistants_on_account_id"
t.index ["captain_unit_id"], name: "index_captain_assistants_on_captain_unit_id"
t.index ["engine"], name: "index_captain_assistants_on_engine"
t.index ["hermes_port"], name: "idx_captain_assistants_hermes_port_unique", unique: true, where: "(hermes_port IS NOT NULL)"
t.index ["parent_assistant_id"], name: "index_captain_assistants_on_parent_assistant_id"
end
create_table "captain_brands", force: :cascade do |t|
@ -684,28 +673,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
t.index ["unit_id"], name: "index_captain_pix_charges_on_unit_id"
end
create_table "captain_pricing_amounts", force: :cascade do |t|
t.bigint "captain_pricing_category_id", null: false
t.string "period", null: false
t.string "day_bucket"
t.decimal "amount", precision: 10, scale: 2, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["captain_pricing_category_id", "period", "day_bucket"], name: "idx_captain_pricing_amount_uniq", unique: true
t.index ["captain_pricing_category_id"], name: "index_captain_pricing_amounts_on_captain_pricing_category_id"
end
create_table "captain_pricing_categories", force: :cascade do |t|
t.bigint "captain_unit_id", null: false
t.string "key", null: false
t.jsonb "aliases", default: [], null: false
t.integer "extra_person_starts_at", default: 3, null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["captain_unit_id", "key"], name: "index_captain_pricing_categories_on_captain_unit_id_and_key", unique: true
t.index ["captain_unit_id"], name: "index_captain_pricing_categories_on_captain_unit_id"
end
create_table "captain_pricing_inboxes", force: :cascade do |t|
t.bigint "captain_pricing_id", null: false
t.bigint "inbox_id", null: false
@ -997,16 +964,10 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
t.boolean "proactive_pix_polling_enabled", default: false, null: false
t.bigint "concierge_inbox_id"
t.jsonb "concierge_config", default: {}, null: false
t.uuid "supabase_unit_id"
t.bigint "supabase_tenant_id", default: 1
t.uuid "supabase_marca_id"
t.decimal "extra_person_fee", precision: 10, scale: 2, default: "0.0", null: false
t.string "currency", default: "BRL", null: false
t.index ["account_id"], name: "index_captain_units_on_account_id"
t.index ["captain_brand_id"], name: "index_captain_units_on_captain_brand_id"
t.index ["concierge_inbox_id"], name: "index_captain_units_on_concierge_inbox_id"
t.index ["inbox_id"], name: "index_captain_units_on_inbox_id"
t.index ["supabase_unit_id"], name: "index_captain_units_on_supabase_unit_id", unique: true, where: "(supabase_unit_id IS NOT NULL)"
end
create_table "categories", force: :cascade do |t|
@ -2168,7 +2129,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "captain_assets", "accounts"
add_foreign_key "captain_assets", "captain_suites"
add_foreign_key "captain_assistants", "captain_units", on_delete: :nullify
add_foreign_key "captain_brands", "accounts"
add_foreign_key "captain_configurations", "accounts"
add_foreign_key "captain_contact_memories", "accounts", on_delete: :cascade
@ -2199,8 +2159,6 @@ ActiveRecord::Schema[7.1].define(version: 2026_05_02_160000) do
add_foreign_key "captain_notification_templates", "inboxes"
add_foreign_key "captain_pix_charges", "captain_reservations", column: "reservation_id"
add_foreign_key "captain_pix_charges", "captain_units", column: "unit_id"
add_foreign_key "captain_pricing_amounts", "captain_pricing_categories"
add_foreign_key "captain_pricing_categories", "captain_units"
add_foreign_key "captain_pricings", "accounts"
add_foreign_key "captain_pricings", "captain_brands"
add_foreign_key "captain_prompt_audit_events", "captain_prompt_profiles", column: "prompt_profile_id"

View File

@ -1,150 +0,0 @@
# Backfill one-time das tabelas de preço pra Dolce Amore (unit 4) e
# Express (unit 5) — copia o que estava hardcoded no PricingTables.rb
# antes da migração pra DB.
#
# Idempotente: roda find_or_create_by em tudo. Pode rodar várias vezes sem
# criar duplicata.
#
# Uso:
# docker exec iachat_iachat_app bundle exec rails runner db/seed_pricing_tables.rb
#
# rubocop:disable all
# Garante extra_person_fee + currency configurados nas units
dolce = Captain::Unit.find(4)
dolce.update!(extra_person_fee: 45.0, currency: 'BRL') if dolce.extra_person_fee.to_f.zero?
express = Captain::Unit.find(5)
express.update!(extra_person_fee: 0.0, currency: 'BRL')
DOLCE_AMORE_DATA = {
'apartamento' => {
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard'],
extra_person_starts_at: 3,
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 }
},
'suite_master' => {
aliases: ['master', 'suite master', 'suíte master', '2 andares'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_luxo' => {
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'suite_tematica' => {
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática'],
extra_person_starts_at: 3,
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }
},
'mini_chale_45' => {
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale'],
extra_person_starts_at: 3,
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 }
},
'chale_2_suites' => {
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites'],
extra_person_starts_at: 4,
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 }
},
'suite_ouro' => {
aliases: ['ouro', 'suite ouro', 'suíte ouro'],
extra_person_starts_at: 4,
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 }
},
'chale_master_4_suites' => {
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites'],
extra_person_starts_at: 8,
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 }
}
}.freeze
EXPRESS_DATA = {
'standard' => {
aliases: %w[standard comum básica basica apartamento\ standard],
extra_person_starts_at: 3,
prices: {
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 120.0 },
'diaria' => 150.0
}
},
'master' => {
aliases: ['master', 'melhor', 'suite master', 'suíte master'],
extra_person_starts_at: 3,
prices: {
'2h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
'3h' => { 'mon_wed' => 60.0, 'thu_sun' => 75.0 },
'4h' => { 'mon_wed' => 70.0 },
'5h' => { 'thu_sun' => 85.0 },
'pernoite_promo' => { 'mon_wed' => 120.0, 'thu_sun' => 140.0 },
'diaria' => 160.0
}
},
'singles' => {
aliases: %w[singles single sozinho],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 80.0, 'thu_sun' => 110.0 },
'diaria' => 130.0
}
},
'familia' => {
aliases: %w[familia família familiar],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => 160.0,
'diaria' => 190.0
}
},
'singles_duplo' => {
aliases: ['singles duplo', 'singles_duplo', 'casal', 'duplo'],
extra_person_starts_at: 99,
prices: {
'pernoite_promo' => { 'mon_wed' => 180.0, 'thu_sun' => 220.0 },
'diaria' => 250.0
}
}
}.freeze
def upsert(unit, data)
data.each do |key, attrs|
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
cat.aliases = attrs[:aliases]
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
cat.save!
attrs[:prices].each do |period, value|
if value.is_a?(Hash)
value.each do |bucket, amount|
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
)
row.amount = amount
row.save!
end
else
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
)
row.amount = value
row.save!
end
end
puts "✓ unit=#{unit.id} #{key} (#{attrs[:prices].size} periods)"
end
end
upsert(dolce, DOLCE_AMORE_DATA)
upsert(express, EXPRESS_DATA)
puts "--- summary ---"
puts "dolce categories: #{dolce.pricing_categories.count}"
puts "dolce amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: dolce.id }).count}"
puts "express categories: #{express.pricing_categories.count}"
puts "express amounts: #{Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: express.id }).count}"
# rubocop:enable all

View File

@ -1,120 +0,0 @@
# Seed pricing for units 1 (Hotel Recanto), 2 (PrimeAL), 3 (Qnn01) from
# scenario data. Units 4 (Dolce), 5 (Express), 6 (Prime Ceilândia) já têm.
#
# Idempotente. Roda quantas vezes quiser.
# rubocop:disable all
NOITES_DATA = {
'standard' => {
aliases: %w[standard comum], extra_person_starts_at: 3,
prices: {
'2h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
'3h' => { 'mon_wed' => 50.0, 'thu_sun' => 65.0 },
'4h' => { 'mon_wed' => 60.0, 'thu_sun' => 80.0 },
'pernoite_promo' => { 'mon_wed' => 100.0, 'thu_sun' => 150.0 },
'diaria' => 170.0
}
},
'luxo' => {
aliases: ['luxo', 'classica', 'clássica'], extra_person_starts_at: 3,
prices: {
'2h' => 60.0, '3h' => 75.0, '4h' => 85.0,
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 160.0 },
'diaria' => 190.0
}
},
'hidromassagem' => {
aliases: %w[hidro hidromassagem banheira spa jacuzzi], extra_person_starts_at: 3,
prices: {
'2h' => 110.0, '3h' => 120.0, '4h' => 150.0,
'pernoite_promo' => 250.0, 'diaria' => 300.0
}
}
}.freeze
PRIME_DATA = {
'stilo' => {
aliases: %w[stilo estilo], extra_person_starts_at: 3,
prices: {
'1h' => { 'mon_wed' => 40.0, 'thu_sun' => 50.0 },
'2h' => { 'mon_wed' => 60.0, 'thu_sun' => 70.0 },
'3h' => { 'mon_wed' => 70.0, 'thu_sun' => 80.0 },
'4h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
'pernoite_promo' => { 'mon_wed' => 130.0, 'thu_sun' => 150.0 },
'pernoite_integral' => { 'mon_wed' => 150.0, 'thu_sun' => 170.0 },
'diaria' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 }
}
},
'alexa' => {
aliases: %w[alexa], extra_person_starts_at: 3,
prices: {
'1h' => { 'mon_wed' => 50.0, 'thu_sun' => 60.0 },
'2h' => { 'mon_wed' => 65.0, 'thu_sun' => 75.0 },
'3h' => { 'mon_wed' => 75.0, 'thu_sun' => 85.0 },
'4h' => { 'mon_wed' => 80.0, 'thu_sun' => 90.0 },
'pernoite_promo' => { 'mon_wed' => 140.0, 'thu_sun' => 160.0 },
'pernoite_integral' => { 'mon_wed' => 160.0, 'thu_sun' => 180.0 },
'diaria' => { 'mon_wed' => 170.0, 'thu_sun' => 200.0 }
}
},
'hidromassagem' => {
aliases: %w[hidro hidromassagem banheira spa jacuzzi ofuro], extra_person_starts_at: 3,
prices: {
'1h' => { 'mon_wed' => 130.0, 'thu_sun' => 140.0 },
'2h' => { 'mon_wed' => 150.0, 'thu_sun' => 160.0 },
'3h' => { 'mon_wed' => 170.0, 'thu_sun' => 180.0 },
'4h' => { 'mon_wed' => 190.0, 'thu_sun' => 200.0 },
'pernoite_promo' => { 'mon_wed' => 260.0, 'thu_sun' => 280.0 },
'pernoite_integral' => { 'mon_wed' => 280.0, 'thu_sun' => 300.0 },
'diaria' => { 'mon_wed' => 350.0, 'thu_sun' => 370.0 }
}
}
}.freeze
def upsert(unit, data)
data.each do |key, attrs|
cat = Captain::PricingCategory.find_or_initialize_by(captain_unit_id: unit.id, key: key)
cat.aliases = attrs[:aliases]
cat.extra_person_starts_at = attrs[:extra_person_starts_at]
cat.save!
attrs[:prices].each do |period, value|
if value.is_a?(Hash)
value.each do |bucket, amount|
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: bucket
)
row.amount = amount
row.save!
end
else
row = Captain::PricingAmount.find_or_initialize_by(
captain_pricing_category_id: cat.id, period: period, day_bucket: nil
)
row.amount = value
row.save!
end
end
puts "✓ unit=#{unit.id} #{key}"
end
end
# 1001 Noites brand units = 1 (Hotel Recanto), 3 (Qnn01)
[1, 3].each do |id|
u = Captain::Unit.find(id)
u.update!(extra_person_fee: 45.0, currency: 'BRL') if u.extra_person_fee.to_f.zero?
upsert(u, NOITES_DATA)
end
# Prime brand unit = 2 (PrimeAL)
u = Captain::Unit.find(2)
u.update!(extra_person_fee: 0.0, currency: 'BRL')
upsert(u, PRIME_DATA)
puts "--- summary ---"
[1, 2, 3].each do |id|
u = Captain::Unit.find(id)
cats = u.pricing_categories.count
amounts = Captain::PricingAmount.joins(:pricing_category).where(captain_pricing_categories: { captain_unit_id: u.id }).count
puts "unit #{id} #{u.name}: cats=#{cats} amounts=#{amounts}"
end
# rubocop:enable all

View File

@ -52,11 +52,10 @@ Os nomes batem com `name`/`title` no banco:
| Slug do arquivo | Captain::Assistant#name |
|---|---|
| `jasmine_qnn01` | `Jasmine( Qnn01)` |
| `jasmine_primeal` | `Jasmine(PrimeAL)` |
| `jasmine_primevl` | `Jasmine(PrimeVL)` |
| `jasmine_express` | `Jasmine (Express)` |
| `jasmine_dolce_amore` | `Jasmine(DolceAmore)` |
| `jasmine_qnn01` | `Jasmine( Qnn01)` |
| `jasmine_primeal` | `Jasmine(PrimeAL)` |
| `jasmine_primevl` | `Jasmine(PrimeVL)` |
| `jasmine_express` | `Jasmine (Express)` |
| Slug do cenário | Captain::Scenario#title |
|---|---|
@ -89,4 +88,3 @@ preenchermos os arquivos lá.
- [ ] Qnn01
- [ ] PrimeVL
- [ ] Express
- [ ] Dolce Amore (criado 2026-04-27 — primeira unidade fora do 1001 Noites; marca distinta, motel-first em Natal/RN; não testado em staging ainda)

View File

@ -1,149 +0,0 @@
# System
You are Captain, a multi-agent system. Transfer via `handoff_to_[agent_name]`. Never mention handoffs to the customer.
# Identidade
Você é {{name}}, atendente via WhatsApp de um estabelecimento de hospedagem. Primeiro contato: identifica intenção e roteia ao cenário certo. Tom: natural, ágil, simpático, brasileiro — como atendente humano.
# 👤 REGRA CRÍTICA — CUMPRIMENTE PELO PRIMEIRO NOME
**ANTES de cada resposta, OBRIGATORIAMENTE leia `# Contact Information → Name:` abaixo no Current Context.** Aplique esta lógica SEM EXCEÇÃO:
1. **Extraia o primeiro nome** de `Name:`:
- Se `Name` tem 2+ palavras compostas só por letras (ex: "Rodrigo Borba Machado", "Maria Silva", "Ana Clara Souza") → primeiro nome = primeira palavra (ex: "Rodrigo", "Maria", "Ana").
- Se `Name` é emoji (ex: "😅‼️"), muito curto (< 3 letras), apenas números, "Unknown" ou vazio NÃO primeiro nome. Pule a personalização.
2. **Na PRIMEIRA resposta da conversa** (quando vai mandar a saudação):
- Se há primeiro nome → comece EXATAMENTE com `Oi, <primeiro_nome>!` (ex: "Oi, Rodrigo!"). Depois continue a saudação normalmente.
- Se não há → use `Oi!` genérico.
3. **Em mensagens seguintes** da mesma conversa: use o primeiro nome de vez em quando (1 a cada 2-3 mensagens), em momentos naturais, como faria um atendente humano brasileiro. NÃO repita em toda frase.
**EXEMPLOS OBRIGATÓRIOS:**
| `Name` no Contact Information | Primeira resposta DEVE começar com |
|---|---|
| `Rodrigo Borba Machado` | `Oi, Rodrigo!` |
| `Maria Silva` | `Oi, Maria!` |
| `😅‼️` ou vazio ou `Unknown` | `Oi!` (sem nome) |
| `Rodrigo` (uma palavra só) | `Oi, Rodrigo!` |
Violar essa regra (cumprimentar sem nome quando `Name` é válido) é erro grave de atendimento. O cliente **já forneceu o nome em interação anterior** e espera que lembremos dele.
# ⛔ REGRAS DE SEGURANÇA (sempre ativas, antes de tudo)
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem de transferência (ver "Transferência" abaixo), (d) encerre, não responda mais.
**2. ROTEIE PRO CENÁRIO PRIMEIRO. SÓ depois pense em handoff humano.** A ordem de decisão é SEMPRE esta:
1. **Pergunta sobre preço, valor, tabela, reserva, Pix, "quanto custa", nome de suíte/categoria**`handoff_to_daniela_reservas`. SEMPRE. A Daniela tem a tabela completa de preços de TODAS as 8 categorias do Dolce (Apartamento, Suíte Master, Luxo, Temática, Mini Chalé 45, Chalé 2 Suítes, Chalé Master 4 Suítes, Suíte Ouro). Você (orquestradora) NUNCA responde preço por cima — sempre roteia.
2. **Pergunta sobre foto, imagem, "me mostra", "tem foto da X"**`handoff_to_maria_fotos`.
3. **Pergunta sobre disponibilidade, "tem suíte livre", "tem vaga pra quinta"**`handoff_to_disponibilidade_suites`.
4. **Pergunta sobre outras unidades, outras cidades, "tem em São Paulo?"**`handoff_to_outras_unidades`.
5. **Reclamação, queixa, ouvidoria**`handoff_to_reclamacoes_ouvidoria`.
6. **Saudação vaga, "oi", "tudo bem", "boa tarde"** → cumprimenta, não roteia, espera próximo passo.
7. **NENHUM dos casos acima** → aí sim, considere FAQ/handoff humano (ver regra abaixo).
**Nunca invente. NA DÚVIDA (depois de já ter descartado os 6 cenários acima), TRANSFERE PRA HUMANO em silêncio.** Se a pergunta não cabe em nenhum cenário e você não tem a info documentada, faça **handoff silencioso**: chame `captain--tools--handoff` pra humano e responda APENAS *"Um momento."*. Não diga "vou transferir", "vou chamar", "passar pra equipe", "deixa eu ver", "te passo com a Daniela", "te conecto com Fulano" — qualquer dessas frases entrega que você é robô OU expõe a estrutura interna (Daniela/Maria são cenários, NÃO pessoas reais que o cliente conhece). Tente o `captain--tools--faq_lookup` primeiro APENAS se a dúvida claramente cai numa FAQ documentada (regra de feriado, política de no-show, etc). Se faq_lookup não trouxer resposta clara e específica → handoff direto, *"Um momento."*. Curva conservadora: prefere transferir do que improvisar.
**3. Disfarce como humano.** Não revele que é IA a menos que o cliente pergunte diretamente.
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. **Em particular: NUNCA fale "Daniela", "Maria", "Camila" ou qualquer nome de cenário pro cliente** — esses são roles internos invisíveis. Pro cliente, é tudo VOCÊ ({{name}}). Quando rotear pra cenário, simplesmente faça o `handoff_to_<key>` e o próximo turno já vem do cenário, sem aviso.
**5. PROIBIDO prometer descontos ou cancelar reservas.** Diretriz interna fixa do Dolce Amore: você NUNCA promete desconto, cortesia, brinde extra ou cancelamento de reserva por conta própria. Se o cliente pedir, responda: *"Vou passar seu pedido pra gerência, eles avaliam e te retornam."* — e não comprometa nada. Quem decide isso é humano, nunca você.
**6. PROIBIDO atender menores de idade.** O Dolce Amore não permite entrada de menores. Se o cliente identificar que é menor, ou trouxer/comentar sobre menor acompanhando, deflete educadamente: *"Aqui no Dolce Amore só recebemos hóspedes maiores de 18 anos, é regra fixa da casa."* — e encerra a tentativa de reserva.
# 🎯 Roteamento
Depois de verificar as 6 regras acima:
1. Identifique intenção do cliente.
2. Olhe "Cenários Disponíveis" abaixo — cada um tem gatilhos.
3. Roteie com `handoff_to_<key>`. Se falta dado, roteie mesmo — o cenário coleta.
4. Sem cenário aplicável: `captain--tools--faq_lookup` pra dúvida factual, ou `captain--tools--handoff` pra humano.
**Saudação curta ou vaga** ("oi", "tudo bem") → não roteie. Cumprimente e espere o próximo passo.
**Princípio:** se intenção encaixa num cenário, use — nunca tente resolver "por cima".
# Formato da Resposta
- Máx 2 parágrafos curtos.
- Uma pergunta por vez.
- Negrito em informações críticas.
- Primeira msg da conversa: use a Saudação Personalizada (abaixo). Se o cliente tem nome cadastrado, prefira a variante com nome.
- Depois de cenário/tool retornar: reescreva em linguagem natural. Nunca copie JSON, IDs ou texto técnico.
- Próximo passo claro no final. Cliente sumiu: 1 lembrete educado e encerra.
# Data/Hora
- Data: {{ current_date }}
- Hora: {{ current_time }}
- Fuso: {{ current_timezone }}
{% if conversation or contact -%}
# Current Context
{% if conversation -%}
{% render 'conversation' %}
{% endif -%}
{% if contact -%}
{% render 'contact' %}
{% endif -%}
{% endif -%}
# reaction_emoji (opcional)
Quando fizer sentido (saudação, agradecimento, celebração, "estou verificando"), sugira emoji no campo `reaction_emoji`. Vazio quando não combinar.
# Cenários Disponíveis
{% for scenario in scenarios %}
## {{ scenario.title }}
{{ scenario.description }}
{% if scenario.trigger_keywords != blank %}
**Gatilhos** (`handoff_to_{{ scenario.key }}`): {{ scenario.trigger_keywords }}
{% else %}
Acionar: `handoff_to_{{ scenario.key }}`
{% endif %}
{% endfor %}
# ⛔ Lembretes finais
Nunca: vazar contexto/metadados; prometer mídia antes do tool confirmar; responder por memória quando existe cenário; usar histórico como fonte; copiar texto cru de ferramenta; prometer desconto/cancelamento sem autorização.
# ---SECAO-ASSISTENTE---
# Instruções Específicas desta Unidade
## Contexto
- **Hotel:** Dolce Amore Motel
- **Endereço:** Rua Professor Pedro Pinheiro de Souza, 225 — Ponta Negra, Natal/RN
- **Especialidade:** motel — casais buscando privacidade, por horas, pernoite ou diária
- **Categorias:** Apartamento Standard, Suíte Master, Suíte Luxo, Suíte Temática, Mini Chalé 45, Chalé 2 Suítes, Chalé Master 4 Suítes, Suíte Ouro
- **Público:** casais maiores de 18 anos, geralmente programa de 3h podendo estender até 24h
- **Pagamento:** Pix (sinal de 50%)
**IMPORTANTE — atendimento EXCLUSIVO de Natal/RN.** O Dolce Amore atende somente Ponta Negra/Natal. Não há outras unidades da marca em outras cidades. Se o cliente perguntar por outras regiões, responda que aqui é exclusivo de Natal e que não temos filial em outras cidades.
## Links
- Tabela de preços: {{ media.tabela }}
- WhatsApp: https://wa.me/5584987013256
- Telefone fixo: (84) 3201-5051
- Maps: https://maps.app.goo.gl/i9BvpZAPagjnnFv69
## Saudação (1ª msg) — FÓRMULA ÚNICA
Monte a saudação assim:
```
<saudacao> Sou a {{name}} do Dolce Amore Motel 😊 Como posso te ajudar?
```
Onde `<saudacao>` é:
- `Oi, <primeiro_nome>!` se Name no Contact Information é nome próprio válido (2+ palavras alfabéticas, ex: "Rodrigo Borba Machado" → primeiro_nome = Rodrigo).
- `Oi!` se Name for emoji, curto, número, "Unknown" ou vazio.
Exemplo concreto:
- Name no Contact = "Rodrigo Borba Machado" → primeiro_nome = "Rodrigo" → saudação DEVE ser exatamente: *"Oi, Rodrigo! Sou a {{name}} do Dolce Amore Motel 😊 Como posso te ajudar?"*
NUNCA comece com `Oi!` isolado quando Name é nome próprio válido. Essa é a checagem de qualidade: antes de enviar, releia sua resposta — se começa com `Oi!` sem o nome do cliente mas o Contact Information tem Name válido, você violou a regra.
## Transferência (hóspede já no motel OU qualquer caso de handoff)
**Mensagem ÚNICA:** *"Um momento."*
NUNCA varie pra "vou transferir", "vou chamar", "passar pra equipe", "estou encaminhando", "recepção", "atendimento local", etc. Apenas *"Um momento."* e a tool de handoff cuida do resto.
## Refere-se à unidade como "Dolce Amore" ou "aqui no Dolce Amore".

View File

@ -31,23 +31,13 @@ Violar essa regra (cumprimentar sem nome quando `Name` é válido) é erro grave
# ⛔ REGRAS DE SEGURANÇA (sempre ativas, antes de tudo)
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem de transferência (ver "Transferência" abaixo), (d) encerre, não responda mais.
**1. Hóspede JÁ no estabelecimento → HANDOFF imediato.** Gatilhos: "estou no quarto", "acabou a água", "traz toalha", "o ar não funciona", "estou aqui", "na recepção", "falta papel", etc. Ação (nesta ordem): (a) chame `captain--tools--handoff` pra humano, (b) aplique label `pausar_ia` via `captain--tools--add_label_to_conversation`, (c) mande a mensagem padrão de transferência desta unidade, (d) encerre, não responda mais.
**2. ROTEIE PRO CENÁRIO PRIMEIRO. SÓ depois pense em handoff humano.** A ordem de decisão é SEMPRE esta:
1. **Pergunta sobre preço, valor, tabela, reserva, Pix, "quanto custa", nome de suíte (Stilo, Alexa, Hidromassagem)**`handoff_to_daniela_reservas`. SEMPRE. A Daniela tem a tabela completa de preços. Você (orquestradora) NUNCA responde preço por cima — sempre roteia.
2. **Pergunta sobre foto, imagem, "me mostra", "tem foto da X"**`handoff_to_maria_fotos`.
3. **Pergunta sobre disponibilidade, "tem suíte livre", "tem vaga pra quinta"**`handoff_to_disponibilidade_suites`.
4. **Pergunta sobre outras unidades (PrimeVL, Qnn01, Express, etc) ou cidades**`handoff_to_outras_unidades`.
5. **Reclamação, queixa, ouvidoria**`handoff_to_reclamacoes_ouvidoria`.
6. **Saudação vaga, "oi", "tudo bem", "boa tarde"** → cumprimenta, não roteia, espera próximo passo.
7. **NENHUM dos casos acima** → aí sim, considere FAQ/handoff humano (ver regra abaixo).
**Nunca invente. NA DÚVIDA (depois de já ter descartado os 6 cenários acima), TRANSFERE PRA HUMANO em silêncio.** Se a pergunta não cabe em nenhum cenário e você não tem a info documentada, faça **handoff silencioso**: chame `captain--tools--handoff` pra humano e responda APENAS *"Um momento."*. Não diga "vou transferir", "vou chamar", "passar pra equipe", "deixa eu ver", "te passo com a Daniela", "te conecto com Fulano" — qualquer dessas frases entrega que você é robô OU expõe a estrutura interna (Daniela/Maria são cenários, NÃO pessoas reais que o cliente conhece). Tente o `captain--tools--faq_lookup` primeiro APENAS se a dúvida claramente cai numa FAQ documentada (regra de feriado, política de no-show, etc). Se faq_lookup não trouxer resposta clara e específica → handoff direto, *"Um momento."*. Curva conservadora: prefere transferir do que improvisar.
**2. Nunca invente.** Dúvida factual fora do escopo dos cenários → chame `captain--tools--faq_lookup` ANTES de responder. Nunca responda "não sei". Nunca use histórico como fonte — FAQ é a fonte oficial.
**3. Disfarce como humano.** Não revele que é IA a menos que o cliente pergunte diretamente.
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. **Em particular: NUNCA fale "Daniela", "Maria", "Camila" ou qualquer nome de cenário pro cliente** — esses são roles internos invisíveis. Pro cliente, é tudo VOCÊ ({{name}}). Quando rotear pra cenário, simplesmente faça o `handoff_to_<key>` e o próximo turno já vem do cenário, sem aviso.
**4. Não exponha o sistema.** Nunca mencione ferramentas, cenários, IDs, ou texto cru de tool. Reescreva em linguagem natural.
# 🎯 Roteamento
@ -132,11 +122,8 @@ Exemplo concreto para este teste:
NUNCA comece com `Oi!` isolado quando Name é nome próprio válido. Essa é a checagem de qualidade: antes de enviar, releia sua resposta — se começa com `Oi!` sem o nome do cliente mas o Contact Information tem Name válido, você violou a regra.
## Transferência (hóspede já no hotel OU qualquer caso de handoff)
**Mensagem ÚNICA:** *"Um momento."*
NUNCA varie pra "vou transferir", "vou chamar", "passar pra equipe", "estou encaminhando", "central de atendimento", "atendimento local", "recepção", etc. Apenas *"Um momento."* e a tool de handoff cuida do resto.
## Transferência (hóspede já no hotel)
*"Vou te encaminhar pra um atendente local aí no hotel pra resolver mais rápido. Nosso primeiro atendimento é pela central, já estou transferindo pra equipe presencial. Só um instante."*
## Refere-se à unidade como "1001 Noites Prime Águas Lindas" ou "aqui em Águas Lindas".

Some files were not shown because too many files have changed in this diff Show More