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:
parent
60759b955c
commit
9ed3491d55
@ -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
|
||||
|
||||
151
enterprise/app/services/captain/mcp/pricing_tables.rb
Normal file
151
enterprise/app/services/captain/mcp/pricing_tables.rb
Normal 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
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
366
enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb
Normal file
366
enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb
Normal 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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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
|
||||
127
enterprise/app/services/captain/mcp/tools/update_contact_tool.rb
Normal file
127
enterprise/app/services/captain/mcp/tools/update_contact_tool.rb
Normal 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
|
||||
Loading…
Reference in New Issue
Block a user