feat: link enviado como mensagem direta + email extraction + contact metadata auto-persist

- GeneratePixTool: envia payment_link como mensagem outgoing direta (bypassa
  hallucination de [Link do Pix] placeholder pela LLM)
- GeneratePixTool: extrai email das mensagens recentes via regex e persiste
  em contact.email
- GenerateReservationLinkTool: mesmo padrao de envio direto do link
- Captain::Reservation: after_create_commit callback atualiza
  ultima_suite/permanencia/reserva_em/total_reservas em contact.custom_attributes
  (aparece no painel lateral)
This commit is contained in:
Rodribm10 2026-04-14 13:44:13 -03:00
parent 7c9411a0b0
commit f8d64b6992
3 changed files with 69 additions and 3 deletions

View File

@ -88,6 +88,7 @@ class Captain::Reservation < ApplicationRecord
before_validation :set_captain_unit_id, on: :create
after_commit :sync_conversation_marker_snapshot
after_create_commit :update_contact_reservation_metadata
def ui_status
Captain::Reservations::MarkerBuilder.ui_status(status)
@ -125,4 +126,23 @@ class Captain::Reservation < ApplicationRecord
rescue StandardError => e
Rails.logger.error("[Captain::Reservation] failed to sync conversation marker: #{e.class} - #{e.message}")
end
# Atualiza campos visiveis no painel lateral do Chatwoot (custom_attributes)
# pra que a recepcionista veja num relance:
# ultima_suite, ultima_permanencia, ultima_reserva_em, total_reservas
def update_contact_reservation_metadata # rubocop:disable Metrics/AbcSize
return if contact.blank?
meta = metadata.to_h
current = contact.custom_attributes.to_h
current['ultima_suite'] = meta['category'].presence || suite_identifier.to_s.split('·').first.to_s.strip
current['ultima_permanencia'] = meta['stay_type'].presence || suite_identifier.to_s.split('·').last.to_s.strip
current['ultima_reserva_em'] = created_at.iso8601
current['total_reservas'] = (current['total_reservas'].to_i + 1)
contact.update_columns(custom_attributes: current) # rubocop:disable Rails/SkipsModelValidations
rescue StandardError => e
Rails.logger.warn("[Captain::Reservation] failed to update contact metadata: #{e.class} - #{e.message}")
end
end

View File

@ -2,6 +2,7 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
CPF_WITH_LABEL_REGEX = /cpf[^\d]*(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})/i
CPF_FALLBACK_REGEX = /\b\d{11}\b/
NAME_WITH_LABEL_REGEX = /nome\s*[:\-]\s*([^\n\r,;]+)/i
EMAIL_REGEX = /\b[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}\b/
SUITE_REGEX = /su[ií]te\s+([^\n\r,.!?]+)/i
DDMMYYYY_REGEX = %r{\b(\d{1,2}/\d{1,2}/\d{2,4})\b}
CURRENCY_REGEX = /r\$\s*([\d.,]+)/i
@ -116,6 +117,8 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
updates[:name] = extracted[:name] if valid_contact_name?(contact.name).blank? && extracted[:name].present?
updates[:email] = extracted[:email] if contact.email.blank? && extracted[:email].present?
return if updates.blank?
contact.update!(updates)
@ -132,6 +135,7 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
cpf = nil
name = nil
email = nil
recent_messages.each do |message|
content = normalize_text(message.content)
@ -139,15 +143,23 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
cpf ||= extract_cpf_from_text(content)
name ||= extract_name_from_text(content)
break if cpf.present? && name.present?
email ||= extract_email_from_text(content)
break if cpf.present? && name.present? && email.present?
end
{
cpf: cpf,
name: name
name: name,
email: email
}.compact
end
def extract_email_from_text(text)
text = normalize_text(text)
candidate = text[EMAIL_REGEX]
candidate.to_s.strip.downcase.presence
end
def extract_cpf_from_text(text)
text = normalize_text(text)
candidate = text[CPF_WITH_LABEL_REGEX, 1]
@ -317,6 +329,18 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
normalize_payload({ formatted_message: msg, success: true })
end
def dispatch_direct_link_message(link, label)
return if @conversation.blank? || link.to_s.strip.empty?
content = "#{label}\n#{link}".strip
Messages::MessageBuilder.new(@assistant, @conversation, {
content: content,
message_type: 'outgoing'
}).perform
rescue StandardError => e
Rails.logger.warn("[GeneratePixTool] failed to dispatch direct link: #{e.class} - #{e.message}")
end
def current_pix_charge_for(reservation)
return nil unless reservation
@ -333,7 +357,13 @@ class Captain::Tools::GeneratePixTool < Captain::Tools::BaseTool
Rails.logger.info "[GeneratePixTool] Reserva #{reservation.id} → pending_payment"
final_prefix = prefix || 'Cobrança Pix gerada com sucesso.'
build_pix_response(charge, reservation, amount: charge_amount, prefix: final_prefix)
response = build_pix_response(charge, reservation, amount: charge_amount, prefix: final_prefix)
# Envia o link como mensagem direta pro cliente. Isso garante que o URL chegue
# no WhatsApp mesmo que a LLM parafraseie com placeholder tipo "[Link do Pix]".
dispatch_direct_link_message(response[:payment_link], 'Link do Pix:')
response
rescue StandardError => e
safe_error_message = normalize_text(e.message)
Rails.logger.error("[GeneratePixTool] Falha ao gerar Pix: #{e.class} - #{safe_error_message}")

View File

@ -81,6 +81,10 @@ class Captain::Tools::GenerateReservationLinkTool < Captain::Tools::BaseTool
query = build_query(enriched_params)
url = "#{base}/?#{query}"
# Envia o link como mensagem direta pra garantir que chegue no WhatsApp
# mesmo que a LLM parafraseie com placeholder [Link da reserva] etc.
dispatch_direct_link_message(conversation, url)
{
formatted_message: url,
raw_payload: url,
@ -117,6 +121,18 @@ class Captain::Tools::GenerateReservationLinkTool < Captain::Tools::BaseTool
}.compact
end
def dispatch_direct_link_message(conversation, url)
return if conversation.blank? || url.to_s.strip.empty?
content = "Link da sua reserva (tudo ja preenchido):\n#{url}"
Messages::MessageBuilder.new(@assistant, conversation, {
content: content,
message_type: 'outgoing'
}).perform
rescue StandardError => e
Rails.logger.warn("[GenerateReservationLinkTool] failed to dispatch link: #{e.class} - #{e.message}")
end
def build_query(actual_params)
mapping = {
marca: actual_params[:marca],