feat(captain/mcp): suite de 9 tools MCP + pricing tables Dolce Amore

Tools novas em enterprise/app/services/captain/mcp/tools/:
- generate_pix: Pix Inter via PricingTable + fallback link reserva-1001
- check_pix_payment: consulta Inter + dispara ConfirmationService
- send_suite_images: fotos da galeria (Captain::GalleryItem) via wuzapi
- reschedule_reservation: remarca reserva (regra 3h antecedência Dolce)
- update_contact: persiste nome/CPF/email/notas no Contact
- get_contact_history: markdown do histórico do cliente on-demand
- react_to_message: reage com emoji via wuzapi (is_reaction=true)

Captain::Mcp::PricingTables: tabela hardcoded Dolce Amore (8 categorias x 4
periodos + regras de pessoa extra). Backend e fonte de verdade — LLM nao
inventa preco.

add_label_tool: cria Label oficial automaticamente se nao existir, aceita
conversation_id em arguments.

mcp_controller: aceita X-Captain-Account-Id/Assistant-Id/Inbox-Id como
fallback de contexto.

tool_registry: 9 tools ativas.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rodribm10 2026-05-01 20:13:16 -03:00
parent 60759b955c
commit 9ed3491d55
11 changed files with 1355 additions and 12 deletions

View File

@ -79,11 +79,32 @@ class Webhooks::Captain::McpController < ApplicationController
# 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'] || {}
ctx = params['_captain_context'] || {}
return {} unless ctx.is_a?(Hash)
body_ctx = params['_captain_context'] || {}
body_ctx = {} unless body_ctx.is_a?(Hash)
ctx.symbolize_keys
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

@ -0,0 +1,151 @@
# Tabelas de preço por unidade do Captain — fonte de verdade backend pra
# tools MCP que precisam validar valor. Espelha o que está nos prompts/skills
# das assistentes (Valentina, Jasmines, etc), mas centralizada e auditável.
#
# Quando o LLM chama `generate_pix`, ele NÃO informa o valor; apenas
# categoria/período. Tool calcula via essa tabela. Isso impede que o LLM
# invente um valor (ex: "aplicou desconto VIP" sozinho).
#
# Estrutura: TABLES[captain_unit_id] = {
# categories: {
# '<categoria_key>' => {
# prices: { '3h' => 85, 'pernoite_promo' => 110, ... },
# aliases: ['apto', 'standard', 'apartamento standard', ...]
# }
# },
# extra_person_fee: 45,
# extra_person_rules: { '<categoria_key>' => starts_at_guest_n }
# }
#
# Hoje só Dolce Amore (unit 4) está mapeado — Hermes só está ativo nele.
# Conforme outras unidades migrarem pra Hermes, expandir aqui.
# rubocop:disable Metrics/ModuleLength
module Captain::Mcp::PricingTables
PERIOD_KEYS = %w[3h pernoite_promo pernoite_integral diaria].freeze
TABLES = {
# Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4)
4 => {
currency: 'BRL',
extra_person_fee: 45.0,
# Por categoria, a partir de qual hóspede a taxa começa a contar.
# Ex: "starts_at_guest_n=3" significa que 3ª pessoa em diante paga.
# Default 3 — base do quarto inclui 2 pessoas (casal).
categories: {
'apartamento' => {
prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 },
extra_person_starts_at: 3,
aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard']
},
'suite_master' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['master', 'suite master', 'suíte master', '2 andares']
},
'suite_luxo' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica']
},
'suite_tematica' => {
prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 },
extra_person_starts_at: 3,
aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática']
},
'mini_chale_45' => {
prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 },
extra_person_starts_at: 3,
aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale']
},
'chale_2_suites' => {
prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 },
extra_person_starts_at: 4,
aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites']
},
'suite_ouro' => {
prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 },
extra_person_starts_at: 4,
aliases: ['ouro', 'suite ouro', 'suíte ouro']
},
'chale_master_4_suites' => {
prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 },
extra_person_starts_at: 8,
aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites']
}
}
}
}.freeze
class << self
# Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança.
# period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria'
# extra_guests: número TOTAL de hóspedes (não só os "extras" — a função
# calcula extras baseado em extra_person_starts_at).
# rubocop:disable Metrics/MethodLength
def calculate(unit_id:, suite_category:, period:, total_guests: 2)
table = TABLES[unit_id]
return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank?
cat_key, cat_data = find_category(table, suite_category)
return { error: "Categoria '#{suite_category}' não reconhecida nesta unidade." } if cat_data.blank?
period_key = normalize_period(period)
return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank?
base = cat_data[:prices][period_key]
return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if base.blank?
starts_at = cat_data[:extra_person_starts_at] || 3
extra_guests = [total_guests.to_i - (starts_at - 1), 0].max
extra_total = extra_guests * table[:extra_person_fee]
total = (base + extra_total).round(2)
{
amount: total,
breakdown: {
unit_id: unit_id,
suite_category: cat_key,
period: period_key,
base_price: base,
total_guests: total_guests,
extra_guests: extra_guests,
extra_person_fee: table[:extra_person_fee],
extra_total: extra_total
}
}
end
def categories_for(unit_id)
TABLES.dig(unit_id, :categories)&.keys || []
end
private
def find_category(table, raw)
needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ')
return [nil, nil] if needle.blank?
table[:categories].each do |key, data|
candidates = ([key.tr('_', ' ')] + data[:aliases].to_a).map { |c| c.to_s.downcase.strip }
return [key, data] if candidates.any?(needle)
end
[nil, nil]
end
def normalize_period(raw)
key = raw.to_s.downcase.strip.tr('-', '_')
return key if PERIOD_KEYS.include?(key)
# aceita variações comuns
case key
when 'pernoite', 'pernoite_normal', 'promocional' then 'pernoite_promo'
when 'feriado', 'pernoite_feriado', 'sex_sab', 'final_de_semana' then 'pernoite_integral'
when '3', '3 h', 'tres_horas', 'permanencia', 'permanencia_3h' then '3h'
when 'diária' then 'diaria'
end
end
# rubocop:enable Metrics/MethodLength
end
end
# rubocop:enable Metrics/ModuleLength

