feat(captain): cliente Captain ↔ Hermes (outgoing job + callback endpoint)
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_<id> (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) <noreply@anthropic.com>
This commit is contained in:
parent
89b471831d
commit
35de8b7fde
@ -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-<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 = 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
|
||||
@ -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]
|
||||
|
||||
32
enterprise/app/jobs/captain/hermes/outgoing_job.rb
Normal file
32
enterprise/app/jobs/captain/hermes/outgoing_job.rb
Normal file
@ -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
|
||||
75
enterprise/app/services/captain/hermes.rb
Normal file
75
enterprise/app/services/captain/hermes.rb
Normal file
@ -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-<id>)
|
||||
# - 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_<id>
|
||||
# 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-<id> ...`.
|
||||
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
|
||||
76
enterprise/app/services/captain/hermes/client.rb
Normal file
76
enterprise/app/services/captain/hermes/client.rb
Normal file
@ -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/<subscription_name> 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
|
||||
@ -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
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user