From 35de8b7fdefc14b62d0a4d6bad7bbef13dd2b283 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 13:22:22 -0300 Subject: [PATCH] =?UTF-8?q?feat(captain):=20cliente=20Captain=20=E2=86=94?= =?UTF-8?q?=20Hermes=20(outgoing=20job=20+=20callback=20endpoint)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa o lado Captain da integração Nível 2 (Hermes como cérebro). Ativação por inbox via env var CAPTAIN_HERMES_INBOX_IDS — inboxes não listadas seguem usando o orquestrador interno do Captain (Daniela_Reservas etc) sem mudança alguma. Princípio "só adiciona, não retira". Componentes: - enterprise/app/services/captain/hermes.rb Módulo helper de config (env vars, URLs, secrets per-inbox). - enterprise/app/services/captain/hermes/client.rb Service que monta payload (msg + contexto da conversa/inbox/contato) e faz POST autenticado via HMAC-SHA256 (X-Hub-Signature-256) no webhook do Hermes Agent (porta 8644). DispatchError em falha de rede/HTTP. - enterprise/app/jobs/captain/hermes/outgoing_job.rb Wrapper Sidekiq do Client. Retry 3x em DispatchError. - app/controllers/webhooks/captain/hermes_callback_controller.rb Recebe callback do plugin captain-http-callback do Hermes. Valida HMAC se CAPTAIN_HERMES_CALLBACK_SECRET setado, identifica conversation pela última pending da inbox (janela 5min) e cria mensagem outgoing. - config/routes.rb Rota POST /webhooks/captain/hermes_callback (fora de /api/v1/accounts). - enterprise/app/services/enterprise/message_templates/hook_execution_service.rb Branch novo no schedule_captain_response: se Hermes habilitado pra inbox, dispara HermesOutgoingJob; senão, fluxo Captain interno como antes. Env vars (todas opcionais; sem set = Hermes desabilitado em todas inboxes): - CAPTAIN_HERMES_INBOX_IDS (CSV de inbox.id) - CAPTAIN_HERMES_WEBHOOK_BASE_URL (default http://172.17.0.1:8644) - CAPTAIN_HERMES_CALLBACK_SECRET (HMAC validar callbacks de entrada) - CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_ (HMAC assinar saídas) Limitação: identificação da conversation no callback usa última pending da inbox dentro de 5min. OK pra PoC com 1 conversa de teste por vez. Em produção, melhorar mapeando delivery_id ↔ conversation_id em Redis. Próximo passo manual (admin VPS): criar subscription no Hermes: hermes webhook subscribe captain-inbox-1 \\ --prompt 'Cliente disse: {message}. Responda como Daniela ...' \\ --deliver http_callback \\ --deliver-chat-id 'http://CAPTAIN_HOST/webhooks/captain/hermes_callback?inbox_id=1' Depois set CAPTAIN_HERMES_INBOX_IDS=1 + CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_1 no stack do Captain e testar pela inbox Angelina. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../captain/hermes_callback_controller.rb | 91 +++++++++++++++++++ config/routes.rb | 1 + .../app/jobs/captain/hermes/outgoing_job.rb | 32 +++++++ enterprise/app/services/captain/hermes.rb | 75 +++++++++++++++ .../app/services/captain/hermes/client.rb | 76 ++++++++++++++++ .../hook_execution_service.rb | 12 +++ 6 files changed, 287 insertions(+) create mode 100644 app/controllers/webhooks/captain/hermes_callback_controller.rb create mode 100644 enterprise/app/jobs/captain/hermes/outgoing_job.rb create mode 100644 enterprise/app/services/captain/hermes.rb create mode 100644 enterprise/app/services/captain/hermes/client.rb diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb new file mode 100644 index 000000000..563dff60c --- /dev/null +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -0,0 +1,91 @@ +# 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- 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= +# Body: { "content": "", "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 = params[:content].to_s.strip + return head :bad_request if content.blank? + + conversation = recent_conversation_for(@inbox) + return log_no_conversation_and_ack if conversation.blank? + + Rails.logger.info( + "[Hermes::Callback] reply received for conv #{conversation.display_id} (#{content.length} chars)" + ) + + create_outgoing_message(conversation, content) + 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 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 diff --git a/config/routes.rb b/config/routes.rb index 77cc3c176..680f1ca87 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -637,6 +637,7 @@ Rails.application.routes.draw do post 'webhooks/tiktok', to: 'webhooks/tiktok#events' post 'webhooks/shopify', to: 'webhooks/shopify#events' post 'webhooks/wuzapi/:inbox_id', to: 'webhooks/wuzapi#process_payload' + post 'webhooks/captain/hermes_callback', to: 'webhooks/captain/hermes_callback#process_payload' namespace :twitter do resource :callback, only: [:show] diff --git a/enterprise/app/jobs/captain/hermes/outgoing_job.rb b/enterprise/app/jobs/captain/hermes/outgoing_job.rb new file mode 100644 index 000000000..6ec229e41 --- /dev/null +++ b/enterprise/app/jobs/captain/hermes/outgoing_job.rb @@ -0,0 +1,32 @@ +# Dispara o webhook do Hermes Agent assincronamente quando uma mensagem +# do cliente chega numa inbox marcada como Hermes-enabled. +# +# Acionado pelo Enterprise::MessageTemplates::HookExecutionService no lugar do +# Captain::Conversation::ResponseBuilderJob padrão, quando +# Captain::Hermes.enabled_for?(inbox) retorna true. +class Captain::Hermes::OutgoingJob < ApplicationJob + queue_as :default + + retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds + + def perform(conversation_id, message_id) + conversation = Conversation.find_by(id: conversation_id) + message = Message.find_by(id: message_id) + + if conversation.blank? || message.blank? + Rails.logger.warn( + "[Captain::Hermes::OutgoingJob] conversation/message not found: c=#{conversation_id} m=#{message_id}" + ) + return + end + + unless Captain::Hermes.enabled_for?(conversation.inbox) + Rails.logger.info( + "[Captain::Hermes::OutgoingJob] inbox #{conversation.inbox_id} not in CAPTAIN_HERMES_INBOX_IDS — skipping" + ) + return + end + + Captain::Hermes::Client.new(conversation.inbox).dispatch(message: message, conversation: conversation) + end +end diff --git a/enterprise/app/services/captain/hermes.rb b/enterprise/app/services/captain/hermes.rb new file mode 100644 index 000000000..42ee78a3f --- /dev/null +++ b/enterprise/app/services/captain/hermes.rb @@ -0,0 +1,75 @@ +# Configuração compartilhada da integração Captain ↔ Hermes Agent. +# +# A integração usa o Hermes como cérebro do atendimento (Nível 2): +# - Captain recebe msg WhatsApp +# - Dispara webhook do Hermes (POST /webhooks/captain-inbox-) +# - Hermes processa via subscription Codex/etc dele +# - Hermes invoca plugin captain-http-callback que POSTa de volta no Captain +# - Captain cria mensagem outgoing e envia pro WhatsApp +# +# A ativação é por inbox via env var. As 9 outras inboxes do Captain seguem +# usando o orquestrador interno (Daniela_Reservas, etc) sem mudança. +# +# Env vars: +# CAPTAIN_HERMES_INBOX_IDS CSV de inbox.id (ex: "1,5"). Se vazio, +# desativa em todas. Inboxes não listadas +# continuam no fluxo Captain interno. +# CAPTAIN_HERMES_WEBHOOK_BASE_URL Base URL do gateway Hermes +# (default http://172.17.0.1:8644). +# CAPTAIN_HERMES_CALLBACK_SECRET HMAC-SHA256 secret pra validar callback +# do Hermes (X-Hermes-Callback-Signature). +# Se vazio, validação é desabilitada (NÃO +# recomendado em prod). +# CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_ +# Per-inbox secret retornado pelo +# `hermes webhook subscribe`. Usado pra +# assinar o POST OUTGOING. Sem ele, o +# Hermes vai rejeitar o webhook. +module Captain::Hermes + DEFAULT_BASE_URL = 'http://172.17.0.1:8644'.freeze + + module_function + + def enabled_for?(inbox) + return false if inbox.blank? + return false unless inbox.respond_to?(:id) + + inbox_ids.include?(inbox.id) + end + + def inbox_ids + @inbox_ids ||= ENV.fetch('CAPTAIN_HERMES_INBOX_IDS', '') + .split(',') + .map { |s| s.strip.to_i } + .reject(&:zero?) + .freeze + end + + def webhook_base_url + @webhook_base_url ||= (ENV['CAPTAIN_HERMES_WEBHOOK_BASE_URL'].presence || DEFAULT_BASE_URL).chomp('/') + end + + def webhook_url_for(inbox) + "#{webhook_base_url}/webhooks/#{subscription_name_for(inbox)}" + end + + # Convenção de nome de subscription no Hermes: precisa bater com o que o + # admin criou via `hermes webhook subscribe captain-inbox- ...`. + def subscription_name_for(inbox) + "captain-inbox-#{inbox.id}" + end + + def subscription_signing_secret(inbox) + ENV.fetch("CAPTAIN_HERMES_SUBSCRIPTION_SECRET_INBOX_#{inbox.id}", nil) + end + + def callback_signing_secret + ENV.fetch('CAPTAIN_HERMES_CALLBACK_SECRET', nil) + end + + # Reseta caches. Útil em specs ou após reload de config. + def reset_cache! + @inbox_ids = nil + @webhook_base_url = nil + end +end diff --git a/enterprise/app/services/captain/hermes/client.rb b/enterprise/app/services/captain/hermes/client.rb new file mode 100644 index 000000000..8e926a092 --- /dev/null +++ b/enterprise/app/services/captain/hermes/client.rb @@ -0,0 +1,76 @@ +# Cliente HTTP que dispara mensagens do Captain pro webhook do Hermes Agent. +# +# Uso: +# Captain::Hermes::Client.new(inbox).dispatch(message: msg, conversation: conv) +# +# Resultado: POST autenticado via HMAC-SHA256 (X-Hub-Signature-256) no endpoint +# /webhooks/ do Hermes. O Hermes responde 202 imediato e +# processa em background. Quando terminar, invoca o plugin captain-http-callback +# que POSTa de volta no Captain (HermesCallbackController). +class Captain::Hermes::Client + TIMEOUT_SECONDS = 10 + + class DispatchError < StandardError; end + + def initialize(inbox) + @inbox = inbox + end + + def dispatch(message:, conversation:) + payload = build_payload(message: message, conversation: conversation) + body = payload.to_json + headers = signed_headers(body) + + Rails.logger.info "[Captain::Hermes::Client] dispatching msg #{message.id} (conv #{conversation.display_id}) → #{webhook_url}" + + response = HTTParty.post( + webhook_url, + body: body, + headers: headers, + timeout: TIMEOUT_SECONDS + ) + + return response if response.success? || response.code == 202 + + raise DispatchError, "Hermes webhook returned HTTP #{response.code}: #{response.body.to_s.truncate(300)}" + rescue HTTParty::Error, Net::ReadTimeout, Net::OpenTimeout, Errno::ECONNREFUSED => e + raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}" + end + + private + + attr_reader :inbox + + def webhook_url + Captain::Hermes.webhook_url_for(inbox) + end + + def build_payload(message:, conversation:) + { + message: message.content.to_s, + contact_name: conversation.contact&.name, + contact_id: conversation.contact_id, + conversation_id: conversation.display_id, + conversation_internal_id: conversation.id, + inbox_id: inbox.id, + inbox_name: inbox.name, + account_id: inbox.account_id, + message_id: message.id, + timestamp: Time.current.to_i + } + end + + def signed_headers(body) + headers = { 'Content-Type' => 'application/json; charset=utf-8' } + + secret = Captain::Hermes.subscription_signing_secret(inbox) + if secret.present? + sig = OpenSSL::HMAC.hexdigest('SHA256', secret, body) + headers['X-Hub-Signature-256'] = "sha256=#{sig}" + else + Rails.logger.warn "[Captain::Hermes::Client] no signing secret for inbox #{inbox.id} — Hermes will reject" + end + + headers + end +end diff --git a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb index a69a969ce..0f1037990 100644 --- a/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb +++ b/enterprise/app/services/enterprise/message_templates/hook_execution_service.rb @@ -30,6 +30,18 @@ module Enterprise::MessageTemplates::HookExecutionService private def schedule_captain_response + return schedule_hermes_response if Captain::Hermes.enabled_for?(conversation.inbox) + + schedule_internal_response + end + + def schedule_hermes_response + # Inbox marcada via CAPTAIN_HERMES_INBOX_IDS roteia pro gateway do Hermes + # Agent em vez do orquestrador interno do Captain. + Captain::Hermes::OutgoingJob.perform_later(conversation.id, message.id) + end + + def schedule_internal_response job_args = [conversation, conversation.inbox.captain_assistant, message] base_wait = conversation.inbox.typing_delay.to_i.seconds