View File

@ -10,9 +10,14 @@
class Captain::Mcp::ToolRegistry
TOOLS = [
Captain::Mcp::Tools::AddLabelTool,
Captain::Mcp::Tools::FaqLookupTool
# Captain::Mcp::Tools::GeneratePixTool — TODO depois MCP base validar
# Captain::Mcp::Tools::SendSuiteImagesTool — TODO depois MCP base validar
Captain::Mcp::Tools::FaqLookupTool,
Captain::Mcp::Tools::GeneratePixTool,
Captain::Mcp::Tools::UpdateContactTool,
Captain::Mcp::Tools::GetContactHistoryTool,
Captain::Mcp::Tools::CheckPixPaymentTool,
Captain::Mcp::Tools::SendSuiteImagesTool,
Captain::Mcp::Tools::RescheduleReservationTool,
Captain::Mcp::Tools::ReactToMessageTool
# Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro
].freeze

View File

@ -25,20 +25,25 @@ class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool
label: {
type: 'string',
description: 'Nome da etiqueta em snake_case (ex: "cliente_recorrente").'
},
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid) que aparece em [ctx: cid=N] no início da mensagem do cliente. Obrigatório.'
}
},
required: ['label']
required: %w[label conversation_id]
}
end
end
def call(args, context:)
label = args['label'].to_s.strip
label = args['label'].to_s.strip.downcase
return error_response('Argumento "label" é obrigatório.') if label.blank?
conversation = resolve_conversation(context)
return error_response('Conversation atual não encontrada no contexto.') if conversation.blank?
conversation = resolve_conversation(args, context)
return error_response('Conversation atual não encontrada. Passe conversation_id em arguments (cid do [ctx]).') if conversation.blank?
ensure_account_label!(conversation.account, label)
conversation.add_labels([label])
text_response("Etiqueta '#{label}' adicionada à conversa #{conversation.display_id}.")
rescue StandardError => e
@ -48,10 +53,30 @@ class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool
private
def resolve_conversation(context)
conv_id = context[:conversation_internal_id] || context[:conversation_id]
# LLM passa conversation_id em arguments (lendo do [ctx: cid=N]).
# Context (header/body) fica como fallback caso algum dia o cliente MCP
# passe a propagar contexto automaticamente.
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
# Conversation#add_labels só salva a tag via acts_as_taggable. Pra a label
# aparecer no sidebar/dropdown da UI do Chatwoot, ela precisa existir como
# registro oficial em account.labels (model Label). Se não existir, criamos
# com cor neutra — gerência pode ajustar depois pelo painel.
def ensure_account_label!(account, title)
return if account.labels.exists?(title: title)
account.labels.create!(
title: title,
description: 'Criada automaticamente via MCP (Hermes Agent)',
color: '#5C7CFA',
show_on_sidebar: true
)
end
end

View File

