iachat/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb
Rodribm10 9ed3491d55 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>
2026-05-01 20:13:16 -03:00

128 lines
4.9 KiB
Ruby

# 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