From 9ed3491d559fcd221118e957b139e3ef7b0ab901 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 20:13:16 -0300 Subject: [PATCH] feat(captain/mcp): suite de 9 tools MCP + pricing tables Dolce Amore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../webhooks/captain/mcp_controller.rb | 27 +- .../services/captain/mcp/pricing_tables.rb | 151 ++++++++ .../app/services/captain/mcp/tool_registry.rb | 11 +- .../captain/mcp/tools/add_label_tool.rb | 37 +- .../mcp/tools/check_pix_payment_tool.rb | 116 ++++++ .../captain/mcp/tools/generate_pix_tool.rb | 366 ++++++++++++++++++ .../mcp/tools/get_contact_history_tool.rb | 142 +++++++ .../mcp/tools/react_to_message_tool.rb | 98 +++++ .../mcp/tools/reschedule_reservation_tool.rb | 156 ++++++++ .../mcp/tools/send_suite_images_tool.rb | 136 +++++++ .../captain/mcp/tools/update_contact_tool.rb | 127 ++++++ 11 files changed, 1355 insertions(+), 12 deletions(-) create mode 100644 enterprise/app/services/captain/mcp/pricing_tables.rb create mode 100644 enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/update_contact_tool.rb diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb index c9d563354..a996e782f 100644 --- a/app/controllers/webhooks/captain/mcp_controller.rb +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/pricing_tables.rb b/enterprise/app/services/captain/mcp/pricing_tables.rb new file mode 100644 index 000000000..e4f89e24d --- /dev/null +++ b/enterprise/app/services/captain/mcp/pricing_tables.rb @@ -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: { +# '' => { +# prices: { '3h' => 85, 'pernoite_promo' => 110, ... }, +# aliases: ['apto', 'standard', 'apartamento standard', ...] +# } +# }, +# extra_person_fee: 45, +# extra_person_rules: { '' => 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 diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb index 4922b63f1..747f5cac2 100644 --- a/enterprise/app/services/captain/mcp/tool_registry.rb +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb index ee06b90e6..9a79e0b7f 100644 --- a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb +++ b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb b/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb new file mode 100644 index 000000000..b34b40feb --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb new file mode 100644 index 000000000..64c417407 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb b/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb new file mode 100644 index 000000000..d4f0fbee5 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb b/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb new file mode 100644 index 000000000..f042ac5b0 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb @@ -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=`. 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 diff --git a/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb b/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb new file mode 100644 index 000000000..02ee33518 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb new file mode 100644 index 000000000..dab07c60f --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb @@ -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 diff --git a/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb b/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb new file mode 100644 index 000000000..b30b0498a --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb @@ -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