@ -0,0 +1,116 @@
# Tool MCP: consulta status de pagamento Pix de uma reserva.
#
# Caso de uso: cliente diz "já paguei", "tá caindo?", "confirma aí". Tool
# consulta a cobrança mais recente da conversa diretamente no Banco Inter
# via Captain::Inter::CobStatusService. Se confirmado pago, atualiza
# Captain::PixCharge + Captain::Reservation + dispara
# Captain::Payments::ConfirmationService (que cuida de marcar reserva
# confirmada, postar mensagem de confirmação, mover labels, etc).
#
# Idempotente: chamadas repetidas com Pix já pago retornam mesmo resultado
# sem efeito colateral. Cliente pode perguntar várias vezes que tá tudo bem.
# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::CheckPixPaymentTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'check_pix_payment'
end
def description
'Verifica se o Pix da reserva já foi pago no Banco Inter. Use quando o cliente ' \
'avisar que pagou ou perguntar status. Retorna: já pago / ainda pendente / não há cobrança. ' \
'Quando confirmar pago, dispara internamente confirmação da reserva (mensagem de ' \
'confirmação vai pro cliente automaticamente).'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
txid: {
type: 'string',
description: 'Opcional. TXID específico da cobrança. Se vazio, pega a Pix mais recente da conversa.'
}
},
required: ['conversation_id']
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
charge = find_charge(conversation, args['txid'])
return text_response('Não há cobrança Pix vinculada a esta conversa. Você pode gerar uma nova com generate_pix.') if charge.blank?
if already_paid?(charge)
return text_response("Pagamento já confirmado para a reserva ##{charge.reservation_id} (R$ #{format('%.2f',
charge.original_value.to_f)}). Pode seguir os próximos passos.")
end
status_result = Captain::Inter::CobStatusService.new(charge).call
if status_result[:paid]
mark_charge_as_paid!(charge, status_result)
paid_amount = status_result[:paid_value].presence || charge.original_value
text_response("Pagamento confirmado no Inter para reserva ##{charge.reservation_id} (TXID #{charge.txid}, R$ #{format('%.2f',
paid_amount.to_f)}). Reserva atualizada.")
else
label = status_result[:status].presence || 'ATIVA'
text_response(
"Ainda não consta pago no Inter (status: #{label}). Pode levar alguns minutos pra cair — " \
'vale aguardar e tentar de novo em 1-2 min.'
)
end
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::CheckPixPaymentTool] error: #{e.class}: #{e.message}")
error_response("Erro ao consultar pagamento: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def find_charge(conversation, txid)
scope = Captain::PixCharge.joins(:reservation)
.where(captain_reservations: { conversation_id: conversation.id, account_id: conversation.account_id })
scope = scope.where(txid: txid.to_s.strip) if txid.present?
scope.order(created_at: :desc).first
end
def already_paid?(charge)
charge.respond_to?(:paid?) ? charge.paid? : charge.status.to_s == 'paid' || charge.reservation&.payment_status.to_s == 'paid'
end
def mark_charge_as_paid!(charge, status_result)
updates = {
status: 'paid',
raw_webhook_payload: status_result[:raw_payload]
}
updates[:e2eid] = status_result[:end_to_end_id] if charge.e2eid.blank? && status_result[:end_to_end_id].present?
updates[:paid_at] = Time.current if charge.paid_at.blank?
charge.update!(updates)
reservation = charge.reservation
return if reservation.blank? || reservation.payment_status.to_s == 'paid'
Captain::Payments::ConfirmationService.new(
reservation: reservation,
source: 'mcp_check_pix_payment',
payload: status_result[:raw_payload]
).perform
end
end
# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength

View File

@ -0,0 +1,366 @@
# Tool MCP: gera cobrança Pix Inter pra reserva de uma suíte.
#
# Caso de uso: cliente confirmou reserva (categoria + dia + duração).
# Hermes invoca esta tool com os dados estruturados; o CAPTAIN calcula o
# valor (consultando Captain::Mcp::PricingTables — fonte de verdade
# backend) e dispara a cobrança Pix via integração Inter já existente.
#
# **NUNCA aceitamos `amount` do LLM** — isso evita que ele invente
# desconto VIP, cortesia ou erro de cálculo. O LLM só fornece os dados
# de classificação; a tabela hardcoded no Captain decide o valor.
#
# Fluxo:
# 1. Resolve Conversation → Inbox → Captain::Unit
# 2. Lookup pricing (Captain::Mcp::PricingTables.calculate)
# 3. Cria/reusa Captain::Reservation (status=draft)
# 4. Captain::Inter::CobService gera Pix (txid + copia-e-cola)
# 5. Posta mensagem outgoing na conversa com link curto do pagamento
# 6. Marca conversa com label `aguardando_pagamento`
# 7. Retorna resumo curto pro LLM (sem URL pra evitar reposta colada)
# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength
class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool
DEFAULT_DEPOSIT_RATIO = 0.5 # sinal padrão = 50% do total
HOURS_BY_PERIOD = {
'3h' => 3,
'pernoite_promo' => 13, # 21h → 10h
'pernoite_integral' => 13,
'diaria' => 24
}.freeze
class << self
def name
'generate_pix'
end
def description
'Gera cobrança Pix pro sinal da reserva (50% do total). Use quando o cliente ' \
'confirmou: categoria de suíte, dia/horário, e tem nome+CPF cadastrados. ' \
'O Captain CALCULA o valor pela tabela oficial — você só passa os dados de ' \
'classificação, NUNCA o valor. A tool envia o link do Pix direto pro cliente; ' \
'você só confirma que foi gerado.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
suite_category: {
type: 'string',
description: 'Categoria da suíte (ex: "suite_master", "apartamento", "mini_chale_45", "chale_2_suites", "suite_ouro", "chale_master_4_suites"). Aceita variações naturais.'
},
period: {
type: 'string',
enum: %w[3h pernoite_promo pernoite_integral diaria],
description: 'Tipo de permanência. "pernoite_promo" = Dom-Qui (mais barato). ' \
'"pernoite_integral" = Sex/Sáb/Feriado/Véspera (mais caro). "3h" = permanência curta. "diaria" = 24h.'
},
total_guests: {
type: 'integer',
description: 'Quantidade TOTAL de hóspedes (não só os extras). Default 2 (casal). A taxa de pessoa extra é calculada automaticamente conforme regra da categoria.',
default: 2
},
check_in_date: {
type: 'string',
description: 'Data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Default: hoje no fuso da conta.'
}
},
required: %w[conversation_id suite_category period]
}
end
end
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]) em arguments.') if conversation.blank?
unit = resolve_unit(conversation)
return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank?
return error_response('Unidade não tem credenciais Inter configuradas. Avise a gerência.') unless unit.inter_credentials_present?
contact = conversation.contact
hydrate_contact_from_recent_messages!(contact, conversation)
missing = identity_missing_fields(contact)
return error_response("Faltam dados do cliente pra gerar Pix: #{missing.join(', ')}. Peça ao cliente antes de chamar esta tool.") if missing.any?
pricing = Captain::Mcp::PricingTables.calculate(
unit_id: unit.id,
suite_category: args['suite_category'],
period: args['period'],
total_guests: (args['total_guests'] || 2).to_i
)
return error_response("Preço não calculado: #{pricing[:error]}") if pricing[:error].present?
total_amount = pricing[:amount]
deposit = (total_amount * DEFAULT_DEPOSIT_RATIO).round(2)
reservation = build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
begin
charge = Captain::Inter::CobService.new(reservation, amount: deposit).call
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] Inter falhou — usando fallback: #{e.class}: #{e.message}")
return dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
end
# Move da fase 'draft' pra 'pending_payment' — agora a reservation
# aparece nas abas/Kanban "Aguardando PIX" do painel.
reservation.update!(status: :pending_payment)
dispatch_link_message(conversation, charge, deposit)
mark_awaiting_payment(conversation)
text_response(
"Pix gerado: sinal R$ #{format('%.2f',
deposit)} (50% de R$ #{format('%.2f',
total_amount)} #{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}). Link enviado em mensagem separada na conversa #{conversation.display_id}."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GeneratePixTool] error: #{e.class}: #{e.message}")
Rails.logger.error(e.backtrace.first(5).join("\n"))
error_response("Erro ao gerar Pix: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_unit(conversation)
captain_inbox = CaptainInbox.find_by(inbox_id: conversation.inbox_id)
return captain_inbox.captain_unit if captain_inbox&.captain_unit.present?
Captain::Unit.find_by(account_id: conversation.account_id, inbox_id: conversation.inbox_id)
end
def identity_missing_fields(contact)
missing = []
missing << 'nome completo' if contact&.name.to_s.squish.length < 3
cpf_digits = contact&.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '')
missing << 'CPF' if cpf_digits.length != 11
missing
end
CPF_REGEX = /\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})\b/
NAME_RUN_REGEX = /\A([\p{L}'\-]{3,}(?:\s+[\p{L}'\-]{2,}){1,5})/u
# Cliente normalmente envia nome+CPF junto numa mensagem ("Rodrigo Borba 12345678901").
# Quando o contact ainda não tem CPF/nome cadastrados, varremos as últimas
# 10 mensagens incoming pra extrair e popular antes de chamar a Inter.
def hydrate_contact_from_recent_messages!(contact, conversation)
return if contact.blank?
needs_cpf = contact.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '').length != 11
needs_name = contact.name.to_s.squish.length < 3
return unless needs_cpf || needs_name
extracted_cpf = nil
extracted_name = nil
conversation.messages
.where(message_type: :incoming, sender_type: 'Contact')
.reorder(created_at: :desc)
.limit(10).each do |msg|
text = msg.content.to_s
extracted_cpf ||= extract_cpf(text) if needs_cpf
extracted_name ||= extract_name(text) if needs_name
break if (!needs_cpf || extracted_cpf) && (!needs_name || extracted_name)
end
updates = {}
updates[:name] = extracted_name if needs_name && extracted_name.present?
updates[:custom_attributes] = contact.custom_attributes.to_h.merge('cpf' => extracted_cpf) if needs_cpf && extracted_cpf.present?
return if updates.empty?
contact.update!(updates)
Rails.logger.info("[Captain::Mcp::GeneratePixTool] hydrated contact #{contact.id} with #{updates.keys}")
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] hydrate failed: #{e.class} - #{e.message}")
end
def extract_cpf(text)
digits = text.to_s.scan(CPF_REGEX).flatten.first.to_s.gsub(/\D/, '')
digits.length == 11 ? digits : nil
end
# Extrai 2-6 palavras alfabéticas seguidas no início do texto, ignorando
# números/pontuação ao redor.
def extract_name(text)
cleaned = text.to_s.gsub(/[\d.,;:\-()]+/, ' ').squish
match = cleaned.match(NAME_RUN_REGEX)
return nil if match.nil?
candidate = match[1].strip
return nil if candidate.length < 5
candidate.split.map(&:capitalize).join(' ')
end
# Reusa a draft mais recente da conversa (últimas 2h) ou cria nova.
# Atualiza campos com base nos novos args (categoria pode ter mudado).
def build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit)
check_in_at = parse_check_in(args['check_in_date'], conversation.account)
period_hours = HOURS_BY_PERIOD[pricing[:breakdown][:period]] || 13
check_out_at = check_in_at + period_hours.hours
reservation = recent_draft_for(conversation) || Captain::Reservation.new(
account: conversation.account,
inbox: conversation.inbox,
contact: conversation.contact,
contact_inbox: conversation.contact_inbox,
conversation: conversation,
captain_unit_id: unit.id
)
reservation.assign_attributes(
suite_identifier: pricing[:breakdown][:suite_category],
check_in_at: check_in_at,
check_out_at: check_out_at,
status: :draft,
payment_status: 'pending',
total_amount: total_amount,
metadata: (reservation.metadata.to_h).merge(
'full_amount' => total_amount,
'deposit_amount' => deposit,
'created_by' => 'mcp_generate_pix_tool',
'pricing_breakdown' => pricing[:breakdown].stringify_keys
)
)
reservation.save!
reservation
end
def recent_draft_for(conversation)
Captain::Reservation
.where(conversation_id: conversation.id, status: 'draft')
.where('updated_at > ?', 2.hours.ago)
.order(updated_at: :desc).first
end
def parse_check_in(raw, account)
tz = account.respond_to?(:timezone) ? (account.timezone.presence || Time.zone.name) : Time.zone.name
Time.use_zone(tz) do
base = if raw.blank?
Time.zone.today
else
try_parse_date(raw) || Time.zone.today
end
Time.zone.local(base.year, base.month, base.day, 21, 0, 0) # entrada padrão 21h
end
end
def try_parse_date(raw)
Date.iso8601(raw)
rescue ArgumentError
begin
Date.strptime(raw, '%d/%m/%Y')
rescue ArgumentError
nil
end
end
def dispatch_link_message(conversation, charge, deposit)
base_url = InstallationConfig.find_by(name: 'FRONTEND_URL')&.value.presence ||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0')
token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s
link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url)
body = "💸 *Pix do sinal — R$ #{format('%.2f', deposit)}*\n\n" \
"Abra o link abaixo pra ver o QR Code e copiar o código Pix:\n#{link}\n\n" \
'Sua reserva fica confirmada automaticamente assim que o pagamento cair (alguns segundos).'
Messages::MessageBuilder.new(
nil,
conversation,
content: body,
message_type: 'outgoing'
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch link: #{e.class} - #{e.message}")
end
def mark_awaiting_payment(conversation)
current = conversation.label_list
merged = (current + ['aguardando_pagamento']).uniq - %w[pagamento_confirmado reserva_feita]
conversation.update_labels(merged)
end
# Quando a Inter API falha (auth, certificado, timeout, etc), em vez de
# devolver erro, mandamos o cliente pra página oficial de reserva
# (reserva-1001) com query string preenchida. Cliente conclui por lá.
# Marca a conversa com `pix_falhou_fallback` pra triagem da gerência.
def dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit)
base = ENV.fetch('RESERVA_1001_BASE_URL',
InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence ||
'https://reservas.hoteis1001noites.com.br')
contact = conversation.contact
custom = contact&.custom_attributes.to_h.with_indifferent_access
params = {
marca: unit.brand&.name,
unidade: unit.name,
permanencia: humanize_period(pricing[:breakdown][:period]),
categoria: humanize_category(pricing[:breakdown][:suite_category]),
checkin: reservation.check_in_at&.iso8601,
nome: contact&.name,
telefone: contact&.phone_number,
cpf: custom[:cpf],
email: contact&.email
}.compact.reject { |_, v| v.to_s.strip.empty? }
url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}"
body = 'Tive um problema técnico pra gerar o Pix por aqui — mas tudo certo, é só finalizar pela página oficial ' \
"(seus dados já estão pré-preenchidos):\n#{url}"
Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform
current = conversation.label_list
conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq)
text_response(
"Inter API indisponível. Link da página de reserva enviado pro cliente (R$ #{format('%.2f',
total_amount)} total / R$ #{format('%.2f',
deposit)} sinal). Marquei conversa com pix_falhou_fallback."
)
end
PERIOD_LABELS = {
'3h' => '3hrs',
'pernoite_promo' => 'Pernoite',
'pernoite_integral' => 'Pernoite',
'diaria' => 'Diaria'
}.freeze
def humanize_period(period_key)
PERIOD_LABELS[period_key] || period_key.to_s.humanize
end
CATEGORY_LABELS = {
'apartamento' => 'Apartamento',
'suite_master' => 'Suite Master',
'suite_luxo' => 'Suite Luxo',
'suite_tematica' => 'Suite Tematica',
'mini_chale_45' => 'Mini Chale 45',
'chale_2_suites' => 'Chale 2 Suites',
'suite_ouro' => 'Suite Ouro',
'chale_master_4_suites' => 'Chale Master 4 Suites'
}.freeze
def humanize_category(cat_key)
CATEGORY_LABELS[cat_key] || cat_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ')
end
end
# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength

