Captain::Hermes::Client (enterprise/app/services/captain/hermes/client.rb): - text_for_hermes: transcreve audio via Whisper antes de enviar pro Hermes (reusa Captain::OpenAiMessageBuilderService) - image_urls_for_hermes: URLs publicas de imagens da message; plugin captain-webhook do Hermes baixa em /tmp/ e popula event.media_urls pra vision multimodal (gpt-4o-mini auxiliary) - contact_history_snapshot: dados eager pro [ctx] (last_reservation_*, total_conversations, ultima_suite, etc) — memoria do contato direto no prompt sem precisar tool call - notify_event + build_event_payload: dispara webhook sintetico pro Hermes pra eventos do sistema (Pix pago etc) — Valentina manda mensagem espontanea sem cliente perguntar Captain::Payments::ConfirmationService: - Hook notify_hermes_proactively! enfileira NotifyPaymentConfirmedJob apos confirmacao de Pix, somente se inbox estiver no fluxo Hermes (Captain interno continua igual sem mudanca) Captain::Hermes::NotifyPaymentConfirmedJob (NOVO): - Monta system_message "[SISTEMA: pagamento_confirmado]\n..." e dispara webhook pro Hermes Valentina - Valentina (via SOUL.md) interpreta como evento do Captain e manda mensagem celebrativa pro cliente Captain::Hermes::DelayedReplyJob (NOVO) — humanizadores: - Liga indicador "digitando..." (composing) via wuzapi - Aguarda delay configuravel via Captain::Assistant.config['response_delay'] (modos: none, fixed, typing_simulation com chars_per_second + min/max) - Posta msg outgoing - Desliga typing - Fallback no HermesCallbackController posta direto se class nao carregada Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
113 lines
3.9 KiB
Ruby
113 lines
3.9 KiB
Ruby
# Recebe o callback do Hermes Agent via plugin captain-http-callback.
|
|
#
|
|
# Fluxo:
|
|
# 1. Captain::Hermes::Client dispara mensagem do cliente pro Hermes
|
|
# (POST /webhooks/captain-inbox-<id> no gateway do Hermes).
|
|
# 2. Hermes processa via subscription Codex/etc dele.
|
|
# 3. Hermes invoca o plugin captain-http-callback que POSTa nesta URL:
|
|
# POST /webhooks/captain/hermes_callback?inbox_id=<id>
|
|
# Body: { "content": "<resposta>", "reply_to": ..., "metadata": {...}, "timestamp": ... }
|
|
# 4. Este controller cria a mensagem outgoing na conversation correta.
|
|
#
|
|
# Identificação da conversation: como o Hermes não preserva metadata customizado
|
|
# de forma confiável, identificamos pela ÚLTIMA conversation pending da inbox
|
|
# que recebeu mensagem nos últimos 5 minutos. Aceitável pra PoC com 1 conversa
|
|
# de teste por vez. Pra produção, melhorar com Redis: delivery_id → conversation_id.
|
|
class Webhooks::Captain::HermesCallbackController < ApplicationController
|
|
RECENT_WINDOW = 5.minutes
|
|
|
|
skip_before_action :verify_authenticity_token, raise: false
|
|
before_action :verify_signature
|
|
before_action :fetch_inbox
|
|
|
|
def process_payload
|
|
content = extract_content
|
|
return head :bad_request if content.blank?
|
|
|
|
conversation = recent_conversation_for(@inbox)
|
|
return log_no_conversation_and_ack if conversation.blank?
|
|
|
|
log_reply(conversation, content)
|
|
if defined?(Captain::Hermes::DelayedReplyJob)
|
|
Captain::Hermes::DelayedReplyJob.perform_later(conversation.id, content)
|
|
else
|
|
create_outgoing_message(conversation, content)
|
|
end
|
|
head :ok
|
|
rescue StandardError => e
|
|
Rails.logger.error "[Hermes::Callback] error: #{e.class}: #{e.message}"
|
|
Rails.logger.error e.backtrace.first(5).join("\n")
|
|
head :internal_server_error
|
|
end
|
|
|
|
private
|
|
|
|
def fetch_inbox
|
|
inbox_id = params[:inbox_id].presence || params.dig(:metadata, :inbox_id).presence
|
|
@inbox = Inbox.find_by(id: inbox_id)
|
|
head :not_found if @inbox.blank?
|
|
end
|
|
|
|
def verify_signature
|
|
secret = Captain::Hermes.callback_signing_secret
|
|
return true if secret.blank? # validação desabilitada (PoC sem secret)
|
|
|
|
signature = request.headers['X-Hermes-Callback-Signature'].to_s
|
|
return head :unauthorized if signature.blank?
|
|
|
|
expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}"
|
|
return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected)
|
|
|
|
true
|
|
end
|
|
|
|
def recent_conversation_for(inbox)
|
|
inbox.conversations
|
|
.where('updated_at >= ?', RECENT_WINDOW.ago)
|
|
.where(status: %w[pending open])
|
|
.order(updated_at: :desc)
|
|
.first
|
|
end
|
|
|
|
def log_no_conversation_and_ack
|
|
Rails.logger.warn "[Hermes::Callback] no recent conversation for inbox #{@inbox.id} — ignorando callback"
|
|
head :ok
|
|
end
|
|
|
|
def extract_content
|
|
normalize_for_whatsapp(params[:content].to_s.strip)
|
|
end
|
|
|
|
# Converte markdown padrão (que LLMs default usam) pra formato WhatsApp:
|
|
# **negrito** -> *negrito*
|
|
# WhatsApp usa single asterisk pra bold; double asterisk aparece literal
|
|
# pro cliente, parecendo bug. Defesa caso o SOUL.md não convença o LLM.
|
|
def normalize_for_whatsapp(content)
|
|
return content if content.blank?
|
|
|
|
content.gsub(/\*\*([^*\n]+?)\*\*/, '*\1*')
|
|
end
|
|
|
|
def log_reply(conversation, content)
|
|
Rails.logger.info(
|
|
"[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)"
|
|
)
|
|
end
|
|
|
|
def create_outgoing_message(conversation, content)
|
|
assistant = conversation.inbox.captain_assistant
|
|
sender = assistant.presence || User.find_by(id: conversation.assignee_id)
|
|
|
|
conversation.messages.create!(
|
|
message_type: :outgoing,
|
|
account_id: conversation.account_id,
|
|
inbox_id: conversation.inbox_id,
|
|
sender: sender,
|
|
content: content,
|
|
content_attributes: {
|
|
external_source: 'hermes_callback'
|
|
}
|
|
)
|
|
end
|
|
end
|