View File

@ -0,0 +1,142 @@
# Tool MCP: retorna histórico estruturado do cliente em markdown.
#
# **Determinístico:** o conteúdo é montado on-the-fly do DB do Captain.
# LLM nunca escreve nem altera. Captain é source of truth único — Reservation,
# Conversation, Message, PixCharge etc — esta tool só serializa em markdown
# numa forma amigável pro LLM ler.
#
# Quando usar (do ponto de vista da Valentina):
# - Cliente pergunta sobre passado livre ("o que falamos sobre alergia?")
# - Cliente pede recap ("me lembra o que tava combinado?")
# - Cliente pergunta sobre reserva antiga não-recente (recente já vem no [ctx])
# - Suspeita de cliente VIP / fidelizado pra calibrar tom
#
# Quando NÃO usar:
# - Pergunta cobertas pelo [ctx] (last_res_*, total_reservas) — responda direto
# - Toda mensagem (custo de latência desnecessário)
class Captain::Mcp::Tools::GetContactHistoryTool < Captain::Mcp::Tools::BaseTool
MAX_RESERVATIONS = 8
MAX_CONVERSATIONS = 5
MAX_MESSAGE_SAMPLES_PER_CONV = 6
class << self
def name
'get_contact_history'
end
def description
'Retorna histórico completo do cliente em markdown (reservas, conversas anteriores, ' \
'labels, mensagens-chave). Use quando o cliente perguntar sobre algo do passado que ' \
'não está no [ctx] (ex: "qual era a reserva de 3 meses atrás", "o que falamos sobre X"). ' \
'NÃO use pra perguntas cobertas pelo [ctx] (last_res_date, total_reservas etc).'
end
def input_schema
{
type: 'object',
properties: {
contact_id: {
type: 'integer',
description: 'ID do contato (campo `contact` do [ctx]). Obrigatório.'
},
query: {
type: 'string',
description: 'Opcional. Termo pra filtrar mensagens por conteúdo (ex: "alergia", "desconto"). Se vazio, retorna histórico geral.'
}
},
required: ['contact_id']
}
end
end
def call(args, context:)
contact_id = args['contact_id'].presence || context[:contact_id]
return error_response('contact_id obrigatório.') if contact_id.blank?
contact = Contact.find_by(id: contact_id)
return error_response("Contato #{contact_id} não encontrado.") if contact.blank?
md = build_markdown(contact, args['query'].to_s.strip)
text_response(md)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::GetContactHistoryTool] error: #{e.class}: #{e.message}")
error_response("Erro ao buscar histórico: #{e.message}")
end
private
def build_markdown(contact, query)
sections = []
sections << header_section(contact)
sections << reservations_section(contact)
sections << conversations_section(contact, query)
sections.compact.join("\n\n")
end
def header_section(contact)
custom = contact.custom_attributes.to_h.with_indifferent_access
cpf = custom[:cpf].to_s
cpf_fmt = cpf.length == 11 ? cpf.gsub(/(\d{3})(\d{3})(\d{3})(\d{2})/, '\1.\2.\3-\4') : cpf
[
"# Cliente: #{contact.name} (contact #{contact.id})",
([
cpf.present? ? "**CPF:** #{cpf_fmt}" : nil,
contact.email.present? ? "**Email:** #{contact.email}" : nil,
contact.phone_number.present? ? "**Telefone:** #{contact.phone_number}" : nil
].compact.join(' · ')).presence,
("**Notas:** #{custom[:notes]}" if custom[:notes].present?)
].compact.join("\n")
end
def reservations_section(contact) # rubocop:disable Metrics/AbcSize
reservations = Captain::Reservation
.where(contact_id: contact.id)
.order(check_in_at: :desc)
.limit(MAX_RESERVATIONS)
return '## Reservas\n_(sem reservas registradas)_' if reservations.empty?
lines = ['## Reservas']
reservations.each do |r|
checkin = r.check_in_at&.strftime('%d/%m/%Y às %Hh%M') || '-'
created = r.created_at.strftime('%d/%m/%Y')
total = r.total_amount.to_f
deposit = r.metadata.to_h['deposit_amount'].to_f
paid = Captain::PixCharge.exists?(reservation_id: r.id, status: 'paid')
lines << "### Reserva ##{r.id} — check-in #{checkin}"
lines << "Suíte: #{r.suite_identifier || '-'} · Status: **#{r.status}** · " \
"Total: R$ #{format('%.2f', total)} · Sinal: R$ #{format('%.2f', deposit)} " \
"(#{paid ? 'pago' : 'não pago'}) · Criada em #{created}"
end
lines.join("\n")
end
def conversations_section(contact, query)
convs = contact.conversations.order(last_activity_at: :desc).limit(MAX_CONVERSATIONS)
return '## Conversas anteriores\n_(sem conversas registradas)_' if convs.empty?
lines = ['## Conversas recentes']
convs.each do |c|
label_str = c.label_list.any? ? " · labels: #{c.label_list.join(', ')}" : ''
activity = c.last_activity_at&.strftime('%d/%m/%Y %H:%M') || '-'
lines << "### Conversa ##{c.display_id} (#{c.status}) — #{activity}#{label_str}"
msg_lines = sample_messages(c, query)
lines.concat(msg_lines) if msg_lines.any?
end
lines.join("\n")
end
def sample_messages(conversation, query)
scope = conversation.messages
.where(message_type: %i[incoming outgoing], private: false)
.where('content ~* ?', '\\S')
scope = scope.where('content ILIKE ?', "%#{query}%") if query.present?
scope = scope.reorder(created_at: :asc).limit(MAX_MESSAGE_SAMPLES_PER_CONV)
scope.map do |m|
who = m.message_type == 'incoming' ? 'Cliente' : 'Atendente'
preview = m.content.to_s.gsub(/\s+/, ' ').strip[0, 200]
"- **#{who}:** #{preview}"
end
end
end

View File

@ -0,0 +1,98 @@
# Tool MCP: reage com emoji em uma mensagem do cliente.
#
# Caso de uso: gestos rápidos sem texto (cliente mandou foto bonita,
# áudio agradecendo, confirmação curta, etc). É bastidor — não substitui
# resposta textual; complementa ou indica leitura.
#
# Implementação: cria Message outgoing com `content_attributes.is_reaction=true`
# e `in_reply_to_external_id=<source_id da msg alvo>`. O pipeline wuzapi
# (Whatsapp::Providers::WuzapiService#send_reaction_message) detecta esses
# atributos e dispara via API do wuzapi como react nativo do WhatsApp.
class Captain::Mcp::Tools::ReactToMessageTool < Captain::Mcp::Tools::BaseTool
class << self
def name
'react_to_message'
end
def description
'Reage com emoji em uma mensagem do cliente (ex: 👍 ❤️ 😍 🙏 😂 😮 😢). ' \
'Use pra gestos curtos: cliente mandou foto bonita → 😍, agradeceu → 🙏, ' \
'confirmou algo → 👍. NÃO substitui resposta — é complementar. Sem texto extra.'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
emoji: {
type: 'string',
description: 'Emoji único a reagir (ex: 👍, ❤️, 😍, 🙏, 😂, 😮, 😢).'
},
message_id: {
type: 'integer',
description: 'Opcional. ID interno da mensagem do cliente. Se vazio, reage à última mensagem incoming da conversa.'
}
},
required: %w[conversation_id emoji]
}
end
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
emoji = args['emoji'].to_s.strip
return error_response('Argumento "emoji" é obrigatório.') if emoji.blank?
target = resolve_target_message(conversation, args['message_id'])
return error_response('Não achei mensagem do cliente pra reagir.') if target.blank?
if target.source_id.blank?
return error_response("Mensagem alvo (id=#{target.id}) sem source_id — wuzapi não consegue identificar a msg no WhatsApp.")
end
assistant = conversation.inbox.captain_assistant
conversation.messages.create!(
message_type: :outgoing,
account_id: conversation.account_id,
inbox_id: conversation.inbox_id,
sender: assistant,
content: emoji,
content_attributes: {
is_reaction: true,
in_reply_to_external_id: target.source_id,
external_source: 'hermes_react_tool'
}
)
text_response("Reação #{emoji} enviada na mensagem ##{target.id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::ReactToMessageTool] error: #{e.class}: #{e.message}")
error_response("Erro ao reagir: #{e.message}")
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def resolve_target_message(conversation, message_id)
if message_id.present?
conversation.messages.find_by(id: message_id)
else
conversation.messages.where(message_type: :incoming).order(created_at: :desc).first
end
end
end

View File

@ -0,0 +1,156 @@
# Tool MCP: remarca uma reserva existente.
#
# Caso de uso: cliente diz "vou precisar mudar pra outra data", "queria
# adiantar pra sex", "consegue empurrar pra 25?". Tool ajusta o
# check_in_at/check_out_at da Captain::Reservation mais recente da
# conversa, mantendo categoria e total_amount intactos.
#
# Política Dolce Amore: remarcação tem que ser feita com no mínimo 3h
# de antecedência em relação ao check-in atual. Tool valida.
#
# Idempotente em datas iguais: se a nova data == atual, não toca em nada.
#
# Não cobre: mudança de categoria/preço (use cancel + generate_pix novo)
# ou cancelamento (transferir pra humano via frase-âncora).
class Captain::Mcp::Tools::RescheduleReservationTool < Captain::Mcp::Tools::BaseTool
MIN_NOTICE_HOURS = 3
class << self
def name
'reschedule_reservation'
end
def description
'Remarca a reserva existente da conversa pra uma nova data. Mantém categoria e ' \
'valor. Política: precisa ser pedido com no mínimo 3h de antecedência em relação ' \
'ao check-in atual. Use quando cliente pedir mudança de data SEM mudar categoria. ' \
'Pra mudança de categoria, transfira pra humano (frase-âncora).'
end
def input_schema
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
new_check_in_date: {
type: 'string',
description: 'Nova data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Hora padrão = mesma da reserva original.'
},
new_check_in_time: {
type: 'string',
description: 'Opcional. Nova hora de check-in (HH:MM, 24h). Default: mantém hora atual.'
}
},
required: %w[conversation_id new_check_in_date]
}
end
end
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
def call(args, context:)
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
reservation = recent_reservation(conversation)
return error_response('Não há reserva ativa pra remarcar nessa conversa.') if reservation.blank?
new_check_in = build_new_check_in(args, reservation)
return error_response('Não consegui interpretar a data. Use YYYY-MM-DD ou DD/MM/YYYY.') if new_check_in.blank?
if new_check_in == reservation.check_in_at
formatted = new_check_in.strftime('%d/%m/%Y %Hh%M')
return text_response("Reserva ##{reservation.id} já está marcada pra #{formatted}. Nada a alterar.")
end
notice_hours = ((reservation.check_in_at - Time.current) / 1.hour).round
if notice_hours < MIN_NOTICE_HOURS
return error_response(
"Política do hotel: remarcação precisa ser pedida com no mínimo #{MIN_NOTICE_HOURS}h de antecedência. " \
"Faltam só #{notice_hours}h pro check-in atual — peça pro cliente confirmar com a gerência."
)
end
duration = reservation.check_out_at - reservation.check_in_at
reservation.update!(check_in_at: new_check_in, check_out_at: new_check_in + duration)
post_reschedule_note(conversation, reservation, new_check_in)
formatted = new_check_in.strftime('%d/%m/%Y às %Hh%M')
valor = format('%.2f', reservation.total_amount.to_f)
text_response(
"Reserva ##{reservation.id} remarcada pra #{formatted} " \
"(categoria #{reservation.suite_identifier}, valor R$ #{valor} mantido)."
)
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::RescheduleReservationTool] error: #{e.class}: #{e.message}")
error_response("Erro ao remarcar: #{e.message}")
end
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
# Pega reserva mais recente da conversa que ainda não foi finalizada/cancelada.
def recent_reservation(conversation)
Captain::Reservation
.where(conversation_id: conversation.id)
.where.not(status: %w[cancelled done])
.order(check_in_at: :desc)
.first
end
def build_new_check_in(args, reservation) # rubocop:disable Metrics/AbcSize
date = parse_date(args['new_check_in_date'])
return nil if date.blank?
time = parse_time(args['new_check_in_time']) || [reservation.check_in_at.hour, reservation.check_in_at.min]
tz = reservation.account.respond_to?(:timezone) ? (reservation.account.timezone.presence || Time.zone.name) : Time.zone.name
Time.use_zone(tz) { Time.zone.local(date.year, date.month, date.day, time[0], time[1], 0) }
end
def parse_date(raw)
raw = raw.to_s.strip
return nil if raw.blank?
Date.iso8601(raw)
rescue ArgumentError
begin
Date.strptime(raw, '%d/%m/%Y')
rescue ArgumentError
nil
end
end
def parse_time(raw)
raw = raw.to_s.strip
return nil if raw.blank?
match = raw.match(/\A(\d{1,2}):(\d{2})\z/)
return nil unless match
[match[1].to_i, match[2].to_i]
end
def post_reschedule_note(conversation, reservation, new_check_in)
body = "🔄 Reserva ##{reservation.id} remarcada pra #{new_check_in.strftime('%d/%m/%Y às %Hh%M')}. Categoria e valor mantidos."
Messages::MessageBuilder.new(
nil,
conversation,
content: body,
message_type: 'outgoing',
private: true # nota interna pro time, cliente não vê
).perform
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::RescheduleReservationTool] failed to post note: #{e.class} - #{e.message}")
end
end

View File

@ -0,0 +1,136 @@
# Tool MCP: envia fotos de suítes pro cliente.
#
# Caso de uso: cliente pede pra ver foto da suíte antes de fechar
# ("manda uma foto da Master?", "tem como ver?"). Tool busca o catálogo
# Captain::GalleryItem da inbox atual (com fallback pra acervo global) e
# envia até `limit` imagens como mensagens outgoing na conversa.
#
# Search: aceita `suite_category` (ex: "Master", "Luxo") OU `suite_number`
# (ex: "101"), mutuamente exclusivos. Match case-insensitive, fuzzy.
#
# Pré-requisito: cadastro do Captain::GalleryItem via painel UI do
# Chatwoot — Captain::Mcp não cria fotos, só consome o catálogo.
class Captain::Mcp::Tools::SendSuiteImagesTool < Captain::Mcp::Tools::BaseTool
DEFAULT_LIMIT = 3
MAX_LIMIT = 5
class << self
def name
'send_suite_images'
end
def description
'Envia fotos da suíte pra conversa do cliente. Use quando ele pedir foto/imagem ' \
'("manda uma foto", "tem como ver?"). Busca no catálogo da inbox atual (fallback ' \
'global). Passe `suite_category` (ex: "Master", "Luxo", "Mini Chalé 45") OU ' \
'`suite_number` (ex: "101") — não combine os dois.'
end
def input_schema # rubocop:disable Metrics/MethodLength
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
suite_category: {
type: 'string',
description: 'Nome/tipo da suíte (ex: "Master", "Luxo", "Mini Chalé 45"). Use quando o cliente pede pelo NOME da categoria.'
},
suite_number: {
type: 'string',
description: 'Número específico da suíte (ex: "101"). Use quando o cliente pede pelo NÚMERO. Mutuamente exclusivo com suite_category.'
},
limit: {
type: 'integer',
description: "Quantas fotos enviar (default #{DEFAULT_LIMIT}, máx #{MAX_LIMIT}).",
default: DEFAULT_LIMIT
}
},
required: ['conversation_id']
}
end
end
def call(args, context:) # rubocop:disable Metrics/AbcSize
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
suite_category = args['suite_category'].to_s.strip
suite_number = args['suite_number'].to_s.strip
return error_response('Passe suite_category OU suite_number — pelo menos um.') if suite_category.blank? && suite_number.blank?
items = find_items(conversation, suite_category, suite_number, args['limit'].to_i)
if items.blank?
label = suite_category.presence || suite_number
return text_response("Nenhuma foto cadastrada pra #{label}. Avise o cliente que pode pedir pro humano.")
end
sent = items.count { |item| send_image_message(conversation, item) }
label = suite_category.presence || "suíte #{suite_number}"
text_response("#{sent} foto(s) de #{label} enviadas na conversa #{conversation.display_id}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::SendSuiteImagesTool] error: #{e.class}: #{e.message}")
error_response("Erro ao enviar fotos: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def find_items(conversation, suite_category, suite_number, limit)
base = Captain::GalleryItem
.active
.where(account_id: conversation.account_id)
.includes(image_attachment: :blob)
.ordered
filtered = if suite_number.present?
base.where('LOWER(suite_number) = ?', suite_number.downcase)
else
base.where(
'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \
'= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \
"'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))",
suite_category
)
end
inbox_scoped = filtered.where(scope: 'inbox', inbox_id: conversation.inbox_id)
pool = inbox_scoped.exists? ? inbox_scoped : filtered.where(scope: 'global')
pool.limit(normalize_limit(limit))
end
def normalize_limit(value)
n = value.to_i
return DEFAULT_LIMIT if n <= 0
[n, MAX_LIMIT].min
end
def send_image_message(conversation, item)
return false unless item.image.attached?
Messages::MessageBuilder.new(
nil,
conversation,
content: item.description.to_s.truncate(220),
message_type: 'outgoing',
attachments: [item.image.blob.signed_id]
).perform
true
rescue StandardError => e
Rails.logger.warn("[Captain::Mcp::SendSuiteImagesTool] failed sending item #{item.id}: #{e.class} - #{e.message}")
false
end
end

View File

@ -0,0 +1,127 @@
# Tool MCP: persiste dados do cliente no Contact do Captain (Chatwoot).
#
# Caso de uso: cliente forneceu nome/CPF/email/telefone na conversa.
# Valentina (ou qualquer agente) chama esta tool ASSIM QUE recebe os dados,
# antes mesmo de tentar gerar Pix. Garante que se o cliente abandonar a
# conversa antes de fechar, os dados ficam persistidos pra próxima
# conversa daquele Contact (visível pelo time humano e pelo Hermes via
# [ctx] na próxima vez).
#
# Validações:
# - name: mínimo 3 chars
# - cpf: exatamente 11 dígitos (formato livre — extrai dígitos)
# - email: regex básico
# - phone: aceita formato livre — não normaliza pra E.164 (Chatwoot já cuida disso ao salvar)
#
# Body wins: campo só é atualizado se passado E válido. Passar string vazia = ignora.
class Captain::Mcp::Tools::UpdateContactTool < Captain::Mcp::Tools::BaseTool
EMAIL_REGEX = /\A[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\z/
class << self
def name
'update_contact'
end
def description
'Salva dados do cliente no cadastro permanente (nome, CPF, email, telefone, ' \
'observações). Use assim que receber o dado — antes mesmo de gerar Pix. ' \
'Garante que próxima conversa do mesmo cliente já vem com [ctx: cpf_ok=true]. ' \
'Não confirme pro cliente que salvou — é bastidor.'
end
def input_schema # rubocop:disable Metrics/MethodLength
{
type: 'object',
properties: {
conversation_id: {
type: 'integer',
description: 'ID interno da conversa (cid do [ctx]). Obrigatório.'
},
name: {
type: 'string',
description: 'Nome completo do cliente (mínimo 3 caracteres). Opcional.'
},
cpf: {
type: 'string',
description: 'CPF do cliente (qualquer formato com 11 dígitos). Opcional.'
},
email: {
type: 'string',
description: 'Email do cliente. Opcional.'
},
phone: {
type: 'string',
description: 'Telefone do cliente (com DDD e país preferencialmente). Opcional.'
},
notes: {
type: 'string',
description: 'Observação livre sobre o cliente (preferências, alergias, ' \
'particularidades). Vai pra custom_attributes.notes. Opcional.'
}
},
required: ['conversation_id']
}
end
end
def call(args, context:) # rubocop:disable Metrics/AbcSize
conversation = resolve_conversation(args, context)
return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank?
contact = conversation.contact
return error_response('Conversa sem contato vinculado.') if contact.blank?
updates = build_updates(args, contact)
return text_response('Nada novo pra salvar (campos vazios ou já idênticos).') if updates.empty?
contact.update!(updates)
text_response("Contato #{contact.id} atualizado: #{updates.keys.join(', ')}.")
rescue ActiveRecord::RecordInvalid => e
Rails.logger.warn("[Captain::Mcp::UpdateContactTool] validation: #{e.record.errors.full_messages.join(', ')}")
error_response("Validação falhou: #{e.record.errors.full_messages.join(', ')}.")
rescue StandardError => e
Rails.logger.error("[Captain::Mcp::UpdateContactTool] error: #{e.class}: #{e.message}")
error_response("Erro ao atualizar contato: #{e.message}")
end
private
def resolve_conversation(args, context)
conv_id = args['conversation_id'].presence ||
context[:conversation_internal_id] ||
context[:conversation_id]
return nil if conv_id.blank?
Conversation.find_by(id: conv_id) || Conversation.find_by(display_id: conv_id)
end
def build_updates(args, contact) # rubocop:disable Metrics/AbcSize
updates = {}
name = args['name'].to_s.squish
updates[:name] = name if name.length >= 3 && name != contact.name.to_s.squish
email = args['email'].to_s.strip.downcase
updates[:email] = email if email.match?(EMAIL_REGEX) && email != contact.email.to_s.downcase
phone = args['phone'].to_s.strip
updates[:phone_number] = phone if phone.present? && phone.gsub(/\D/, '').length >= 10 && phone != contact.phone_number.to_s
custom_changes = build_custom_attribute_changes(args, contact)
updates[:custom_attributes] = contact.custom_attributes.to_h.merge(custom_changes) if custom_changes.any?
updates
end
def build_custom_attribute_changes(args, contact)
custom_changes = {}
current = contact.custom_attributes.to_h.with_indifferent_access
cpf_digits = args['cpf'].to_s.gsub(/\D/, '')
custom_changes['cpf'] = cpf_digits if cpf_digits.length == 11 && cpf_digits != current[:cpf].to_s
notes = args['notes'].to_s.strip
custom_changes['notes'] = notes if notes.present? && notes != current[:notes].to_s
custom_changes
end
end