From 7700afd508b7aba3f8e77cac87765176e8b40268 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 11:24:31 -0300 Subject: [PATCH 01/63] =?UTF-8?q?feat(captain):=20adiciona=20Hermes=20Gate?= =?UTF-8?q?way=20como=203=C2=AA=20op=C3=A7=C3=A3o=20de=20LLM=20provider?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Acrescenta valor 'openai_hermes_gateway' ao CAPTAIN_LLM_PROVIDER, sem mexer nas opções existentes (openai_api e openai_codex_oauth continuam intactos). Quando ativado, o Captain chama o Hermes Agent rodando em modo gateway HTTP local (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877). O Hermes faz o roteamento multi-modelo (Codex/Anthropic/Gemini) usando o OAuth dele em ~/.hermes/auth.json — o Captain não precisa fazer OAuth direto. Configs novas em installation_config.yml: - CAPTAIN_HERMES_GATEWAY_URL — URL do gateway (default host.docker.internal:9877) - CAPTAIN_HERMES_GATEWAY_MODEL — modelo no formato / - CAPTAIN_HERMES_GATEWAY_API_KEY — opcional, dummy se gateway local não exige Embeddings e Files API continuam apontando pra OpenAI tradicional via legacy_openai_settings — Hermes Gateway não expõe esses endpoints. Specs cobrem: dummy key, custom api_key override, custom model, defaults, trailing slash strip, light_model por provider, hermes_gateway? predicate. Co-Authored-By: Claude Opus 4.7 (1M context) --- config/installation_config.yml | 17 ++++- .../services/captain/llm/provider_config.rb | 45 ++++++++++--- .../captain/llm/provider_config_spec.rb | 67 +++++++++++++++++++ 3 files changed, 119 insertions(+), 10 deletions(-) diff --git a/config/installation_config.yml b/config/installation_config.yml index 1d02405d2..e4892b526 100644 --- a/config/installation_config.yml +++ b/config/installation_config.yml @@ -184,7 +184,7 @@ # MARK: Captain Config - name: CAPTAIN_LLM_PROVIDER display_title: 'Captain LLM Provider' - description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional) ou openai_codex_oauth (assinatura ChatGPT Plus via proxy interno).' + description: 'Qual provider o Captain usa: openai_api (padrão, API key tradicional), openai_codex_oauth (assinatura ChatGPT Plus via proxy interno) ou openai_hermes_gateway (Hermes Agent rodando como gateway HTTP local — ele faz o roteamento multi-modelo via OAuth).' value: 'openai_api' locked: false - name: CAPTAIN_CODEX_PROXY_URL @@ -192,6 +192,21 @@ description: 'URL base do proxy Codex interno quando CAPTAIN_LLM_PROVIDER=openai_codex_oauth. Default: http://localhost:3000/codex' value: 'http://localhost:3000/codex' locked: false +- name: CAPTAIN_HERMES_GATEWAY_URL + display_title: 'Captain Hermes Gateway URL' + description: 'URL base do Hermes Gateway quando CAPTAIN_LLM_PROVIDER=openai_hermes_gateway. Default: http://host.docker.internal:9877 (Hermes rodando no host, container alcança via host.docker.internal).' + value: 'http://host.docker.internal:9877' + locked: false +- name: CAPTAIN_HERMES_GATEWAY_MODEL + display_title: 'Captain Hermes Gateway Model' + description: 'Modelo a passar pro Hermes Gateway no formato /. Default: anthropic/claude-opus-4-5. O Hermes faz o roteamento real e pode usar Codex/Anthropic/Gemini conforme config local em ~/.hermes/config.yaml.' + value: 'anthropic/claude-opus-4-5' + locked: false +- name: CAPTAIN_HERMES_GATEWAY_API_KEY + display_title: 'Captain Hermes Gateway API Key (optional)' + description: 'API key opcional pro Hermes Gateway. Geralmente vazio (gateway local não exige auth). Se setado, vai no Authorization header das requisições do Captain pro Hermes.' + locked: false + type: secret - name: CAPTAIN_OPEN_AI_API_KEY display_title: 'OpenAI API Key' description: 'The API key used to authenticate requests to OpenAI services for Captain AI.' diff --git a/enterprise/app/services/captain/llm/provider_config.rb b/enterprise/app/services/captain/llm/provider_config.rb index ba84be7c3..f3004187a 100644 --- a/enterprise/app/services/captain/llm/provider_config.rb +++ b/enterprise/app/services/captain/llm/provider_config.rb @@ -9,9 +9,16 @@ # (CAPTAIN_CODEX_PROXY_URL, default http://localhost:3000/codex) e usa uma # api_key dummy — o proxy ignora o Authorization header e usa OAuth interno. # +# - openai_hermes_gateway: aponta para o Hermes Agent rodando em modo gateway +# (CAPTAIN_HERMES_GATEWAY_URL, default http://host.docker.internal:9877). +# O Hermes Gateway expõe API HTTP compatível com OpenAI e roteia internamente +# pra Codex/Anthropic/Gemini conforme sua config local em ~/.hermes/config.yaml. +# Auth: usa CAPTAIN_HERMES_GATEWAY_API_KEY se setado, senão dummy (gateway local). +# # O "legacy" ruby-openai usado para PDF/Files API NÃO deve usar esse módulo: -# o endpoint Codex não expõe Files API, então esses serviços continuam -# apontando sempre para OpenAI tradicional. +# o endpoint Codex/Hermes não expõe Files API nem /embeddings, então esses +# serviços continuam apontando sempre para OpenAI tradicional via +# legacy_openai_settings. class Captain::Llm::ProviderConfig DEFAULT_MODEL = 'gpt-4.1-mini'.freeze DEFAULT_OPENAI_ENDPOINT = 'https://api.openai.com'.freeze @@ -23,12 +30,20 @@ class Captain::Llm::ProviderConfig # endpoint Codex da OpenAI via ChatGPT Plus. DEFAULT_CODEX_MODEL = 'gpt-5.2'.freeze + # Hermes Gateway: defaults para o setup standard do Hermes Agent rodando + # como gateway HTTP local. O gateway escuta em 0.0.0.0:9877 por padrão e + # aceita o nome do modelo no formato `/`. + DEFAULT_HERMES_GATEWAY_URL = 'http://host.docker.internal:9877'.freeze + DEFAULT_HERMES_GATEWAY_MODEL = 'anthropic/claude-opus-4-5'.freeze + HERMES_GATEWAY_DUMMY_KEY = 'hermes-gateway'.freeze + # Modelo leve pra tasks de background (extração de memória, verificação de - # contradição, traduções internas). Quando usamos Codex, reutilizamos o - # mesmo modelo do chat — o endpoint não expõe gpt-4o-mini. + # contradição, traduções internas). Quando usamos Codex/Hermes, reutilizamos + # o mesmo modelo do chat — esses endpoints não expõem gpt-4o-mini. LIGHT_MODEL_DEFAULTS = { 'openai_api' => 'gpt-4o-mini', - 'openai_codex_oauth' => DEFAULT_CODEX_MODEL + 'openai_codex_oauth' => DEFAULT_CODEX_MODEL, + 'openai_hermes_gateway' => DEFAULT_HERMES_GATEWAY_MODEL }.freeze class << self @@ -40,12 +55,16 @@ class Captain::Llm::ProviderConfig provider == 'openai_codex_oauth' end + def hermes_gateway? + provider == 'openai_hermes_gateway' + end + # Retorna { api_key:, api_base:, model: } para RubyLLM/Agents. def settings - if codex_oauth? - codex_settings - else - openai_api_settings + case provider + when 'openai_codex_oauth' then codex_settings + when 'openai_hermes_gateway' then hermes_gateway_settings + else openai_api_settings end end @@ -88,6 +107,14 @@ class Captain::Llm::ProviderConfig } end + def hermes_gateway_settings + { + api_key: cfg('CAPTAIN_HERMES_GATEWAY_API_KEY').presence || HERMES_GATEWAY_DUMMY_KEY, + api_base: (cfg('CAPTAIN_HERMES_GATEWAY_URL').presence || DEFAULT_HERMES_GATEWAY_URL).chomp('/'), + model: cfg('CAPTAIN_HERMES_GATEWAY_MODEL').presence || DEFAULT_HERMES_GATEWAY_MODEL + } + end + def openai_api_settings { api_key: cfg('CAPTAIN_OPEN_AI_API_KEY'), diff --git a/spec/enterprise/services/captain/llm/provider_config_spec.rb b/spec/enterprise/services/captain/llm/provider_config_spec.rb index 5e9ee518e..45dbd463e 100644 --- a/spec/enterprise/services/captain/llm/provider_config_spec.rb +++ b/spec/enterprise/services/captain/llm/provider_config_spec.rb @@ -63,5 +63,72 @@ RSpec.describe Captain::Llm::ProviderConfig do expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_CODEX_PROXY_URL) end end + + context 'when provider is openai_hermes_gateway' do + before do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway') + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_URL', value: 'http://host.docker.internal:9877') + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL', value: 'anthropic/claude-opus-4-5') + end + + it 'returns the gateway URL with dummy api_key when no key is configured' do + settings = described_class.settings + expect(settings[:api_key]).to eq(described_class::HERMES_GATEWAY_DUMMY_KEY) + expect(settings[:api_base]).to eq('http://host.docker.internal:9877') + expect(settings[:model]).to eq('anthropic/claude-opus-4-5') + end + + it 'honors CAPTAIN_HERMES_GATEWAY_API_KEY when present' do + InstallationConfig.create!(name: 'CAPTAIN_HERMES_GATEWAY_API_KEY', value: 'sk-hermes-real') + expect(described_class.settings[:api_key]).to eq('sk-hermes-real') + end + + it 'honors a custom CAPTAIN_HERMES_GATEWAY_MODEL value' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').update!(value: 'openai/gpt-5.4') + expect(described_class.settings[:model]).to eq('openai/gpt-5.4') + end + + it 'reports hermes_gateway? as true and codex_oauth? as false' do + expect(described_class.hermes_gateway?).to be true + expect(described_class.codex_oauth?).to be false + end + + it 'strips trailing slash from gateway URL' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').update!(value: 'http://host.docker.internal:9877/') + expect(described_class.settings[:api_base]).to eq('http://host.docker.internal:9877') + end + + it 'uses default model when CAPTAIN_HERMES_GATEWAY_MODEL is missing' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_MODEL').delete + expect(described_class.settings[:model]).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL) + end + + it 'uses default URL when CAPTAIN_HERMES_GATEWAY_URL is missing' do + InstallationConfig.find_by!(name: 'CAPTAIN_HERMES_GATEWAY_URL').delete + expect(described_class.settings[:api_base]).to eq(described_class::DEFAULT_HERMES_GATEWAY_URL) + end + end + + context 'when default provider (openai_api) is in use' do + it 'reports hermes_gateway? as false' do + expect(described_class.hermes_gateway?).to be false + end + end + + describe '.light_model' do + it 'returns gpt-4o-mini for openai_api' do + expect(described_class.light_model).to eq('gpt-4o-mini') + end + + it 'returns DEFAULT_CODEX_MODEL for openai_codex_oauth' do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_codex_oauth') + expect(described_class.light_model).to eq(described_class::DEFAULT_CODEX_MODEL) + end + + it 'returns DEFAULT_HERMES_GATEWAY_MODEL for openai_hermes_gateway' do + InstallationConfig.create!(name: 'CAPTAIN_LLM_PROVIDER', value: 'openai_hermes_gateway') + expect(described_class.light_model).to eq(described_class::DEFAULT_HERMES_GATEWAY_MODEL) + end + end end end From 89b471831d6ff1ff9a0a711663c115799f66f3da Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 13:13:15 -0300 Subject: [PATCH 02/63] feat(hermes): plugin captain-http-callback (HTTP delivery adapter) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adiciona plugin externo pro Hermes Agent que entrega a resposta do agente como POST HTTP a uma URL configurável — em vez de empurrar pra plataforma de mensageria (Telegram, Slack, etc) como o Hermes faz por default. Por quê: O Hermes nativamente entrega respostas em plataformas conhecidas. Quando integramos o Hermes como cérebro de outro backend (Captain / Chatwoot), precisamos da resposta de volta via HTTP pro backend continuar o fluxo (mandar pro cliente WhatsApp, atualizar conversa, etc). O Hermes não tem deliver type "http_callback" built-in, então criamos via API de plugin oficial deles (kind: platform). Arquivos: - plugin.yaml — manifest (kind=platform) - __init__.py — entrypoint (re-exporta register) - adapter.py — HttpCallbackAdapter implementando BasePlatformAdapter - README.md — uso, formato do POST, signing HMAC opcional Como funciona: 1. Backend (Captain) → POST /webhooks/ no Hermes (entrada) 2. Hermes processa via Codex/Anthropic/Gemini conforme config dele 3. Hermes invoca este plugin (deliver=http_callback) 4. Plugin POSTa resposta na URL configurada via --deliver-chat-id 5. Backend recebe e roteia pro destinatário real Validado end-to-end no Hermes da VPS com: - Subscription criada via `hermes webhook subscribe ... --deliver http_callback` - POST simulando msg do cliente → resposta chegou no servidor de teste em ~11s (tempo de processamento via subscription Codex) - Plugin enabled e descoberto via `hermes plugins list` Próximo passo (separado, em outro PR): cliente Captain (outgoing + incoming endpoint) que conecta o Captain ao Hermes via este plugin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../captain-http-callback/README.md | 76 ++++++ .../captain-http-callback/__init__.py | 3 + .../captain-http-callback/adapter.py | 216 ++++++++++++++++++ .../captain-http-callback/plugin.yaml | 14 ++ 4 files changed, 309 insertions(+) create mode 100644 hermes-plugins/captain-http-callback/README.md create mode 100644 hermes-plugins/captain-http-callback/__init__.py create mode 100644 hermes-plugins/captain-http-callback/adapter.py create mode 100644 hermes-plugins/captain-http-callback/plugin.yaml diff --git a/hermes-plugins/captain-http-callback/README.md b/hermes-plugins/captain-http-callback/README.md new file mode 100644 index 000000000..5b5b4d259 --- /dev/null +++ b/hermes-plugins/captain-http-callback/README.md @@ -0,0 +1,76 @@ +# captain-http-callback + +Hermes Agent platform plugin que entrega a resposta do agente como `POST` HTTP a uma URL configurável. + +## Por que existe + +O Hermes nativamente entrega respostas em plataformas conhecidas (Telegram, Slack, Discord, WhatsApp, etc). Quando integramos o Hermes como cérebro de outro backend (no nosso caso o **Captain / Chatwoot**), precisamos da resposta de volta via HTTP para o backend continuar o fluxo (mandar pro cliente WhatsApp, atualizar conversa, etc) — esse plugin fornece essa ponte. + +## Fluxo + +``` +1. Captain → POST /webhooks/ no Hermes (entrada da mensagem do cliente) +2. Hermes processa via LLM (Codex/Anthropic/Gemini conforme config dele) +3. Hermes invoca este plugin com a resposta gerada +4. Plugin → POST com a resposta +5. Captain recebe, identifica conversa, manda pro WhatsApp +``` + +## Instalação + +Plugin é discovered automaticamente quando colocado em `~/.hermes/plugins/captain-http-callback/`. Após copiar os arquivos, reinicie o gateway: + +```bash +pkill -f "hermes.*gateway" && sleep 2 +nohup hermes gateway run --replace > /var/log/hermes-gateway.log 2>&1 & +``` + +Verifique: + +```bash +hermes plugins list | grep http_callback +``` + +## Uso + +Cria webhook subscription apontando para este deliver type: + +```bash +hermes webhook subscribe minha-rota \ + --prompt "Cliente disse: {message}. Responda como ..." \ + --deliver http_callback \ + --deliver-chat-id "https://seu-backend.example/api/hermes_callback" +``` + +`--deliver-chat-id` é interpretado como a **URL de callback**. Quando o agente terminar de processar, este plugin POSTa nessa URL. + +## Formato do POST de callback + +```http +POST HTTP/1.1 +Content-Type: application/json; charset=utf-8 +X-Hermes-Callback-Signature: sha256= (opcional, se signing_secret configurado) + +{ + "content": "", + "reply_to": "", + "metadata": { ... }, + "timestamp": +} +``` + +## Assinatura HMAC opcional + +Define a env var `CAPTAIN_HTTP_CALLBACK_SECRET` em `~/.hermes/.env` (ou no shell do Hermes). Quando set, o plugin assina cada POST com `HMAC-SHA256(secret, body)` no header `X-Hermes-Callback-Signature`. O backend valida a assinatura antes de processar. + +```bash +# em ~/.hermes/.env +CAPTAIN_HTTP_CALLBACK_SECRET= +``` + +## Limitações + +- **Send-only.** Não recebe mensagens. A entrada da conversa precisa vir via outro adapter (geralmente o `webhook` adapter built-in). +- **Resposta esperada:** o backend precisa aceitar `POST` JSON e responder `2xx`. Plugin loga warning em qualquer status `>= 300` e retorna falha pro Hermes. +- **Timeout default:** 15s. Configurável via `extra.timeout_seconds` no `config.yaml`. +- **Sem retry built-in.** Se o backend retornar erro, é falha — o Hermes pode escolher logar e seguir. Adicione retry no backend caller se precisar. diff --git a/hermes-plugins/captain-http-callback/__init__.py b/hermes-plugins/captain-http-callback/__init__.py new file mode 100644 index 000000000..d4f1d7bf0 --- /dev/null +++ b/hermes-plugins/captain-http-callback/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/hermes-plugins/captain-http-callback/adapter.py b/hermes-plugins/captain-http-callback/adapter.py new file mode 100644 index 000000000..b3be1c039 --- /dev/null +++ b/hermes-plugins/captain-http-callback/adapter.py @@ -0,0 +1,216 @@ +"""HTTP Callback Platform Adapter for Hermes Agent. + +A plugin-based gateway adapter that delivers the agent response as an HTTP +POST to a configurable URL — instead of pushing it to a chat platform. + +Designed for backend integrations (e.g. Captain / Chatwoot ↔ Hermes) where +the response must be returned synchronously to a known service endpoint. + +Usage: + + hermes webhook subscribe my-route \\ + --deliver http_callback \\ + --deliver-chat-id "http://your-backend/api/hermes_callback" + +The URL passed via ``--deliver-chat-id`` is what receives the POST when the +agent finishes processing the inbound webhook. + +Environment variables: + CAPTAIN_HTTP_CALLBACK_SECRET Optional HMAC-SHA256 signing key. When set, + each POST includes header + ``X-Hermes-Callback-Signature: sha256=`` + computed over the raw request body. +""" + +import asyncio +import hashlib +import hmac +import json +import logging +import os +import time +from typing import Any, AsyncIterator, Dict, Optional + +import aiohttp + +from gateway.config import Platform +from gateway.platforms.base import BasePlatformAdapter, MessageEvent, SendResult + +logger = logging.getLogger(__name__) + + +class HttpCallbackAdapter(BasePlatformAdapter): + """Send-only adapter that POSTs the agent response to an HTTP URL. + + Inbound message reception is NOT supported — this adapter is meant to + pair with the built-in ``webhook`` adapter (which receives requests and + invokes the agent). The flow is: + + external service -> POST /webhooks/ (webhook adapter) + -> agent processes + -> http_callback adapter POSTs response back + """ + + def __init__(self, config, **kwargs): + platform = Platform("http_callback") + super().__init__(config=config, platform=platform) + + extra = getattr(config, "extra", {}) or {} + self.signing_secret: str = ( + os.getenv("CAPTAIN_HTTP_CALLBACK_SECRET") + or extra.get("signing_secret", "") + ) + self.timeout_seconds: float = float(extra.get("timeout_seconds", 15)) + self._session: Optional[aiohttp.ClientSession] = None + + # ------------------------------------------------------------------ lifecycle + async def connect(self) -> bool: + """Open a reusable aiohttp session — no real network handshake.""" + if self._session is None or self._session.closed: + self._session = aiohttp.ClientSession( + timeout=aiohttp.ClientTimeout(total=self.timeout_seconds), + ) + logger.info( + "[http_callback] adapter ready (signing=%s, timeout=%.1fs)", + "yes" if self.signing_secret else "no", + self.timeout_seconds, + ) + return True + + async def disconnect(self) -> None: + if self._session is not None and not self._session.closed: + await self._session.close() + self._session = None + + # ------------------------------------------------------------------ send + async def send( + self, + chat_id: str, + content: str, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """POST the agent response to ``chat_id`` (interpreted as a URL).""" + url = (chat_id or "").strip() + if not url.startswith(("http://", "https://")): + return SendResult( + success=False, + error=f"Invalid callback URL: {url!r} (must start with http:// or https://)", + ) + + body: Dict[str, Any] = { + "content": content, + "reply_to": reply_to, + "metadata": metadata or {}, + "timestamp": int(time.time()), + } + body_bytes = json.dumps(body, ensure_ascii=False).encode("utf-8") + + headers = {"Content-Type": "application/json; charset=utf-8"} + if self.signing_secret: + sig = hmac.new( + self.signing_secret.encode("utf-8"), + body_bytes, + hashlib.sha256, + ).hexdigest() + headers["X-Hermes-Callback-Signature"] = f"sha256={sig}" + + if self._session is None or self._session.closed: + await self.connect() + + try: + async with self._session.post(url, data=body_bytes, headers=headers) as resp: + status = resp.status + resp_text = await resp.text() + if 200 <= status < 300: + logger.info("[http_callback] POST %s -> HTTP %d", url, status) + return SendResult( + success=True, + message_id=f"hcb-{int(time.time() * 1000)}", + ) + logger.warning( + "[http_callback] POST %s -> HTTP %d (body[:200]=%r)", + url, + status, + resp_text[:200], + ) + return SendResult( + success=False, + error=f"HTTP {status}: {resp_text[:200]}", + ) + except asyncio.TimeoutError: + return SendResult( + success=False, + error=f"Timeout after {self.timeout_seconds}s", + ) + except aiohttp.ClientError as e: + logger.exception("[http_callback] aiohttp error for %s", url) + return SendResult(success=False, error=f"{type(e).__name__}: {e}") + except Exception as e: # pylint: disable=broad-except + logger.exception("[http_callback] unexpected error for %s", url) + return SendResult(success=False, error=f"{type(e).__name__}: {e}") + + # ------------------------------------------------------------------ no-op + async def send_typing(self, chat_id: str, metadata=None) -> None: # noqa: D401 + """No-op — HTTP callback has no typing indicator.""" + return None + + async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: + return { + "chat_id": chat_id, + "platform": "http_callback", + "type": "http_callback", + } + + async def receive_messages(self) -> AsyncIterator[MessageEvent]: + """Adapter is send-only — never yields events.""" + if False: # pragma: no cover - never executed; keeps async generator typing + yield # type: ignore[misc] + return + + +# --------------------------------------------------------------------------- +# Plugin registry hooks +# --------------------------------------------------------------------------- + +def check_requirements() -> bool: + """``aiohttp`` ships with Hermes — should always be importable.""" + try: + import aiohttp # noqa: F401 + return True + except ImportError: + return False + + +def validate_config(config) -> bool: # pragma: no cover - trivial + """No global config required — URL comes per-subscription via chat_id.""" + return True + + +def is_connected(config) -> bool: # pragma: no cover - trivial + """Always considered ready (no persistent connection).""" + return True + + +def register(ctx) -> None: + """Plugin entry point invoked by Hermes during plugin discovery.""" + ctx.register_platform( + name="http_callback", + label="HTTP Callback", + adapter_factory=lambda cfg: HttpCallbackAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=[], + install_hint="aiohttp (bundled with Hermes — no extra install needed)", + emoji="🔗", + pii_safe=True, + allow_update_command=False, + platform_hint=( + "Você está respondendo via callback HTTP a um sistema backend. " + "Sua resposta será enviada por POST JSON a um endpoint configurado " + "pelo desenvolvedor — não vai para um chat humano direto. Mantenha " + "o formato natural como se fosse pro cliente final; o backend " + "encaminha sua resposta ao destinatário real." + ), + ) diff --git a/hermes-plugins/captain-http-callback/plugin.yaml b/hermes-plugins/captain-http-callback/plugin.yaml new file mode 100644 index 000000000..b89dd61a8 --- /dev/null +++ b/hermes-plugins/captain-http-callback/plugin.yaml @@ -0,0 +1,14 @@ +name: captain-http-callback +kind: platform +version: 0.1.0 +description: > + HTTP callback platform adapter for Hermes Agent. + POSTs the agent response back to a configurable URL (passed as + --deliver-chat-id when creating the webhook subscription). + Optional HMAC-SHA256 signing via env var CAPTAIN_HTTP_CALLBACK_SECRET. + + Use case: integrate Hermes as the LLM/agent brain behind another + backend (e.g. Captain / Chatwoot) where the agent response must be + delivered synchronously to a known service endpoint, rather than to + a chat platform like Telegram or Slack. +author: Captain (fazer.ai) From 35de8b7fdefc14b62d0a4d6bad7bbef13dd2b283 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 13:22:22 -0300 Subject: [PATCH 03/63] =?UTF-8?q?feat(captain):=20cliente=20Captain=20?= =?UTF-8?q?=E2=86=94=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 From d781f4a0487e6904d9c73674334d1f028a005518 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 15:16:05 -0300 Subject: [PATCH 04/63] feat(hermes): plugin captain-webhook (stable session_chat_id) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugin pro Hermes Agent que SUBSTITUI o WebhookAdapter built-in pra suportar session_chat_id estável derivado de campo no payload. Por que existe -------------- O WebhookAdapter built-in monta a chave de sessão como: session_chat_id = f"webhook:{route}:{delivery_id}" delivery_id é único por POST → cada msg cria sessão nova no Hermes. OK pra webhooks one-shot, ERRADO pra integração de chat onde múltiplas mensagens da mesma conversa precisam compartilhar memória de sessão. Como funciona ------------- Quando o caller (Captain) inclui `conversation_id` ou `hermes_session_id` no payload, o plugin reescreve chat_id pra: session_chat_id = f"webhook:{route}:session:{conversation_id}" Mesma conversation_id em múltiplas POSTs → mesma sessão Hermes → contexto e memória preservados. Sem o campo, fallback ao comportamento default (session nova por POST). 100% backward-compatible. Implementação ------------- - kind: platform — registra com name="webhook" pra substituir built-in (Hermes prioriza platform_registry sobre código built-in em gateway/run.py:_create_adapter) - Herda WebhookAdapter — só override `handle_message` (rewrite chat_id) e `connect` (recupera gateway_runner via _gateway_runner_ref pq o plugin path não seta isso explicitamente) - Outros adapters (HMAC, rate limit, idempotency, parsing, deliver dispatch) — herdados sem cópia Validado end-to-end na VPS (profile valentina): - POST com conversation_id=99999 (msg 1) → session:99999 criada - POST com conversation_id=99999 (msg 2) → MESMA session reutilizada - Hermes responde via Codex em ~10s (2 turnos cumulativos) - http_callback faz POST de volta no Captain (HTTP 200) - Logs mostram: [captain-webhook] Stable session: ... -> session:99999 Combinado com captain-http-callback, completa o ciclo Captain ↔ Hermes: Captain manda webhook com conversation_id → Hermes processa em sessão estável → http_callback POSTa resposta de volta → Captain envia ao WhatsApp. Co-Authored-By: Claude Opus 4.7 (1M context) --- hermes-plugins/captain-webhook/README.md | 77 ++++++++ hermes-plugins/captain-webhook/__init__.py | 3 + hermes-plugins/captain-webhook/adapter.py | 198 +++++++++++++++++++++ hermes-plugins/captain-webhook/plugin.yaml | 28 +++ 4 files changed, 306 insertions(+) create mode 100644 hermes-plugins/captain-webhook/README.md create mode 100644 hermes-plugins/captain-webhook/__init__.py create mode 100644 hermes-plugins/captain-webhook/adapter.py create mode 100644 hermes-plugins/captain-webhook/plugin.yaml diff --git a/hermes-plugins/captain-webhook/README.md b/hermes-plugins/captain-webhook/README.md new file mode 100644 index 000000000..fd5c96795 --- /dev/null +++ b/hermes-plugins/captain-webhook/README.md @@ -0,0 +1,77 @@ +# captain-webhook + +Hermes Agent platform plugin que **substitui** o `WebhookAdapter` built-in pra suportar **session_chat_id estável** derivado de campo no payload. + +## Por que existe + +O webhook adapter built-in do Hermes monta a chave de sessão como: + +``` +session_chat_id = f"webhook:{route_name}:{delivery_id}" +``` + +`delivery_id` é único por POST → cada mensagem cria sessão nova no Hermes. Isso funciona pra webhooks one-shot (alertas, GitHub events), mas é **errado pra integração de chat** onde múltiplas mensagens da mesma conversa precisam compartilhar memória de sessão. + +Esse plugin permite que o caller (ex: Captain) inclua um identificador estável no payload — `conversation_id` (preferido) ou `hermes_session_id` — e o adapter reescreve a chave pra: + +``` +session_chat_id = f"webhook:{route_name}:session:{conversation_id}" +``` + +Mesmo `conversation_id` em múltiplas POSTs → mesma sessão Hermes → memória da conversa preservada. + +## Como funciona + +`CaptainWebhookAdapter` herda de `WebhookAdapter` built-in e faz **uma única override**: o método `handle_message()`. Ele: + +1. Recebe o `event` já montado pelo built-in +2. Lê `event.raw_message` (o payload JSON do webhook) +3. Se houver `hermes_session_id` ou `conversation_id`, monta novo `chat_id` +4. Mirror o `_delivery_info` pra nova chave (pra o `send()` posterior achar config) +5. Modifica `event.source.chat_id` +6. Chama `super().handle_message(event)` + +Toda outra lógica (HMAC, rate limit, idempotency, parsing JSON, signature validation, deliver dispatch) é herdada **sem cópia**. + +## Como o Hermes substitui o built-in + +`gateway/run.py`: +```python +# Plugin-registered platforms (checked first) +if platform_registry.is_registered(platform.value): + adapter = platform_registry.create_adapter(platform.value, config) + if adapter is not None: + return adapter +# Fall through to built-in adapters below +``` + +Se este plugin se registrar com `name="webhook"` (mesmo nome do built-in), `is_registered("webhook")` retorna `True` e o `CaptainWebhookAdapter` é usado em vez do built-in `WebhookAdapter`. + +## Instalação no profile do Hermes + +```bash +# Copia plugin pro profile +cp -r hermes-plugins/captain-webhook /root/.hermes/profiles//plugins/ + +# Ativa +HERMES_HOME=/root/.hermes/profiles/ hermes plugins enable captain-webhook + +# Reinicia gateway pra carregar +pkill -f "HERMES_HOME=/root/.hermes/profiles/" +HERMES_HOME=/root/.hermes/profiles/ nohup hermes gateway run --replace > /var/log/hermes-.log 2>&1 & +``` + +Verifica: +```bash +HERMES_HOME=/root/.hermes/profiles/ hermes plugins list | grep captain-webhook +``` + +## Backward compatibility + +Quando o payload **NÃO traz** `hermes_session_id` nem `conversation_id`, o adapter **não modifica nada** — comportamento idêntico ao built-in. Webhooks one-shot continuam funcionando normalmente. + +## Limitações + +- O plugin estende a versão do `WebhookAdapter` instalada no Hermes. Quando o Hermes for atualizado, é prudente revisar se a interface base mudou (signature do `handle_message`, formato do `chat_id`, etc). +- Não modifica o ciclo de idempotency: cada POST ainda precisa de `delivery_id` único (auto-gerado pelo Hermes ou via header `X-Request-ID`). +- Não persiste sessions entre restarts do Hermes — isso é responsabilidade do session store do próprio Hermes (SQLite por profile). diff --git a/hermes-plugins/captain-webhook/__init__.py b/hermes-plugins/captain-webhook/__init__.py new file mode 100644 index 000000000..d4f1d7bf0 --- /dev/null +++ b/hermes-plugins/captain-webhook/__init__.py @@ -0,0 +1,3 @@ +from .adapter import register + +__all__ = ["register"] diff --git a/hermes-plugins/captain-webhook/adapter.py b/hermes-plugins/captain-webhook/adapter.py new file mode 100644 index 000000000..ab7f712e4 --- /dev/null +++ b/hermes-plugins/captain-webhook/adapter.py @@ -0,0 +1,198 @@ +"""Captain Webhook Adapter — extends Hermes built-in WebhookAdapter to +support stable session_chat_id derived from a payload field. + +Why this exists +--------------- +The default ``WebhookAdapter`` builds the session key as:: + + session_chat_id = f"webhook:{route_name}:{delivery_id}" + +Since ``delivery_id`` is unique per HTTP POST, every webhook hit creates a +fresh Hermes session. That's correct behavior for one-shot webhook events +(GitHub PRs, monitoring alerts) but wrong for backend integrations where +many messages of the same logical conversation must share session memory. + +This plugin lets the caller include a stable identifier in the JSON +payload — typically ``conversation_id`` (preferred) or +``hermes_session_id`` — and the adapter rewrites the session key to:: + + session_chat_id = f"webhook:{route_name}:session:{conversation_id}" + +Same conversation_id across multiple POSTs → same session in Hermes → +session memory preserved. Different conversation_id (or none) → fresh +session, identical to the built-in. + +Idempotency (``delivery_id`` based) is unchanged: each POST still gets a +unique delivery_id, so retries are still de-duplicated. + +Implementation +-------------- +We register this plugin under the SAME platform name as the built-in +(``webhook``). Hermes' ``_create_adapter`` checks ``platform_registry`` +first and uses the plugin if registered, falling back to the built-in +only if nothing is registered. So registering ``name="webhook"`` here +substitutes the built-in completely. + +Behavior is inherited unchanged from ``WebhookAdapter`` (HMAC, rate +limiting, idempotency, signature validation, prompt rendering, deliver +dispatch). The only override is ``handle_message()``, which mutates +``event.source.chat_id`` before delegating to the parent. +""" + +import logging +from typing import Optional + +from gateway.platforms.base import MessageEvent +from gateway.platforms.webhook import WebhookAdapter + +logger = logging.getLogger(__name__) + + +class CaptainWebhookAdapter(WebhookAdapter): + """WebhookAdapter with stable session_chat_id derived from payload.""" + + SESSION_FIELD_PRIMARY = "hermes_session_id" + SESSION_FIELD_FALLBACK = "conversation_id" + + async def connect(self) -> bool: + # Hermes' built-in webhook adapter is the only platform that gets + # `gateway_runner` set explicitly in `_create_adapter()` (see + # gateway/run.py around `Platform.WEBHOOK`). When a plugin replaces + # the built-in via `platform_registry`, that explicit assignment is + # skipped — leaving `self.gateway_runner` unset and breaking + # cross-platform delivery (e.g. forwarding the response to a sibling + # plugin like `http_callback`). + # + # Recover by pulling the live runner from the module-level + # `_gateway_runner_ref` weakref that GatewayRunner.__init__ populates. + if not getattr(self, "gateway_runner", None): + try: + from gateway.run import _gateway_runner_ref + runner = _gateway_runner_ref() + if runner is not None: + self.gateway_runner = runner + logger.info( + "[captain-webhook] gateway_runner linked via _gateway_runner_ref" + ) + else: + logger.warning( + "[captain-webhook] _gateway_runner_ref() returned None — " + "cross-platform delivery (http_callback etc) will fail" + ) + except Exception as exc: # pragma: no cover - defensive + logger.warning( + "[captain-webhook] Could not link gateway_runner: %s", exc + ) + return await super().connect() + + async def handle_message(self, event: MessageEvent) -> None: + custom_session = self._extract_custom_session_id(event) + if custom_session: + self._rewrite_chat_id(event, custom_session) + return await super().handle_message(event) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _extract_custom_session_id(self, event: MessageEvent) -> Optional[str]: + payload = event.raw_message + if not isinstance(payload, dict): + return None + value = payload.get(self.SESSION_FIELD_PRIMARY) or payload.get( + self.SESSION_FIELD_FALLBACK + ) + if value is None: + return None + return str(value).strip() or None + + def _rewrite_chat_id(self, event: MessageEvent, custom_session: str) -> None: + old_chat_id = event.source.chat_id + # Default chat_id format from base WebhookAdapter: + # "webhook::" + # Extract route_name to keep the new key consistent with that scheme. + try: + prefix, route_name, _ = old_chat_id.split(":", 2) + except ValueError: + logger.warning( + "[captain-webhook] Unexpected chat_id format %r — skipping rewrite", + old_chat_id, + ) + return + + new_chat_id = f"{prefix}:{route_name}:session:{custom_session}" + if new_chat_id == old_chat_id: + return + + # The base adapter stores delivery info under the original chat_id so + # that send() can locate the deliver target/payload when the agent + # finishes. Mirror the entry under the new chat_id so the rewrite + # doesn't break delivery routing. + delivery_config = self._delivery_info.get(old_chat_id) + if delivery_config is not None: + self._delivery_info[new_chat_id] = delivery_config + self._delivery_info_created[new_chat_id] = ( + self._delivery_info_created.get(old_chat_id, 0) + ) + + # SessionSource and MessageEvent are non-frozen dataclasses — safe to mutate. + event.source.chat_id = new_chat_id + + logger.info( + "[captain-webhook] Stable session: %s -> %s (custom=%s)", + old_chat_id, + new_chat_id, + custom_session, + ) + + +# --------------------------------------------------------------------------- +# Plugin registry hooks +# --------------------------------------------------------------------------- + +def check_requirements() -> bool: + """Built-in WebhookAdapter must be importable (it always is in Hermes).""" + try: + from gateway.platforms.webhook import WebhookAdapter # noqa: F401 + return True + except ImportError: + return False + + +def validate_config(config) -> bool: # pragma: no cover - trivial passthrough + """All built-in webhook config is valid for this adapter as well.""" + return True + + +def is_connected(config) -> bool: # pragma: no cover - trivial passthrough + """Always considered ready when the platform block is enabled.""" + extra = getattr(config, "extra", {}) or {} + return bool(extra.get("enabled", True)) + + +def register(ctx) -> None: + """Plugin entry point. + + Registers under name="webhook" — same as the built-in — so Hermes' + ``_create_adapter`` picks this adapter instead of the built-in + (registry is checked before the if/elif chain). + """ + ctx.register_platform( + name="webhook", + label="Captain Webhook (stable sessions)", + adapter_factory=lambda cfg: CaptainWebhookAdapter(cfg), + check_fn=check_requirements, + validate_config=validate_config, + is_connected=is_connected, + required_env=[], + install_hint="Inherits from built-in WebhookAdapter — no extra deps.", + emoji="🪝", + pii_safe=True, + allow_update_command=False, + platform_hint=( + "Webhook platform with stable session_chat_id when payload " + "carries 'conversation_id' or 'hermes_session_id'. Keeps the " + "same Hermes session across multiple HTTP POSTs of the same " + "logical conversation." + ), + ) diff --git a/hermes-plugins/captain-webhook/plugin.yaml b/hermes-plugins/captain-webhook/plugin.yaml new file mode 100644 index 000000000..03a4a0a35 --- /dev/null +++ b/hermes-plugins/captain-webhook/plugin.yaml @@ -0,0 +1,28 @@ +name: captain-webhook +kind: platform +version: 0.1.0 +description: > + Drop-in replacement for the built-in `webhook` platform adapter that + supports stable session_chat_id via payload field. + + The default Hermes WebhookAdapter creates a fresh session per POST + (session_chat_id = "webhook::" — and delivery_id is + unique per request). For backend integrations like Captain ↔ Hermes + where multiple messages of the same conversation must share session + memory, the caller can include `conversation_id` (or `hermes_session_id`) + in the payload — this adapter constructs: + + session_chat_id = "webhook::session:" + + keeping the Hermes session continuous across messages of the same + conversation. Idempotency (delivery_id) remains unchanged. + + When the payload has neither `conversation_id` nor `hermes_session_id`, + behavior is identical to the built-in adapter (every msg is fresh + session). 100% backward-compatible. + + Inheritance-only override: this plugin extends WebhookAdapter and only + overrides handle_message() to rewrite event.source.chat_id when the + payload carries a stable session id. All other behavior (HMAC, rate + limiting, idempotency, parsing, deliver) is inherited unchanged. +author: Captain (fazer.ai) From cd519a73c401a4b0bca524051b1f2bf572fde082 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 15:24:57 -0300 Subject: [PATCH 05/63] fix(captain): converte markdown bold pra formato WhatsApp no callback Hermes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes (e LLMs default em geral) emitem **negrito** no formato markdown padrão. WhatsApp usa formato próprio: *negrito* (single asterisk). Sem conversão, o cliente vê asteriscos literais no WhatsApp, parecendo bug. Defesa em camadas: 1. SOUL.md da Valentina foi atualizado com regra explícita de formato WhatsApp (single asterisk pra bold, underscore pra itálico, etc). 2. Este controller faz normalização defensiva no callback recebido do Hermes: regex `**texto**` -> `*texto*` antes de criar a mensagem outgoing. Não afeta o resto do conteúdo. normalize_for_whatsapp() é trivialmente reversível e idempotente (executar 2x é igual a 1x). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../captain/hermes_callback_controller.rb | 27 +++++++++++++++---- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb index 563dff60c..cad7174c5 100644 --- a/app/controllers/webhooks/captain/hermes_callback_controller.rb +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -21,16 +21,13 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController before_action :fetch_inbox def process_payload - content = params[:content].to_s.strip + content = extract_content 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)" - ) - + log_reply(conversation, content) create_outgoing_message(conversation, content) head :ok rescue StandardError => e @@ -73,6 +70,26 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController 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) From 23911ea878f5308945632bce2afd260a31f87e7b Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 15:32:38 -0300 Subject: [PATCH 06/63] feat(captain): MCP server (HTTP) expondo tools pro Hermes Agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implementa servidor MCP (Model Context Protocol) HTTP no Captain pra o Hermes Agent invocar tools do Captain via `hermes mcp add`. Substrato pra integração de Nível 2 onde o agente consulta tools quando precisa executar ações reais (buscar FAQ, adicionar label, futuramente Pix etc). Arquivos: - app/controllers/webhooks/captain/mcp_controller.rb Endpoint POST /webhooks/captain/mcp. Valida HMAC (CAPTAIN_MCP_SECRET), parseia JSON-RPC, despacha pro Server. Extrai params._captain_context com multi-tenant ids (conversation_id, inbox_id, account_id, etc). - enterprise/app/services/captain/mcp/server.rb Subset MCP suficiente: initialize, tools/list, tools/call, ping, notifications/initialized. JSON-RPC síncrono (sem SSE). - enterprise/app/services/captain/mcp/tool_registry.rb Lista centralizada de tools. - enterprise/app/services/captain/mcp/tools/base_tool.rb Interface base pras tools (.name, .description, .input_schema, #call). - enterprise/app/services/captain/mcp/tools/add_label_tool.rb Tool 1: aplica label na conversation atual. - enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb Tool 2: busca semântica em FAQs (Captain::AssistantResponse via pgvector cosine). Reaproveita SearchReplyDocumentationService pra paridade com o caminho legado do Captain. - config/routes.rb Rota POST /webhooks/captain/mcp. Conexão pelo Hermes: hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp Auth: HMAC X-Hub-Signature-256 quando CAPTAIN_MCP_SECRET setado. TODO próxima sprint: generate_pix_tool, send_suite_images_tool. Handoff fica via automation hoje (UI Chatwoot). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../webhooks/captain/mcp_controller.rb | 72 ++++++++++++++ config/routes.rb | 1 + enterprise/app/services/captain/mcp/server.rb | 96 +++++++++++++++++++ .../app/services/captain/mcp/tool_registry.rb | 35 +++++++ .../captain/mcp/tools/add_label_tool.rb | 57 +++++++++++ .../services/captain/mcp/tools/base_tool.rb | 56 +++++++++++ .../captain/mcp/tools/faq_lookup_tool.rb | 76 +++++++++++++++ 7 files changed, 393 insertions(+) create mode 100644 app/controllers/webhooks/captain/mcp_controller.rb create mode 100644 enterprise/app/services/captain/mcp/server.rb create mode 100644 enterprise/app/services/captain/mcp/tool_registry.rb create mode 100644 enterprise/app/services/captain/mcp/tools/add_label_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/base_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb new file mode 100644 index 000000000..3e6b18afc --- /dev/null +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -0,0 +1,72 @@ +# Endpoint MCP (Model Context Protocol) HTTP do Captain. +# +# POST /webhooks/captain/mcp +# +# Hermes Agent (e qualquer cliente MCP) conecta aqui pra invocar tools do +# Captain (add_label, faq_lookup, generate_pix, etc). +# +# Conexão pelo Hermes: +# hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp +# +# Auth: HMAC-SHA256 do body via header `X-Hub-Signature-256`, secret +# compartilhado via env var `CAPTAIN_MCP_SECRET` (igual ao padrão de +# `hermes_callback`). Quando vazio, validação é desabilitada (PoC/dev). +# +# Multi-tenant: o cliente MCP pode mandar contexto (conversation_id, +# inbox_id, account_id) num campo de extensão chamado `_captain_context` +# dentro de `params` do JSON-RPC. Tools que precisam (add_label etc) leem +# esse contexto pra resolver a conversa correta. +class Webhooks::Captain::McpController < ApplicationController + skip_before_action :verify_authenticity_token, raise: false + before_action :verify_signature + + def process_payload + request_body = parse_request_body + return head :bad_request if request_body.blank? + + response = Captain::Mcp::Server.handle( + request_body, + context: extract_context(request_body) + ) + + return head :ok if response.nil? # MCP notifications + + render json: response + rescue StandardError => e + Rails.logger.error "[Captain::Mcp] error: #{e.class}: #{e.message}" + Rails.logger.error e.backtrace.first(5).join("\n") + render json: { jsonrpc: '2.0', error: { code: -32_603, message: 'Internal error' } }, status: :internal_server_error + end + + private + + def parse_request_body + JSON.parse(request.raw_post) + rescue JSON::ParserError + nil + end + + def verify_signature + secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil) + return true if secret.blank? + + signature = request.headers['X-Hub-Signature-256'].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 + + # Cliente MCP pode mandar contexto multi-tenant em params._captain_context. + # Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation + # é (já que MCP em si é stateless entre client/server). + def extract_context(request_body) + params = request_body['params'] || {} + ctx = params['_captain_context'] || {} + return {} unless ctx.is_a?(Hash) + + ctx.symbolize_keys + end +end diff --git a/config/routes.rb b/config/routes.rb index 680f1ca87..1fd35cb52 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -638,6 +638,7 @@ Rails.application.routes.draw do 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' + post 'webhooks/captain/mcp', to: 'webhooks/captain/mcp#process_payload' namespace :twitter do resource :callback, only: [:show] diff --git a/enterprise/app/services/captain/mcp/server.rb b/enterprise/app/services/captain/mcp/server.rb new file mode 100644 index 000000000..b81882f58 --- /dev/null +++ b/enterprise/app/services/captain/mcp/server.rb @@ -0,0 +1,96 @@ +# Servidor MCP (Model Context Protocol) HTTP do Captain. +# +# Implementa subset suficiente da spec MCP pra Hermes Agent consumir como +# cliente via `hermes mcp add captain-tools --url `. Métodos +# implementados: +# - initialize — handshake (cliente apresenta info, server responde +# capabilities + serverInfo) +# - tools/list — devolve a lista de tools registradas +# - tools/call — executa uma tool específica e devolve o resultado +# - notifications/initialized — no-op (cliente confirma que está pronto) +# - ping — no-op (health check) +# +# Não suporta SSE/streaming ainda — modo POST/JSON síncrono basta pro +# caso de uso atual (tools que retornam rápido como add_label, faq_lookup). +# +# Auth/segurança ficam no controller (HMAC), aqui só roteia. +class Captain::Mcp::Server + PROTOCOL_VERSION = '2024-11-05'.freeze + SERVER_NAME = 'captain-mcp'.freeze + SERVER_VERSION = '0.1.0'.freeze + + class << self + def handle(request, context: {}) + new(context: context).handle(request) + end + end + + def initialize(context: {}) + @context = context || {} + end + + def handle(request) + rid = request['id'] + method = request['method'].to_s + params = request['params'] || {} + + dispatch(method, rid, params) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::Server] error handling #{method}: #{e.class}: #{e.message}") + error_response(rid, -32_603, "Internal error: #{e.message}") + end + + private + + def dispatch(method, rid, params) + case method + when 'initialize' then respond(rid, initialize_result(params)) + when 'tools/list' then respond(rid, { tools: Captain::Mcp::ToolRegistry.descriptors }) + when 'tools/call' then respond(rid, tools_call(params)) + when 'ping' then respond(rid, {}) + when 'notifications/initialized', 'notifications/cancelled' then nil + else + error_response(rid, -32_601, "Method not found: #{method}") + end + end + + attr_reader :context + + def initialize_result(_params) + { + protocolVersion: PROTOCOL_VERSION, + capabilities: { + tools: { listChanged: false } + }, + serverInfo: { + name: SERVER_NAME, + version: SERVER_VERSION + } + } + end + + def tools_call(params) + name = params['name'].to_s + args = params['arguments'] || {} + Captain::Mcp::ToolRegistry.call(name, args, context: context) + end + + def respond(id, result) + { + jsonrpc: '2.0', + id: id, + result: result + } + end + + def error_response(id, code, message) + { + jsonrpc: '2.0', + id: id, + error: { + code: code, + message: message + } + } + end +end diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb new file mode 100644 index 000000000..4922b63f1 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -0,0 +1,35 @@ +# Registry centralizado das tools MCP do Captain. +# +# Adicionar uma tool nova = incluir a classe em TOOLS abaixo. Cada tool +# herda de Captain::Mcp::Tools::BaseTool e responde a .to_mcp_descriptor +# (pra `tools/list`) e #call(args, context:) (pra `tools/call`). +# +# Hermes consulta tools/list pra saber o que pode chamar e tools/call pra +# executar. Toda tool aqui está disponível pra qualquer profile do Hermes +# que se conecte ao MCP server do Captain via `hermes mcp add`. +class Captain::Mcp::ToolRegistry + TOOLS = [ + Captain::Mcp::Tools::AddLabelTool, + Captain::Mcp::Tools::FaqLookupTool + # Captain::Mcp::Tools::GeneratePixTool — TODO depois MCP base validar + # Captain::Mcp::Tools::SendSuiteImagesTool — TODO depois MCP base validar + # Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro + ].freeze + + class << self + def descriptors + TOOLS.map(&:to_mcp_descriptor) + end + + def find(name) + TOOLS.find { |klass| klass.name == name.to_s } + end + + def call(name, args, context:) + klass = find(name) + raise ArgumentError, "Tool não registrada: #{name}" if klass.nil? + + klass.new.call(args || {}, context: context || {}) + end + end +end diff --git a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb new file mode 100644 index 000000000..ee06b90e6 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb @@ -0,0 +1,57 @@ +# Tool MCP: adiciona uma etiqueta na conversation atual. +# +# Caso de uso: Hermes detecta cliente recorrente / VIP / situação especial +# e quer marcar a conversa pro time humano filtrar depois. +# +# Exemplos de uso pelo LLM: +# - "marca como cliente_recorrente" +# - "etiqueta como pedido_desconto" +class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'add_label' + end + + def description + 'Adiciona uma etiqueta (label) à conversa atual do cliente. ' \ + 'Use pra marcar contexto importante: cliente_recorrente, pedido_desconto, ' \ + 'reclamacao, vip, etc. A etiqueta deve ser snake_case curto.' + end + + def input_schema + { + type: 'object', + properties: { + label: { + type: 'string', + description: 'Nome da etiqueta em snake_case (ex: "cliente_recorrente").' + } + }, + required: ['label'] + } + end + end + + def call(args, context:) + label = args['label'].to_s.strip + return error_response('Argumento "label" é obrigatório.') if label.blank? + + conversation = resolve_conversation(context) + return error_response('Conversation atual não encontrada no contexto.') if conversation.blank? + + conversation.add_labels([label]) + text_response("Etiqueta '#{label}' adicionada à conversa #{conversation.display_id}.") + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::AddLabelTool] error: #{e.class}: #{e.message}") + error_response("Falha ao adicionar etiqueta: #{e.message}") + end + + private + + def resolve_conversation(context) + conv_id = 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 +end diff --git a/enterprise/app/services/captain/mcp/tools/base_tool.rb b/enterprise/app/services/captain/mcp/tools/base_tool.rb new file mode 100644 index 000000000..2253c2ce0 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/base_tool.rb @@ -0,0 +1,56 @@ +# Interface base pras tools MCP do Captain. +# +# Cada tool concreta herda desta classe e implementa: +# - .name — identificador (snake_case) +# - .description — texto pro LLM decidir quando chamar +# - .input_schema — JSON Schema (Draft 2020-12) dos argumentos +# - #call(args, context:) — execução real +# +# context é um hash com metadata da invocação (ex: conversation_id, +# inbox_id, account_id) extraído do request MCP. Tools usam isso pra +# resolver entidades do Captain (Conversation, Inbox, etc). +class Captain::Mcp::Tools::BaseTool + class ExecutionError < StandardError; end + + class << self + def name + raise NotImplementedError, "#{self} must implement .name" + end + + def description + raise NotImplementedError, "#{self} must implement .description" + end + + def input_schema + raise NotImplementedError, "#{self} must implement .input_schema" + end + + def to_mcp_descriptor + { + name: name, + description: description, + inputSchema: input_schema + } + end + end + + def call(_args, context:) + raise NotImplementedError, "#{self.class} must implement #call" + end + + protected + + def text_response(text) + { + content: [{ type: 'text', text: text.to_s }], + isError: false + } + end + + def error_response(message) + { + content: [{ type: 'text', text: message.to_s }], + isError: true + } + end +end diff --git a/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb b/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb new file mode 100644 index 000000000..c03906433 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/faq_lookup_tool.rb @@ -0,0 +1,76 @@ +# Tool MCP: busca semântica em FAQs/documentação aprovada pelas gerentes. +# +# Caso de uso típico: cliente pergunta algo que NÃO está na skill estruturada +# do Hermes (ex: aceita pet, formas de pagamento alternativo, política de +# alguma situação específica). Em vez de inventar, Hermes chama esta tool +# e responde com base no FAQ atualizado em tempo real pelo Captain UI. +# +# Reaproveita Captain::Tools::SearchReplyDocumentationService — exatamente +# o mesmo serviço que o orquestrador interno do Captain usava antes, +# garantindo que Hermes vê os mesmos FAQs que o caminho legado veria. +class Captain::Mcp::Tools::FaqLookupTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'faq_lookup' + end + + def description + 'Busca semântica em FAQs/documentação aprovada pelas gerentes do hotel. ' \ + 'Use quando o cliente perguntar algo que NÃO está na sua skill ' \ + '(ex: política de pets, horários especiais, convênios, regras pontuais). ' \ + 'Retorna até 5 perguntas/respostas mais próximas semanticamente da query. ' \ + 'Se não encontrar nada relevante, prefira transferir pro humano em vez ' \ + 'de inventar.' + end + + def input_schema + { + type: 'object', + properties: { + query: { + type: 'string', + description: 'Pergunta ou tema a buscar em linguagem natural ' \ + '(ex: aceitam pets, estacionamento coberto, ' \ + 'forma de pagamento sem ser Pix).' + } + }, + required: ['query'] + } + end + end + + def call(args, context:) + query = args['query'].to_s.strip + return error_response('Argumento "query" é obrigatório.') if query.blank? + + account = resolve_account(context) + return error_response('Account não encontrada no contexto MCP.') if account.blank? + + assistant = resolve_assistant(context, account) + result = ::Captain::Tools::SearchReplyDocumentationService.new( + account: account, + assistant: assistant + ).execute(query: query) + + text_response(result.presence || 'Nenhum FAQ relevante encontrado pra essa pergunta.') + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::FaqLookupTool] error: #{e.class}: #{e.message}") + error_response("Falha na busca de FAQ: #{e.message}") + end + + private + + def resolve_account(context) + account_id = context[:account_id] + return nil if account_id.blank? + + Account.find_by(id: account_id) + end + + def resolve_assistant(context, account) + assistant_id = context[:assistant_id] + return nil if assistant_id.blank? + + account.captain_assistants.find_by(id: assistant_id) + end +end From 713bb16012df153cd5ab278fba8f08d7558c9aa3 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 15:42:57 -0300 Subject: [PATCH 07/63] =?UTF-8?q?feat(captain):=20MCP=20controller=20aceit?= =?UTF-8?q?a=20Bearer=20token=20al=C3=A9m=20de=20HMAC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes Agent (cliente MCP) usa `--auth header` que envia `Authorization: Bearer ` — padrão MCP. Antes o Captain MCP server só aceitava HMAC-SHA256 (X-Hub-Signature-256), incompatível com o que Hermes manda nativamente. Agora aceita qualquer um dos 2 modos: - Bearer token (recomendado, padrão MCP) — Hermes envia automaticamente - HMAC-SHA256 do body — pra clientes que preferem assinar payload Ambos validados com ActiveSupport::SecurityUtils.secure_compare contra o mesmo CAPTAIN_MCP_SECRET. Sem secret = validação desabilitada (PoC/dev). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../webhooks/captain/mcp_controller.rb | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 deletions(-) diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb index 3e6b18afc..c9d563354 100644 --- a/app/controllers/webhooks/captain/mcp_controller.rb +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -8,9 +8,13 @@ # Conexão pelo Hermes: # hermes mcp add captain-tools --url http://CAPTAIN_HOST/webhooks/captain/mcp # -# Auth: HMAC-SHA256 do body via header `X-Hub-Signature-256`, secret -# compartilhado via env var `CAPTAIN_MCP_SECRET` (igual ao padrão de -# `hermes_callback`). Quando vazio, validação é desabilitada (PoC/dev). +# Auth: aceita 2 modos (qualquer um basta): +# - Bearer token (padrão MCP, recomendado): `Authorization: Bearer ` +# É o que `hermes mcp add --auth header` usa nativamente. +# - HMAC-SHA256 do body: `X-Hub-Signature-256: sha256=` +# Para clientes que preferem assinar o body inteiro. +# Secret compartilhado via env var `CAPTAIN_MCP_SECRET`. Quando vazio, +# validação é desabilitada (PoC/dev). # # Multi-tenant: o cliente MCP pode mandar contexto (conversation_id, # inbox_id, account_id) num campo de extensão chamado `_captain_context` @@ -50,13 +54,26 @@ class Webhooks::Captain::McpController < ApplicationController secret = ENV.fetch('CAPTAIN_MCP_SECRET', nil) return true if secret.blank? + return true if bearer_token_matches?(secret) + return true if hmac_signature_matches?(secret) + + head :unauthorized + end + + def bearer_token_matches?(secret) + auth_header = request.headers['Authorization'].to_s + return false unless auth_header.start_with?('Bearer ') + + token = auth_header.delete_prefix('Bearer ').strip + ActiveSupport::SecurityUtils.secure_compare(token, secret) + end + + def hmac_signature_matches?(secret) signature = request.headers['X-Hub-Signature-256'].to_s - return head :unauthorized if signature.blank? + return false if signature.blank? expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, request.raw_post)}" - return head :unauthorized unless ActiveSupport::SecurityUtils.secure_compare(signature, expected) - - true + ActiveSupport::SecurityUtils.secure_compare(signature, expected) end # Cliente MCP pode mandar contexto multi-tenant em params._captain_context. From 8fab08ba57acfc95c803668048ea3ef9761ad2eb Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 15:52:09 -0300 Subject: [PATCH 08/63] =?UTF-8?q?fix(captain):=20regra=20de=20pessoa=20ext?= =?UTF-8?q?ra=20do=20Dolce=20Amore=20=E2=80=94=20taxa=20a=20partir=20da=20?= =?UTF-8?q?3=C2=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug reportado por cliente real: "valor extra não é a partir da 3ª pessoa? Porque aí é para casal, ou seja sempre vai ter duas pessoas". Cliente está certo. A base do quarto pra casal JÁ INCLUI 2 pessoas; taxa extra começa na 3ª pessoa, não na 2ª. A documentação anterior (modelo + skill Hermes + scenario 21 em prod) tinha "a partir da 2ª pessoa", o que fazia Valentina cobrar extra pra ambos do casal — erro. Mudanças: - Texto da regra: "a partir da 3ª pessoa" pra Apartamento/Suítes/Mini Chalé 45, com nota explícita "base do quarto já inclui 2 pessoas". - Exemplo de cálculo refeito: 4 pessoas Suíte Master pernoite sex/sáb = 180 + (2 × 45) = R$ 270 (era 180 + 3×45 = 315, errado). Categorias maiores (Chalé 2 Suítes, Suíte Ouro, Chalé Master 4 Suítes) mantidas como antes (4ª e 8ª pessoa) — Rodrigo precisa revisar essas separadamente já que cada uma tem capacidade diferente. Aplicado em 3 lugares: - db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md - /root/.hermes/profiles/valentina/skills/dolce-amore-reservas/SKILL.md - DB do Captain prod scenario 21 (via update_columns, +363 chars) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../scenarios/jasmine_dolce_amore__daniela_reservas.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md b/db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md index 091b30d9c..b3a454c62 100644 --- a/db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md +++ b/db/seed_prompts/_modelos/scenarios/jasmine_dolce_amore__daniela_reservas.md @@ -244,11 +244,16 @@ Também conta como intenção de reserva quando o cliente já dá dados concreto | Suíte Ouro | 230 | 340 | 440 | 830 | 30 | | Chalé Master 4 Suítes | 360 | 510 | 580 | 1.240 | 80 | -**Pessoa extra:** R$ 45,00 por pessoa adicional. Faixa varia por categoria: -- Apartamento, Suíte Master/Luxo/Temática, Mini Chalé 45 → cobra a partir da **2ª pessoa**. +**Pessoa extra:** R$ 45,00 por pessoa adicional. **A base do quarto JÁ INCLUI o casal (2 pessoas) — taxa extra começa na 3ª pessoa pra apartamento/suítes**. Faixa varia por categoria: +- Apartamento, Suíte Master/Luxo/Temática, Mini Chalé 45 → cobra a partir da **3ª pessoa** (2 pessoas já incluídas no valor base). - Chalé 2 Suítes e Suíte Ouro → cobra a partir da **4ª pessoa**. - Chalé Master 4 Suítes → cobra a partir da **8ª pessoa**. +**Exemplo de cálculo:** 4 pessoas na Suíte Master pernoite sex/sáb/feriado: +- Base da suíte: R$ 180 (já inclui 2 pessoas) +- Pessoas extras: 4 - 2 = 2 pessoas → 2 × R$ 45 = R$ 90 +- Total: R$ 180 + R$ 90 = **R$ 270** + **Hora excedente** (após o tempo contratado): - Apartamento: R$ 25/h - Suíte Master/Luxo/Temática: R$ 30/h From 2ade066468edd418fd21a815091275204d275543 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 16:08:18 -0300 Subject: [PATCH 09/63] feat(captain): EmbeddingService aceita provider de embedding dedicado MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Permite trocar provider de embedding sem afetar o provider de chat. Útil quando OpenAI key tradicional está fora (ban, billing, etc) mas você quer usar outro provider OpenAI-compatible só pra embeddings — exemplo clássico: Gemini OpenAI-compatible em https://generativelanguage.googleapis.com/v1beta/openai com modelo gemini-embedding-001 + dimensions=1536 (pra bater com schema pgvector). Env vars novas (com fallback pro legacy_openai_settings se não setadas): CAPTAIN_EMBEDDING_API_KEY — API key dedicada pra embeddings CAPTAIN_EMBEDDING_ENDPOINT — base URL sem /v1 (default herda OpenAI) CAPTAIN_EMBEDDING_DIMENSIONS — força redução do vector (ex: 1536) Quando CAPTAIN_EMBEDDING_API_KEY está vazia, comportamento é idêntico ao de antes (legacy_openai_settings). Backward-compatible. Também aceita as variáveis via InstallationConfig (UI) ou ENV — ENV tem precedência (padrão Chatwoot). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/captain/llm/embedding_service.rb | 42 ++++++++++++++++--- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb index 7cbb3c03c..420ddb027 100644 --- a/enterprise/app/services/captain/llm/embedding_service.rb +++ b/enterprise/app/services/captain/llm/embedding_service.rb @@ -26,17 +26,47 @@ class Captain::Llm::EmbeddingService private - # Embeddings sempre vão direto pra OpenAI tradicional — o endpoint Codex - # via ChatGPT OAuth não expõe /embeddings. + # Embeddings vão pra OpenAI tradicional por default (o endpoint Codex + # via ChatGPT OAuth não expõe /embeddings). Override opcional via env vars + # dedicadas — útil pra trocar provider de embedding (ex: Gemini + # OpenAI-compatible) sem alterar o provider de chat: + # + # CAPTAIN_EMBEDDING_API_KEY — sobrescreve API key + # CAPTAIN_EMBEDDING_ENDPOINT — sobrescreve base URL (sem /v1 no final) + # CAPTAIN_EMBEDDING_DIMENSIONS — força reduction (ex: 1536 pra Gemini + # bater com schema pgvector(1536)) def embed_with_legacy_openai(content, model) - legacy = Captain::Llm::ProviderConfig.legacy_openai_settings - api_base = legacy[:api_base].present? ? "#{legacy[:api_base]}/v1" : nil + settings = embedding_settings + api_base = settings[:api_base].present? ? "#{settings[:api_base]}/v1" : nil + embed_options = embed_extra_options - Llm::Config.with_api_key(legacy[:api_key], api_base: api_base) do |ctx| - ctx.embed(content, model: model).vectors + Llm::Config.with_api_key(settings[:api_key], api_base: api_base) do |ctx| + ctx.embed(content, model: model, **embed_options).vectors end end + def embedding_settings + custom_key = installation_config_value('CAPTAIN_EMBEDDING_API_KEY') + return Captain::Llm::ProviderConfig.legacy_openai_settings if custom_key.blank? + + { + api_key: custom_key, + api_base: installation_config_value('CAPTAIN_EMBEDDING_ENDPOINT')&.chomp('/') + } + end + + def embed_extra_options + dims = installation_config_value('CAPTAIN_EMBEDDING_DIMENSIONS') + return {} if dims.blank? + + { dimensions: dims.to_i } + end + + def installation_config_value(name) + ENV.fetch(name, nil) || + InstallationConfig.find_by(name: name)&.value + end + def instrumentation_params(content, model) { span_name: 'llm.captain.embedding', From 60759b955c0a334719e2eb3462c72cb0798ba0c6 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 16:18:50 -0300 Subject: [PATCH 10/63] =?UTF-8?q?fix(captain):=20for=C3=A7a=20provider=20:?= =?UTF-8?q?openai=20quando=20h=C3=A1=20config=20dedicada=20de=20embedding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RubyLLM auto-detecta provider pelo prefixo do nome do modelo (ex: `gemini-*` → provider Gemini → exige `gemini_api_key`). Quando temos config dedicada de embedding (CAPTAIN_EMBEDDING_API_KEY) apontando pra endpoint OpenAI-compatible (ex: Gemini OpenAI-compat em generativelanguage.googleapis.com/v1beta/openai), queremos que o RubyLLM mande a request via OpenAI client mesmo que o nome do modelo bata com outro provider. Solução: passar provider: :openai e assume_model_exists: true ao chamar embed quando dedicated_embedding_config? retornar true. Sem isso, o RubyLLM falha com `Missing configuration for Gemini: gemini_api_key` mesmo com a key correta setada. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/captain/llm/embedding_service.rb | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/enterprise/app/services/captain/llm/embedding_service.rb b/enterprise/app/services/captain/llm/embedding_service.rb index 420ddb027..b20a0916d 100644 --- a/enterprise/app/services/captain/llm/embedding_service.rb +++ b/enterprise/app/services/captain/llm/embedding_service.rb @@ -40,11 +40,22 @@ class Captain::Llm::EmbeddingService api_base = settings[:api_base].present? ? "#{settings[:api_base]}/v1" : nil embed_options = embed_extra_options + # Quando há config dedicada de embedding (CAPTAIN_EMBEDDING_API_KEY etc), + # forçamos provider :openai pra que o RubyLLM trate como OpenAI-compatible + # mesmo com modelos cujo nome auto-detectaria outro provider (ex: + # `gemini-embedding-001` apontado pro endpoint Gemini OpenAI-compat). + embed_options[:provider] = :openai if dedicated_embedding_config? + embed_options[:assume_model_exists] = true if dedicated_embedding_config? + Llm::Config.with_api_key(settings[:api_key], api_base: api_base) do |ctx| ctx.embed(content, model: model, **embed_options).vectors end end + def dedicated_embedding_config? + installation_config_value('CAPTAIN_EMBEDDING_API_KEY').present? + end + def embedding_settings custom_key = installation_config_value('CAPTAIN_EMBEDDING_API_KEY') return Captain::Llm::ProviderConfig.legacy_openai_settings if custom_key.blank? From 9ed3491d559fcd221118e957b139e3ef7b0ab901 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 20:13:16 -0300 Subject: [PATCH 11/63] feat(captain/mcp): suite de 9 tools MCP + pricing tables Dolce Amore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../webhooks/captain/mcp_controller.rb | 27 +- .../services/captain/mcp/pricing_tables.rb | 151 ++++++++ .../app/services/captain/mcp/tool_registry.rb | 11 +- .../captain/mcp/tools/add_label_tool.rb | 37 +- .../mcp/tools/check_pix_payment_tool.rb | 116 ++++++ .../captain/mcp/tools/generate_pix_tool.rb | 366 ++++++++++++++++++ .../mcp/tools/get_contact_history_tool.rb | 142 +++++++ .../mcp/tools/react_to_message_tool.rb | 98 +++++ .../mcp/tools/reschedule_reservation_tool.rb | 156 ++++++++ .../mcp/tools/send_suite_images_tool.rb | 136 +++++++ .../captain/mcp/tools/update_contact_tool.rb | 127 ++++++ 11 files changed, 1355 insertions(+), 12 deletions(-) create mode 100644 enterprise/app/services/captain/mcp/pricing_tables.rb create mode 100644 enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/update_contact_tool.rb diff --git a/app/controllers/webhooks/captain/mcp_controller.rb b/app/controllers/webhooks/captain/mcp_controller.rb index c9d563354..a996e782f 100644 --- a/app/controllers/webhooks/captain/mcp_controller.rb +++ b/app/controllers/webhooks/captain/mcp_controller.rb @@ -79,11 +79,32 @@ class Webhooks::Captain::McpController < ApplicationController # Cliente MCP pode mandar contexto multi-tenant em params._captain_context. # Hermes inclui isso quando chama uma tool, pra Captain saber qual conversation # é (já que MCP em si é stateless entre client/server). + # + # Fallback: cada profile do Hermes está atrelado a uma unidade + # (Valentina → Dolce Amore, Jasmine → Prime AL, etc), então também aceitamos + # contexto via headers HTTP fixos no config.yaml do profile: + # X-Captain-Account-Id, X-Captain-Assistant-Id, X-Captain-Inbox-Id. + # Body wins se houver conflito (override por chamada). def extract_context(request_body) params = request_body['params'] || {} - ctx = params['_captain_context'] || {} - return {} unless ctx.is_a?(Hash) + body_ctx = params['_captain_context'] || {} + body_ctx = {} unless body_ctx.is_a?(Hash) - ctx.symbolize_keys + extract_header_context.merge(body_ctx.symbolize_keys) + end + + def extract_header_context + { + account_id: header_int('X-Captain-Account-Id'), + assistant_id: header_int('X-Captain-Assistant-Id'), + inbox_id: header_int('X-Captain-Inbox-Id') + }.compact + end + + def header_int(name) + value = request.headers[name].to_s + return nil if value.blank? + + value.to_i end end diff --git a/enterprise/app/services/captain/mcp/pricing_tables.rb b/enterprise/app/services/captain/mcp/pricing_tables.rb new file mode 100644 index 000000000..e4f89e24d --- /dev/null +++ b/enterprise/app/services/captain/mcp/pricing_tables.rb @@ -0,0 +1,151 @@ +# Tabelas de preço por unidade do Captain — fonte de verdade backend pra +# tools MCP que precisam validar valor. Espelha o que está nos prompts/skills +# das assistentes (Valentina, Jasmines, etc), mas centralizada e auditável. +# +# Quando o LLM chama `generate_pix`, ele NÃO informa o valor; apenas +# categoria/período. Tool calcula via essa tabela. Isso impede que o LLM +# invente um valor (ex: "aplicou desconto VIP" sozinho). +# +# Estrutura: TABLES[captain_unit_id] = { +# categories: { +# '' => { +# prices: { '3h' => 85, 'pernoite_promo' => 110, ... }, +# aliases: ['apto', 'standard', 'apartamento standard', ...] +# } +# }, +# extra_person_fee: 45, +# extra_person_rules: { '' => starts_at_guest_n } +# } +# +# Hoje só Dolce Amore (unit 4) está mapeado — Hermes só está ativo nele. +# Conforme outras unidades migrarem pra Hermes, expandir aqui. +# rubocop:disable Metrics/ModuleLength +module Captain::Mcp::PricingTables + PERIOD_KEYS = %w[3h pernoite_promo pernoite_integral diaria].freeze + + TABLES = { + # Motel Dolce Amore — Ponta Negra, Natal/RN (captain_unit_id=4) + 4 => { + currency: 'BRL', + extra_person_fee: 45.0, + # Por categoria, a partir de qual hóspede a taxa começa a contar. + # Ex: "starts_at_guest_n=3" significa que 3ª pessoa em diante paga. + # Default 3 — base do quarto inclui 2 pessoas (casal). + categories: { + 'apartamento' => { + prices: { '3h' => 85.0, 'pernoite_promo' => 110.0, 'pernoite_integral' => 155.0, 'diaria' => 290.0 }, + extra_person_starts_at: 3, + aliases: ['apto', 'standard', 'apartamento standard', 'apartamento_standard'] + }, + 'suite_master' => { + prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }, + extra_person_starts_at: 3, + aliases: ['master', 'suite master', 'suíte master', '2 andares'] + }, + 'suite_luxo' => { + prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }, + extra_person_starts_at: 3, + aliases: ['luxo', 'suite luxo', 'suíte luxo', 'classica', 'clássica'] + }, + 'suite_tematica' => { + prices: { '3h' => 90.0, 'pernoite_promo' => 130.0, 'pernoite_integral' => 180.0, 'diaria' => 340.0 }, + extra_person_starts_at: 3, + aliases: ['tematica', 'temática', 'suite tematica', 'suíte temática'] + }, + 'mini_chale_45' => { + prices: { '3h' => 100.0, 'pernoite_promo' => 140.0, 'pernoite_integral' => 190.0, 'diaria' => 400.0 }, + extra_person_starts_at: 3, + aliases: ['mini chale', 'mini chalé', 'chale 45', 'chalé 45', 'mini chalé 45', 'mini_chale'] + }, + 'chale_2_suites' => { + prices: { '3h' => 165.0, 'pernoite_promo' => 240.0, 'pernoite_integral' => 350.0, 'diaria' => 490.0 }, + extra_person_starts_at: 4, + aliases: ['chale 2', 'chalé 2', 'chale 2 suites', 'chalé 2 suítes', 'chale_2', '2 suites'] + }, + 'suite_ouro' => { + prices: { '3h' => 230.0, 'pernoite_promo' => 340.0, 'pernoite_integral' => 440.0, 'diaria' => 830.0 }, + extra_person_starts_at: 4, + aliases: ['ouro', 'suite ouro', 'suíte ouro'] + }, + 'chale_master_4_suites' => { + prices: { '3h' => 360.0, 'pernoite_promo' => 510.0, 'pernoite_integral' => 580.0, 'diaria' => 1240.0 }, + extra_person_starts_at: 8, + aliases: ['chale master', 'chalé master', 'master 4 suites', 'chalé master 4 suítes', 'chale_master', '4 suites'] + } + } + } + }.freeze + + class << self + # Retorna {amount:, breakdown:} ou erro {error:} pra uma cobrança. + # period: '3h' | 'pernoite_promo' | 'pernoite_integral' | 'diaria' + # extra_guests: número TOTAL de hóspedes (não só os "extras" — a função + # calcula extras baseado em extra_person_starts_at). + # rubocop:disable Metrics/MethodLength + def calculate(unit_id:, suite_category:, period:, total_guests: 2) + table = TABLES[unit_id] + return { error: "Unidade #{unit_id} não tem tabela de preços cadastrada." } if table.blank? + + cat_key, cat_data = find_category(table, suite_category) + return { error: "Categoria '#{suite_category}' não reconhecida nesta unidade." } if cat_data.blank? + + period_key = normalize_period(period) + return { error: "Período '#{period}' inválido. Use: #{PERIOD_KEYS.join(', ')}." } if period_key.blank? + + base = cat_data[:prices][period_key] + return { error: "Preço de '#{period_key}' não definido para '#{cat_key}'." } if base.blank? + + starts_at = cat_data[:extra_person_starts_at] || 3 + extra_guests = [total_guests.to_i - (starts_at - 1), 0].max + extra_total = extra_guests * table[:extra_person_fee] + total = (base + extra_total).round(2) + + { + amount: total, + breakdown: { + unit_id: unit_id, + suite_category: cat_key, + period: period_key, + base_price: base, + total_guests: total_guests, + extra_guests: extra_guests, + extra_person_fee: table[:extra_person_fee], + extra_total: extra_total + } + } + end + + def categories_for(unit_id) + TABLES.dig(unit_id, :categories)&.keys || [] + end + + private + + def find_category(table, raw) + needle = raw.to_s.downcase.strip.tr('_', ' ').squeeze(' ') + return [nil, nil] if needle.blank? + + table[:categories].each do |key, data| + candidates = ([key.tr('_', ' ')] + data[:aliases].to_a).map { |c| c.to_s.downcase.strip } + return [key, data] if candidates.any?(needle) + end + + [nil, nil] + end + + def normalize_period(raw) + key = raw.to_s.downcase.strip.tr('-', '_') + return key if PERIOD_KEYS.include?(key) + + # aceita variações comuns + case key + when 'pernoite', 'pernoite_normal', 'promocional' then 'pernoite_promo' + when 'feriado', 'pernoite_feriado', 'sex_sab', 'final_de_semana' then 'pernoite_integral' + when '3', '3 h', 'tres_horas', 'permanencia', 'permanencia_3h' then '3h' + when 'diária' then 'diaria' + end + end + # rubocop:enable Metrics/MethodLength + end +end +# rubocop:enable Metrics/ModuleLength diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb index 4922b63f1..747f5cac2 100644 --- a/enterprise/app/services/captain/mcp/tool_registry.rb +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -10,9 +10,14 @@ class Captain::Mcp::ToolRegistry TOOLS = [ Captain::Mcp::Tools::AddLabelTool, - Captain::Mcp::Tools::FaqLookupTool - # Captain::Mcp::Tools::GeneratePixTool — TODO depois MCP base validar - # Captain::Mcp::Tools::SendSuiteImagesTool — TODO depois MCP base validar + Captain::Mcp::Tools::FaqLookupTool, + Captain::Mcp::Tools::GeneratePixTool, + Captain::Mcp::Tools::UpdateContactTool, + Captain::Mcp::Tools::GetContactHistoryTool, + Captain::Mcp::Tools::CheckPixPaymentTool, + Captain::Mcp::Tools::SendSuiteImagesTool, + Captain::Mcp::Tools::RescheduleReservationTool, + Captain::Mcp::Tools::ReactToMessageTool # Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro ].freeze diff --git a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb index ee06b90e6..9a79e0b7f 100644 --- a/enterprise/app/services/captain/mcp/tools/add_label_tool.rb +++ b/enterprise/app/services/captain/mcp/tools/add_label_tool.rb @@ -25,20 +25,25 @@ class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool label: { type: 'string', description: 'Nome da etiqueta em snake_case (ex: "cliente_recorrente").' + }, + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid) que aparece em [ctx: cid=N] no início da mensagem do cliente. Obrigatório.' } }, - required: ['label'] + required: %w[label conversation_id] } end end def call(args, context:) - label = args['label'].to_s.strip + label = args['label'].to_s.strip.downcase return error_response('Argumento "label" é obrigatório.') if label.blank? - conversation = resolve_conversation(context) - return error_response('Conversation atual não encontrada no contexto.') if conversation.blank? + conversation = resolve_conversation(args, context) + return error_response('Conversation atual não encontrada. Passe conversation_id em arguments (cid do [ctx]).') if conversation.blank? + ensure_account_label!(conversation.account, label) conversation.add_labels([label]) text_response("Etiqueta '#{label}' adicionada à conversa #{conversation.display_id}.") rescue StandardError => e @@ -48,10 +53,30 @@ class Captain::Mcp::Tools::AddLabelTool < Captain::Mcp::Tools::BaseTool private - def resolve_conversation(context) - conv_id = context[:conversation_internal_id] || context[:conversation_id] + # LLM passa conversation_id em arguments (lendo do [ctx: cid=N]). + # Context (header/body) fica como fallback caso algum dia o cliente MCP + # passe a propagar contexto automaticamente. + 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 + + # Conversation#add_labels só salva a tag via acts_as_taggable. Pra a label + # aparecer no sidebar/dropdown da UI do Chatwoot, ela precisa existir como + # registro oficial em account.labels (model Label). Se não existir, criamos + # com cor neutra — gerência pode ajustar depois pelo painel. + def ensure_account_label!(account, title) + return if account.labels.exists?(title: title) + + account.labels.create!( + title: title, + description: 'Criada automaticamente via MCP (Hermes Agent)', + color: '#5C7CFA', + show_on_sidebar: true + ) + end end diff --git a/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb b/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb new file mode 100644 index 000000000..b34b40feb --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/check_pix_payment_tool.rb @@ -0,0 +1,116 @@ +# Tool MCP: consulta status de pagamento Pix de uma reserva. +# +# Caso de uso: cliente diz "já paguei", "tá caindo?", "confirma aí". Tool +# consulta a cobrança mais recente da conversa diretamente no Banco Inter +# via Captain::Inter::CobStatusService. Se confirmado pago, atualiza +# Captain::PixCharge + Captain::Reservation + dispara +# Captain::Payments::ConfirmationService (que cuida de marcar reserva +# confirmada, postar mensagem de confirmação, mover labels, etc). +# +# Idempotente: chamadas repetidas com Pix já pago retornam mesmo resultado +# sem efeito colateral. Cliente pode perguntar várias vezes que tá tudo bem. +# rubocop:disable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength +class Captain::Mcp::Tools::CheckPixPaymentTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'check_pix_payment' + end + + def description + 'Verifica se o Pix da reserva já foi pago no Banco Inter. Use quando o cliente ' \ + 'avisar que pagou ou perguntar status. Retorna: já pago / ainda pendente / não há cobrança. ' \ + 'Quando confirmar pago, dispara internamente confirmação da reserva (mensagem de ' \ + 'confirmação vai pro cliente automaticamente).' + end + + def input_schema + { + type: 'object', + properties: { + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + txid: { + type: 'string', + description: 'Opcional. TXID específico da cobrança. Se vazio, pega a Pix mais recente da conversa.' + } + }, + required: ['conversation_id'] + } + end + end + + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank? + + charge = find_charge(conversation, args['txid']) + return text_response('Não há cobrança Pix vinculada a esta conversa. Você pode gerar uma nova com generate_pix.') if charge.blank? + + if already_paid?(charge) + return text_response("Pagamento já confirmado para a reserva ##{charge.reservation_id} (R$ #{format('%.2f', + charge.original_value.to_f)}). Pode seguir os próximos passos.") + end + + status_result = Captain::Inter::CobStatusService.new(charge).call + + if status_result[:paid] + mark_charge_as_paid!(charge, status_result) + paid_amount = status_result[:paid_value].presence || charge.original_value + text_response("Pagamento confirmado no Inter para reserva ##{charge.reservation_id} (TXID #{charge.txid}, R$ #{format('%.2f', + paid_amount.to_f)}). Reserva atualizada.") + else + label = status_result[:status].presence || 'ATIVA' + text_response( + "Ainda não consta pago no Inter (status: #{label}). Pode levar alguns minutos pra cair — " \ + 'vale aguardar e tentar de novo em 1-2 min.' + ) + end + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::CheckPixPaymentTool] error: #{e.class}: #{e.message}") + error_response("Erro ao consultar pagamento: #{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 find_charge(conversation, txid) + scope = Captain::PixCharge.joins(:reservation) + .where(captain_reservations: { conversation_id: conversation.id, account_id: conversation.account_id }) + scope = scope.where(txid: txid.to_s.strip) if txid.present? + scope.order(created_at: :desc).first + end + + def already_paid?(charge) + charge.respond_to?(:paid?) ? charge.paid? : charge.status.to_s == 'paid' || charge.reservation&.payment_status.to_s == 'paid' + end + + def mark_charge_as_paid!(charge, status_result) + updates = { + status: 'paid', + raw_webhook_payload: status_result[:raw_payload] + } + updates[:e2eid] = status_result[:end_to_end_id] if charge.e2eid.blank? && status_result[:end_to_end_id].present? + updates[:paid_at] = Time.current if charge.paid_at.blank? + charge.update!(updates) + + reservation = charge.reservation + return if reservation.blank? || reservation.payment_status.to_s == 'paid' + + Captain::Payments::ConfirmationService.new( + reservation: reservation, + source: 'mcp_check_pix_payment', + payload: status_result[:raw_payload] + ).perform + end +end +# rubocop:enable Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength diff --git a/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb new file mode 100644 index 000000000..64c417407 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/generate_pix_tool.rb @@ -0,0 +1,366 @@ +# Tool MCP: gera cobrança Pix Inter pra reserva de uma suíte. +# +# Caso de uso: cliente confirmou reserva (categoria + dia + duração). +# Hermes invoca esta tool com os dados estruturados; o CAPTAIN calcula o +# valor (consultando Captain::Mcp::PricingTables — fonte de verdade +# backend) e dispara a cobrança Pix via integração Inter já existente. +# +# **NUNCA aceitamos `amount` do LLM** — isso evita que ele invente +# desconto VIP, cortesia ou erro de cálculo. O LLM só fornece os dados +# de classificação; a tabela hardcoded no Captain decide o valor. +# +# Fluxo: +# 1. Resolve Conversation → Inbox → Captain::Unit +# 2. Lookup pricing (Captain::Mcp::PricingTables.calculate) +# 3. Cria/reusa Captain::Reservation (status=draft) +# 4. Captain::Inter::CobService gera Pix (txid + copia-e-cola) +# 5. Posta mensagem outgoing na conversa com link curto do pagamento +# 6. Marca conversa com label `aguardando_pagamento` +# 7. Retorna resumo curto pro LLM (sem URL pra evitar reposta colada) +# rubocop:disable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength +class Captain::Mcp::Tools::GeneratePixTool < Captain::Mcp::Tools::BaseTool + DEFAULT_DEPOSIT_RATIO = 0.5 # sinal padrão = 50% do total + HOURS_BY_PERIOD = { + '3h' => 3, + 'pernoite_promo' => 13, # 21h → 10h + 'pernoite_integral' => 13, + 'diaria' => 24 + }.freeze + + class << self + def name + 'generate_pix' + end + + def description + 'Gera cobrança Pix pro sinal da reserva (50% do total). Use quando o cliente ' \ + 'confirmou: categoria de suíte, dia/horário, e tem nome+CPF cadastrados. ' \ + 'O Captain CALCULA o valor pela tabela oficial — você só passa os dados de ' \ + 'classificação, NUNCA o valor. A tool envia o link do Pix direto pro cliente; ' \ + 'você só confirma que foi gerado.' + end + + def input_schema + { + type: 'object', + properties: { + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + suite_category: { + type: 'string', + description: 'Categoria da suíte (ex: "suite_master", "apartamento", "mini_chale_45", "chale_2_suites", "suite_ouro", "chale_master_4_suites"). Aceita variações naturais.' + }, + period: { + type: 'string', + enum: %w[3h pernoite_promo pernoite_integral diaria], + description: 'Tipo de permanência. "pernoite_promo" = Dom-Qui (mais barato). ' \ + '"pernoite_integral" = Sex/Sáb/Feriado/Véspera (mais caro). "3h" = permanência curta. "diaria" = 24h.' + }, + total_guests: { + type: 'integer', + description: 'Quantidade TOTAL de hóspedes (não só os extras). Default 2 (casal). A taxa de pessoa extra é calculada automaticamente conforme regra da categoria.', + default: 2 + }, + check_in_date: { + type: 'string', + description: 'Data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Default: hoje no fuso da conta.' + } + }, + required: %w[conversation_id suite_category period] + } + end + end + + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]) em arguments.') if conversation.blank? + + unit = resolve_unit(conversation) + return error_response('Unidade do Captain não vinculada à inbox dessa conversa.') if unit.blank? + return error_response('Unidade não tem credenciais Inter configuradas. Avise a gerência.') unless unit.inter_credentials_present? + + contact = conversation.contact + hydrate_contact_from_recent_messages!(contact, conversation) + missing = identity_missing_fields(contact) + return error_response("Faltam dados do cliente pra gerar Pix: #{missing.join(', ')}. Peça ao cliente antes de chamar esta tool.") if missing.any? + + pricing = Captain::Mcp::PricingTables.calculate( + unit_id: unit.id, + suite_category: args['suite_category'], + period: args['period'], + total_guests: (args['total_guests'] || 2).to_i + ) + return error_response("Preço não calculado: #{pricing[:error]}") if pricing[:error].present? + + total_amount = pricing[:amount] + deposit = (total_amount * DEFAULT_DEPOSIT_RATIO).round(2) + + reservation = build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit) + + begin + charge = Captain::Inter::CobService.new(reservation, amount: deposit).call + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::GeneratePixTool] Inter falhou — usando fallback: #{e.class}: #{e.message}") + return dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit) + end + + # Move da fase 'draft' pra 'pending_payment' — agora a reservation + # aparece nas abas/Kanban "Aguardando PIX" do painel. + reservation.update!(status: :pending_payment) + + dispatch_link_message(conversation, charge, deposit) + mark_awaiting_payment(conversation) + + text_response( + "Pix gerado: sinal R$ #{format('%.2f', + deposit)} (50% de R$ #{format('%.2f', + total_amount)} — #{pricing[:breakdown][:suite_category]} / #{pricing[:breakdown][:period]}). Link enviado em mensagem separada na conversa #{conversation.display_id}." + ) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::GeneratePixTool] error: #{e.class}: #{e.message}") + Rails.logger.error(e.backtrace.first(5).join("\n")) + error_response("Erro ao gerar Pix: #{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 resolve_unit(conversation) + captain_inbox = CaptainInbox.find_by(inbox_id: conversation.inbox_id) + return captain_inbox.captain_unit if captain_inbox&.captain_unit.present? + + Captain::Unit.find_by(account_id: conversation.account_id, inbox_id: conversation.inbox_id) + end + + def identity_missing_fields(contact) + missing = [] + missing << 'nome completo' if contact&.name.to_s.squish.length < 3 + cpf_digits = contact&.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '') + missing << 'CPF' if cpf_digits.length != 11 + missing + end + + CPF_REGEX = /\b(\d{3}\.?\d{3}\.?\d{3}-?\d{2}|\d{11})\b/ + NAME_RUN_REGEX = /\A([\p{L}'\-]{3,}(?:\s+[\p{L}'\-]{2,}){1,5})/u + + # Cliente normalmente envia nome+CPF junto numa mensagem ("Rodrigo Borba 12345678901"). + # Quando o contact ainda não tem CPF/nome cadastrados, varremos as últimas + # 10 mensagens incoming pra extrair e popular antes de chamar a Inter. + def hydrate_contact_from_recent_messages!(contact, conversation) + return if contact.blank? + + needs_cpf = contact.custom_attributes.to_h.with_indifferent_access[:cpf].to_s.gsub(/\D/, '').length != 11 + needs_name = contact.name.to_s.squish.length < 3 + return unless needs_cpf || needs_name + + extracted_cpf = nil + extracted_name = nil + + conversation.messages + .where(message_type: :incoming, sender_type: 'Contact') + .reorder(created_at: :desc) + .limit(10).each do |msg| + text = msg.content.to_s + extracted_cpf ||= extract_cpf(text) if needs_cpf + extracted_name ||= extract_name(text) if needs_name + break if (!needs_cpf || extracted_cpf) && (!needs_name || extracted_name) + end + + updates = {} + updates[:name] = extracted_name if needs_name && extracted_name.present? + updates[:custom_attributes] = contact.custom_attributes.to_h.merge('cpf' => extracted_cpf) if needs_cpf && extracted_cpf.present? + return if updates.empty? + + contact.update!(updates) + Rails.logger.info("[Captain::Mcp::GeneratePixTool] hydrated contact #{contact.id} with #{updates.keys}") + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::GeneratePixTool] hydrate failed: #{e.class} - #{e.message}") + end + + def extract_cpf(text) + digits = text.to_s.scan(CPF_REGEX).flatten.first.to_s.gsub(/\D/, '') + digits.length == 11 ? digits : nil + end + + # Extrai 2-6 palavras alfabéticas seguidas no início do texto, ignorando + # números/pontuação ao redor. + def extract_name(text) + cleaned = text.to_s.gsub(/[\d.,;:\-()]+/, ' ').squish + match = cleaned.match(NAME_RUN_REGEX) + return nil if match.nil? + + candidate = match[1].strip + return nil if candidate.length < 5 + + candidate.split.map(&:capitalize).join(' ') + end + + # Reusa a draft mais recente da conversa (últimas 2h) ou cria nova. + # Atualiza campos com base nos novos args (categoria pode ter mudado). + def build_or_update_reservation!(conversation, unit, args, pricing, total_amount, deposit) + check_in_at = parse_check_in(args['check_in_date'], conversation.account) + period_hours = HOURS_BY_PERIOD[pricing[:breakdown][:period]] || 13 + check_out_at = check_in_at + period_hours.hours + + reservation = recent_draft_for(conversation) || Captain::Reservation.new( + account: conversation.account, + inbox: conversation.inbox, + contact: conversation.contact, + contact_inbox: conversation.contact_inbox, + conversation: conversation, + captain_unit_id: unit.id + ) + + reservation.assign_attributes( + suite_identifier: pricing[:breakdown][:suite_category], + check_in_at: check_in_at, + check_out_at: check_out_at, + status: :draft, + payment_status: 'pending', + total_amount: total_amount, + metadata: (reservation.metadata.to_h).merge( + 'full_amount' => total_amount, + 'deposit_amount' => deposit, + 'created_by' => 'mcp_generate_pix_tool', + 'pricing_breakdown' => pricing[:breakdown].stringify_keys + ) + ) + + reservation.save! + reservation + end + + def recent_draft_for(conversation) + Captain::Reservation + .where(conversation_id: conversation.id, status: 'draft') + .where('updated_at > ?', 2.hours.ago) + .order(updated_at: :desc).first + end + + def parse_check_in(raw, account) + tz = account.respond_to?(:timezone) ? (account.timezone.presence || Time.zone.name) : Time.zone.name + Time.use_zone(tz) do + base = if raw.blank? + Time.zone.today + else + try_parse_date(raw) || Time.zone.today + end + Time.zone.local(base.year, base.month, base.day, 21, 0, 0) # entrada padrão 21h + end + end + + def try_parse_date(raw) + Date.iso8601(raw) + rescue ArgumentError + begin + Date.strptime(raw, '%d/%m/%Y') + rescue ArgumentError + nil + end + end + + def dispatch_link_message(conversation, charge, deposit) + base_url = InstallationConfig.find_by(name: 'FRONTEND_URL')&.value.presence || + ENV.fetch('FRONTEND_URL', 'http://localhost:3000') + base_url = base_url.gsub('0.0.0.0', '127.0.0.1') if base_url.include?('0.0.0.0') + + token = charge.to_sgid(expires_in: 2.hours, purpose: :pix_payment).to_s + link = Rails.application.routes.url_helpers.short_payment_link_url(token, host: base_url) + + body = "💸 *Pix do sinal — R$ #{format('%.2f', deposit)}*\n\n" \ + "Abra o link abaixo pra ver o QR Code e copiar o código Pix:\n#{link}\n\n" \ + 'Sua reserva fica confirmada automaticamente assim que o pagamento cair (alguns segundos).' + + Messages::MessageBuilder.new( + nil, + conversation, + content: body, + message_type: 'outgoing' + ).perform + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::GeneratePixTool] failed to dispatch link: #{e.class} - #{e.message}") + end + + def mark_awaiting_payment(conversation) + current = conversation.label_list + merged = (current + ['aguardando_pagamento']).uniq - %w[pagamento_confirmado reserva_feita] + conversation.update_labels(merged) + end + + # Quando a Inter API falha (auth, certificado, timeout, etc), em vez de + # devolver erro, mandamos o cliente pra página oficial de reserva + # (reserva-1001) com query string preenchida. Cliente conclui por lá. + # Marca a conversa com `pix_falhou_fallback` pra triagem da gerência. + def dispatch_fallback_link!(conversation, unit, reservation, pricing, total_amount, deposit) + base = ENV.fetch('RESERVA_1001_BASE_URL', + InstallationConfig.find_by(name: 'RESERVA_1001_BASE_URL')&.value.presence || + 'https://reservas.hoteis1001noites.com.br') + contact = conversation.contact + custom = contact&.custom_attributes.to_h.with_indifferent_access + + params = { + marca: unit.brand&.name, + unidade: unit.name, + permanencia: humanize_period(pricing[:breakdown][:period]), + categoria: humanize_category(pricing[:breakdown][:suite_category]), + checkin: reservation.check_in_at&.iso8601, + nome: contact&.name, + telefone: contact&.phone_number, + cpf: custom[:cpf], + email: contact&.email + }.compact.reject { |_, v| v.to_s.strip.empty? } + + url = "#{base.chomp('/')}/?#{URI.encode_www_form(params)}" + + body = 'Tive um problema técnico pra gerar o Pix por aqui — mas tudo certo, é só finalizar pela página oficial ' \ + "(seus dados já estão pré-preenchidos):\n#{url}" + + Messages::MessageBuilder.new(nil, conversation, content: body, message_type: 'outgoing').perform + + current = conversation.label_list + conversation.update_labels((current + %w[aguardando_pagamento pix_falhou_fallback]).uniq) + + text_response( + "Inter API indisponível. Link da página de reserva enviado pro cliente (R$ #{format('%.2f', + total_amount)} total / R$ #{format('%.2f', + deposit)} sinal). Marquei conversa com pix_falhou_fallback." + ) + end + + PERIOD_LABELS = { + '3h' => '3hrs', + 'pernoite_promo' => 'Pernoite', + 'pernoite_integral' => 'Pernoite', + 'diaria' => 'Diaria' + }.freeze + + def humanize_period(period_key) + PERIOD_LABELS[period_key] || period_key.to_s.humanize + end + + CATEGORY_LABELS = { + 'apartamento' => 'Apartamento', + 'suite_master' => 'Suite Master', + 'suite_luxo' => 'Suite Luxo', + 'suite_tematica' => 'Suite Tematica', + 'mini_chale_45' => 'Mini Chale 45', + 'chale_2_suites' => 'Chale 2 Suites', + 'suite_ouro' => 'Suite Ouro', + 'chale_master_4_suites' => 'Chale Master 4 Suites' + }.freeze + + def humanize_category(cat_key) + CATEGORY_LABELS[cat_key] || cat_key.to_s.tr('_', ' ').split.map(&:capitalize).join(' ') + end +end +# rubocop:enable Metrics/ClassLength, Metrics/MethodLength, Metrics/AbcSize, Layout/LineLength diff --git a/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb b/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb new file mode 100644 index 000000000..d4f0fbee5 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_contact_history_tool.rb @@ -0,0 +1,142 @@ +# Tool MCP: retorna histórico estruturado do cliente em markdown. +# +# **Determinístico:** o conteúdo é montado on-the-fly do DB do Captain. +# LLM nunca escreve nem altera. Captain é source of truth único — Reservation, +# Conversation, Message, PixCharge etc — esta tool só serializa em markdown +# numa forma amigável pro LLM ler. +# +# Quando usar (do ponto de vista da Valentina): +# - Cliente pergunta sobre passado livre ("o que falamos sobre alergia?") +# - Cliente pede recap ("me lembra o que tava combinado?") +# - Cliente pergunta sobre reserva antiga não-recente (recente já vem no [ctx]) +# - Suspeita de cliente VIP / fidelizado pra calibrar tom +# +# Quando NÃO usar: +# - Pergunta cobertas pelo [ctx] (last_res_*, total_reservas) — responda direto +# - Toda mensagem (custo de latência desnecessário) +class Captain::Mcp::Tools::GetContactHistoryTool < Captain::Mcp::Tools::BaseTool + MAX_RESERVATIONS = 8 + MAX_CONVERSATIONS = 5 + MAX_MESSAGE_SAMPLES_PER_CONV = 6 + + class << self + def name + 'get_contact_history' + end + + def description + 'Retorna histórico completo do cliente em markdown (reservas, conversas anteriores, ' \ + 'labels, mensagens-chave). Use quando o cliente perguntar sobre algo do passado que ' \ + 'não está no [ctx] (ex: "qual era a reserva de 3 meses atrás", "o que falamos sobre X"). ' \ + 'NÃO use pra perguntas cobertas pelo [ctx] (last_res_date, total_reservas etc).' + end + + def input_schema + { + type: 'object', + properties: { + contact_id: { + type: 'integer', + description: 'ID do contato (campo `contact` do [ctx]). Obrigatório.' + }, + query: { + type: 'string', + description: 'Opcional. Termo pra filtrar mensagens por conteúdo (ex: "alergia", "desconto"). Se vazio, retorna histórico geral.' + } + }, + required: ['contact_id'] + } + end + end + + def call(args, context:) + contact_id = args['contact_id'].presence || context[:contact_id] + return error_response('contact_id obrigatório.') if contact_id.blank? + + contact = Contact.find_by(id: contact_id) + return error_response("Contato #{contact_id} não encontrado.") if contact.blank? + + md = build_markdown(contact, args['query'].to_s.strip) + text_response(md) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::GetContactHistoryTool] error: #{e.class}: #{e.message}") + error_response("Erro ao buscar histórico: #{e.message}") + end + + private + + def build_markdown(contact, query) + sections = [] + sections << header_section(contact) + sections << reservations_section(contact) + sections << conversations_section(contact, query) + sections.compact.join("\n\n") + end + + def header_section(contact) + custom = contact.custom_attributes.to_h.with_indifferent_access + cpf = custom[:cpf].to_s + cpf_fmt = cpf.length == 11 ? cpf.gsub(/(\d{3})(\d{3})(\d{3})(\d{2})/, '\1.\2.\3-\4') : cpf + + [ + "# Cliente: #{contact.name} (contact #{contact.id})", + ([ + cpf.present? ? "**CPF:** #{cpf_fmt}" : nil, + contact.email.present? ? "**Email:** #{contact.email}" : nil, + contact.phone_number.present? ? "**Telefone:** #{contact.phone_number}" : nil + ].compact.join(' · ')).presence, + ("**Notas:** #{custom[:notes]}" if custom[:notes].present?) + ].compact.join("\n") + end + + def reservations_section(contact) # rubocop:disable Metrics/AbcSize + reservations = Captain::Reservation + .where(contact_id: contact.id) + .order(check_in_at: :desc) + .limit(MAX_RESERVATIONS) + return '## Reservas\n_(sem reservas registradas)_' if reservations.empty? + + lines = ['## Reservas'] + reservations.each do |r| + checkin = r.check_in_at&.strftime('%d/%m/%Y às %Hh%M') || '-' + created = r.created_at.strftime('%d/%m/%Y') + total = r.total_amount.to_f + deposit = r.metadata.to_h['deposit_amount'].to_f + paid = Captain::PixCharge.exists?(reservation_id: r.id, status: 'paid') + lines << "### Reserva ##{r.id} — check-in #{checkin}" + lines << "Suíte: #{r.suite_identifier || '-'} · Status: **#{r.status}** · " \ + "Total: R$ #{format('%.2f', total)} · Sinal: R$ #{format('%.2f', deposit)} " \ + "(#{paid ? 'pago' : 'não pago'}) · Criada em #{created}" + end + lines.join("\n") + end + + def conversations_section(contact, query) + convs = contact.conversations.order(last_activity_at: :desc).limit(MAX_CONVERSATIONS) + return '## Conversas anteriores\n_(sem conversas registradas)_' if convs.empty? + + lines = ['## Conversas recentes'] + convs.each do |c| + label_str = c.label_list.any? ? " · labels: #{c.label_list.join(', ')}" : '' + activity = c.last_activity_at&.strftime('%d/%m/%Y %H:%M') || '-' + lines << "### Conversa ##{c.display_id} (#{c.status}) — #{activity}#{label_str}" + msg_lines = sample_messages(c, query) + lines.concat(msg_lines) if msg_lines.any? + end + lines.join("\n") + end + + def sample_messages(conversation, query) + scope = conversation.messages + .where(message_type: %i[incoming outgoing], private: false) + .where('content ~* ?', '\\S') + scope = scope.where('content ILIKE ?', "%#{query}%") if query.present? + scope = scope.reorder(created_at: :asc).limit(MAX_MESSAGE_SAMPLES_PER_CONV) + + scope.map do |m| + who = m.message_type == 'incoming' ? 'Cliente' : 'Atendente' + preview = m.content.to_s.gsub(/\s+/, ' ').strip[0, 200] + "- **#{who}:** #{preview}" + end + end +end diff --git a/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb b/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb new file mode 100644 index 000000000..f042ac5b0 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/react_to_message_tool.rb @@ -0,0 +1,98 @@ +# Tool MCP: reage com emoji em uma mensagem do cliente. +# +# Caso de uso: gestos rápidos sem texto (cliente mandou foto bonita, +# áudio agradecendo, confirmação curta, etc). É bastidor — não substitui +# resposta textual; complementa ou indica leitura. +# +# Implementação: cria Message outgoing com `content_attributes.is_reaction=true` +# e `in_reply_to_external_id=`. O pipeline wuzapi +# (Whatsapp::Providers::WuzapiService#send_reaction_message) detecta esses +# atributos e dispara via API do wuzapi como react nativo do WhatsApp. +class Captain::Mcp::Tools::ReactToMessageTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'react_to_message' + end + + def description + 'Reage com emoji em uma mensagem do cliente (ex: 👍 ❤️ 😍 🙏 😂 😮 😢). ' \ + 'Use pra gestos curtos: cliente mandou foto bonita → 😍, agradeceu → 🙏, ' \ + 'confirmou algo → 👍. NÃO substitui resposta — é complementar. Sem texto extra.' + end + + def input_schema + { + type: 'object', + properties: { + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + emoji: { + type: 'string', + description: 'Emoji único a reagir (ex: 👍, ❤️, 😍, 🙏, 😂, 😮, 😢).' + }, + message_id: { + type: 'integer', + description: 'Opcional. ID interno da mensagem do cliente. Se vazio, reage à última mensagem incoming da conversa.' + } + }, + required: %w[conversation_id emoji] + } + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank? + + emoji = args['emoji'].to_s.strip + return error_response('Argumento "emoji" é obrigatório.') if emoji.blank? + + target = resolve_target_message(conversation, args['message_id']) + return error_response('Não achei mensagem do cliente pra reagir.') if target.blank? + if target.source_id.blank? + return error_response("Mensagem alvo (id=#{target.id}) sem source_id — wuzapi não consegue identificar a msg no WhatsApp.") + end + + assistant = conversation.inbox.captain_assistant + conversation.messages.create!( + message_type: :outgoing, + account_id: conversation.account_id, + inbox_id: conversation.inbox_id, + sender: assistant, + content: emoji, + content_attributes: { + is_reaction: true, + in_reply_to_external_id: target.source_id, + external_source: 'hermes_react_tool' + } + ) + + text_response("Reação #{emoji} enviada na mensagem ##{target.id}.") + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::ReactToMessageTool] error: #{e.class}: #{e.message}") + error_response("Erro ao reagir: #{e.message}") + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + 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 resolve_target_message(conversation, message_id) + if message_id.present? + conversation.messages.find_by(id: message_id) + else + conversation.messages.where(message_type: :incoming).order(created_at: :desc).first + end + end +end diff --git a/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb b/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb new file mode 100644 index 000000000..02ee33518 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/reschedule_reservation_tool.rb @@ -0,0 +1,156 @@ +# Tool MCP: remarca uma reserva existente. +# +# Caso de uso: cliente diz "vou precisar mudar pra outra data", "queria +# adiantar pra sex", "consegue empurrar pra 25?". Tool ajusta o +# check_in_at/check_out_at da Captain::Reservation mais recente da +# conversa, mantendo categoria e total_amount intactos. +# +# Política Dolce Amore: remarcação tem que ser feita com no mínimo 3h +# de antecedência em relação ao check-in atual. Tool valida. +# +# Idempotente em datas iguais: se a nova data == atual, não toca em nada. +# +# Não cobre: mudança de categoria/preço (use cancel + generate_pix novo) +# ou cancelamento (transferir pra humano via frase-âncora). +class Captain::Mcp::Tools::RescheduleReservationTool < Captain::Mcp::Tools::BaseTool + MIN_NOTICE_HOURS = 3 + + class << self + def name + 'reschedule_reservation' + end + + def description + 'Remarca a reserva existente da conversa pra uma nova data. Mantém categoria e ' \ + 'valor. Política: precisa ser pedido com no mínimo 3h de antecedência em relação ' \ + 'ao check-in atual. Use quando cliente pedir mudança de data SEM mudar categoria. ' \ + 'Pra mudança de categoria, transfira pra humano (frase-âncora).' + end + + def input_schema + { + type: 'object', + properties: { + conversation_id: { + type: 'integer', + description: 'ID interno da conversa (cid do [ctx]). Obrigatório.' + }, + new_check_in_date: { + type: 'string', + description: 'Nova data de check-in (YYYY-MM-DD ou DD/MM/YYYY). Hora padrão = mesma da reserva original.' + }, + new_check_in_time: { + type: 'string', + description: 'Opcional. Nova hora de check-in (HH:MM, 24h). Default: mantém hora atual.' + } + }, + required: %w[conversation_id new_check_in_date] + } + end + end + + # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + def call(args, context:) + conversation = resolve_conversation(args, context) + return error_response('Conversa não encontrada. Passe conversation_id (cid do [ctx]).') if conversation.blank? + + reservation = recent_reservation(conversation) + return error_response('Não há reserva ativa pra remarcar nessa conversa.') if reservation.blank? + + new_check_in = build_new_check_in(args, reservation) + return error_response('Não consegui interpretar a data. Use YYYY-MM-DD ou DD/MM/YYYY.') if new_check_in.blank? + + if new_check_in == reservation.check_in_at + formatted = new_check_in.strftime('%d/%m/%Y %Hh%M') + return text_response("Reserva ##{reservation.id} já está marcada pra #{formatted}. Nada a alterar.") + end + + notice_hours = ((reservation.check_in_at - Time.current) / 1.hour).round + if notice_hours < MIN_NOTICE_HOURS + return error_response( + "Política do hotel: remarcação precisa ser pedida com no mínimo #{MIN_NOTICE_HOURS}h de antecedência. " \ + "Faltam só #{notice_hours}h pro check-in atual — peça pro cliente confirmar com a gerência." + ) + end + + duration = reservation.check_out_at - reservation.check_in_at + reservation.update!(check_in_at: new_check_in, check_out_at: new_check_in + duration) + post_reschedule_note(conversation, reservation, new_check_in) + + formatted = new_check_in.strftime('%d/%m/%Y às %Hh%M') + valor = format('%.2f', reservation.total_amount.to_f) + text_response( + "Reserva ##{reservation.id} remarcada pra #{formatted} " \ + "(categoria #{reservation.suite_identifier}, valor R$ #{valor} mantido)." + ) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::RescheduleReservationTool] error: #{e.class}: #{e.message}") + error_response("Erro ao remarcar: #{e.message}") + end + # rubocop:enable Metrics/AbcSize, Metrics/MethodLength + + 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 + + # Pega reserva mais recente da conversa que ainda não foi finalizada/cancelada. + def recent_reservation(conversation) + Captain::Reservation + .where(conversation_id: conversation.id) + .where.not(status: %w[cancelled done]) + .order(check_in_at: :desc) + .first + end + + def build_new_check_in(args, reservation) # rubocop:disable Metrics/AbcSize + date = parse_date(args['new_check_in_date']) + return nil if date.blank? + + time = parse_time(args['new_check_in_time']) || [reservation.check_in_at.hour, reservation.check_in_at.min] + tz = reservation.account.respond_to?(:timezone) ? (reservation.account.timezone.presence || Time.zone.name) : Time.zone.name + Time.use_zone(tz) { Time.zone.local(date.year, date.month, date.day, time[0], time[1], 0) } + end + + def parse_date(raw) + raw = raw.to_s.strip + return nil if raw.blank? + + Date.iso8601(raw) + rescue ArgumentError + begin + Date.strptime(raw, '%d/%m/%Y') + rescue ArgumentError + nil + end + end + + def parse_time(raw) + raw = raw.to_s.strip + return nil if raw.blank? + + match = raw.match(/\A(\d{1,2}):(\d{2})\z/) + return nil unless match + + [match[1].to_i, match[2].to_i] + end + + def post_reschedule_note(conversation, reservation, new_check_in) + body = "🔄 Reserva ##{reservation.id} remarcada pra #{new_check_in.strftime('%d/%m/%Y às %Hh%M')}. Categoria e valor mantidos." + Messages::MessageBuilder.new( + nil, + conversation, + content: body, + message_type: 'outgoing', + private: true # nota interna pro time, cliente não vê + ).perform + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::RescheduleReservationTool] failed to post note: #{e.class} - #{e.message}") + end +end diff --git a/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb b/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb new file mode 100644 index 000000000..dab07c60f --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/send_suite_images_tool.rb @@ -0,0 +1,136 @@ +# Tool MCP: envia fotos de suítes pro cliente. +# +# Caso de uso: cliente pede pra ver foto da suíte antes de fechar +# ("manda uma foto da Master?", "tem como ver?"). Tool busca o catálogo +# Captain::GalleryItem da inbox atual (com fallback pra acervo global) e +# envia até `limit` imagens como mensagens outgoing na conversa. +# +# Search: aceita `suite_category` (ex: "Master", "Luxo") OU `suite_number` +# (ex: "101"), mutuamente exclusivos. Match case-insensitive, fuzzy. +# +# Pré-requisito: cadastro do Captain::GalleryItem via painel UI do +# Chatwoot — Captain::Mcp não cria fotos, só consome o catálogo. +class Captain::Mcp::Tools::SendSuiteImagesTool < Captain::Mcp::Tools::BaseTool + DEFAULT_LIMIT = 3 + MAX_LIMIT = 5 + + class << self + def name + 'send_suite_images' + end + + def description + 'Envia fotos da suíte pra conversa do cliente. Use quando ele pedir foto/imagem ' \ + '("manda uma foto", "tem como ver?"). Busca no catálogo da inbox atual (fallback ' \ + 'global). Passe `suite_category` (ex: "Master", "Luxo", "Mini Chalé 45") OU ' \ + '`suite_number` (ex: "101") — não combine os dois.' + 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.' + }, + suite_category: { + type: 'string', + description: 'Nome/tipo da suíte (ex: "Master", "Luxo", "Mini Chalé 45"). Use quando o cliente pede pelo NOME da categoria.' + }, + suite_number: { + type: 'string', + description: 'Número específico da suíte (ex: "101"). Use quando o cliente pede pelo NÚMERO. Mutuamente exclusivo com suite_category.' + }, + limit: { + type: 'integer', + description: "Quantas fotos enviar (default #{DEFAULT_LIMIT}, máx #{MAX_LIMIT}).", + default: DEFAULT_LIMIT + } + }, + 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? + + suite_category = args['suite_category'].to_s.strip + suite_number = args['suite_number'].to_s.strip + return error_response('Passe suite_category OU suite_number — pelo menos um.') if suite_category.blank? && suite_number.blank? + + items = find_items(conversation, suite_category, suite_number, args['limit'].to_i) + if items.blank? + label = suite_category.presence || suite_number + return text_response("Nenhuma foto cadastrada pra #{label}. Avise o cliente que pode pedir pro humano.") + end + + sent = items.count { |item| send_image_message(conversation, item) } + label = suite_category.presence || "suíte #{suite_number}" + text_response("#{sent} foto(s) de #{label} enviadas na conversa #{conversation.display_id}.") + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::SendSuiteImagesTool] error: #{e.class}: #{e.message}") + error_response("Erro ao enviar fotos: #{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 find_items(conversation, suite_category, suite_number, limit) + base = Captain::GalleryItem + .active + .where(account_id: conversation.account_id) + .includes(image_attachment: :blob) + .ordered + + filtered = if suite_number.present? + base.where('LOWER(suite_number) = ?', suite_number.downcase) + else + base.where( + 'LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(suite_category, ' \ + "'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e')) " \ + '= LOWER(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(?, ' \ + "'ã','a'),'â','a'),'á','a'),'à','a'),'é','e'),'ê','e'))", + suite_category + ) + end + + inbox_scoped = filtered.where(scope: 'inbox', inbox_id: conversation.inbox_id) + pool = inbox_scoped.exists? ? inbox_scoped : filtered.where(scope: 'global') + + pool.limit(normalize_limit(limit)) + end + + def normalize_limit(value) + n = value.to_i + return DEFAULT_LIMIT if n <= 0 + + [n, MAX_LIMIT].min + end + + def send_image_message(conversation, item) + return false unless item.image.attached? + + Messages::MessageBuilder.new( + nil, + conversation, + content: item.description.to_s.truncate(220), + message_type: 'outgoing', + attachments: [item.image.blob.signed_id] + ).perform + true + rescue StandardError => e + Rails.logger.warn("[Captain::Mcp::SendSuiteImagesTool] failed sending item #{item.id}: #{e.class} - #{e.message}") + false + end +end diff --git a/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb b/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb new file mode 100644 index 000000000..b30b0498a --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/update_contact_tool.rb @@ -0,0 +1,127 @@ +# 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 From 48fad2977b6840835e2a6c332295d798b11c4314 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 20:15:50 -0300 Subject: [PATCH 12/63] feat(captain/hermes): payload enriquecido + humanizadores + notif Pix proativa MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../captain/hermes_callback_controller.rb | 6 +- .../jobs/captain/hermes/delayed_reply_job.rb | 84 +++++++++++ .../hermes/notify_payment_confirmed_job.rb | 56 +++++++ .../app/services/captain/hermes/client.rb | 139 +++++++++++++++++- .../captain/payments/confirmation_service.rb | 11 ++ 5 files changed, 292 insertions(+), 4 deletions(-) create mode 100644 enterprise/app/jobs/captain/hermes/delayed_reply_job.rb create mode 100644 enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb diff --git a/app/controllers/webhooks/captain/hermes_callback_controller.rb b/app/controllers/webhooks/captain/hermes_callback_controller.rb index cad7174c5..901b00ed3 100644 --- a/app/controllers/webhooks/captain/hermes_callback_controller.rb +++ b/app/controllers/webhooks/captain/hermes_callback_controller.rb @@ -28,7 +28,11 @@ class Webhooks::Captain::HermesCallbackController < ApplicationController return log_no_conversation_and_ack if conversation.blank? log_reply(conversation, content) - create_outgoing_message(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}" diff --git a/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb b/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb new file mode 100644 index 000000000..e460b858b --- /dev/null +++ b/enterprise/app/jobs/captain/hermes/delayed_reply_job.rb @@ -0,0 +1,84 @@ +# Posta a resposta do Hermes na conversa simulando comportamento humano: +# 1. Liga indicador de "digitando..." (composing) via wuzapi +# 2. Aguarda delay configurado pelo assistant (typing_simulation, fixed ou none) +# 3. Posta a mensagem outgoing +# +# Config vive em `Captain::Assistant.config['response_delay']`: +# { +# "mode": "typing_simulation" | "fixed" | "none", +# "chars_per_second": 25, # apenas typing_simulation +# "seconds": 3, # apenas fixed +# "min_seconds": 1.5, # cap inferior pra typing_simulation +# "max_seconds": 8.0 # cap superior pra typing_simulation +# } +# +# Default: none (zero delay, igual antes — defensivo). +class Captain::Hermes::DelayedReplyJob < ApplicationJob + queue_as :default + + DEFAULT_CONFIG = { + 'mode' => 'none', + 'chars_per_second' => 25, + 'min_seconds' => 1.5, + 'max_seconds' => 8.0 + }.freeze + + def perform(conversation_id, content) + conversation = Conversation.find_by(id: conversation_id) + if conversation.blank? + Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] conv #{conversation_id} not found") + return + end + + delay = compute_delay(conversation, content) + + if delay.positive? + send_typing(conversation, 'typing_on') + sleep(delay) + end + + create_outgoing_message(conversation, content) + + # WhatsApp cliente costuma sumir typing automático ao receber msg, mas + # mandamos typing_off explícito por segurança. + send_typing(conversation, 'typing_off') if delay.positive? + end + + private + + def compute_delay(conversation, content) + cfg = DEFAULT_CONFIG.merge(conversation.inbox.captain_assistant&.config.to_h.fetch('response_delay', {})) + case cfg['mode'] + when 'fixed' then cfg['seconds'].to_f + when 'typing_simulation' + cps = cfg['chars_per_second'].to_f + cps = 25 if cps <= 0 + raw = content.to_s.length / cps + raw.clamp(cfg['min_seconds'].to_f, cfg['max_seconds'].to_f) + else 0.0 + end + end + + def send_typing(conversation, status) + return unless conversation.inbox.respond_to?(:channel) + return unless conversation.inbox.channel.respond_to?(:toggle_typing_status) + + conversation.inbox.channel.toggle_typing_status(status, conversation: conversation) + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::DelayedReplyJob] toggle_typing_status #{status} failed: #{e.class} - #{e.message}") + 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/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb b/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb new file mode 100644 index 000000000..80dee2e1f --- /dev/null +++ b/enterprise/app/jobs/captain/hermes/notify_payment_confirmed_job.rb @@ -0,0 +1,56 @@ +# Notifica o Hermes Agent sobre confirmação de pagamento de uma reserva, pra +# que o agente mande mensagem espontânea pro cliente celebrando (sem cliente +# precisar perguntar "já caiu?"). +# +# Disparado por Captain::Payments::ConfirmationService (somente quando a +# inbox da reservation está em CAPTAIN_HERMES_INBOX_IDS — coexiste com o +# fluxo Captain interno). +class Captain::Hermes::NotifyPaymentConfirmedJob < ApplicationJob + queue_as :default + + retry_on Captain::Hermes::Client::DispatchError, attempts: 3, wait: 5.seconds + + def perform(reservation_id) # rubocop:disable Metrics/MethodLength + reservation = Captain::Reservation.find_by(id: reservation_id) + if reservation.blank? + Rails.logger.warn("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} not found") + return + end + + conversation = reservation.conversation + if conversation.blank? + Rails.logger.info("[Captain::Hermes::NotifyPaymentConfirmedJob] reservation #{reservation_id} has no conversation — skipping") + return + end + + unless Captain::Hermes.enabled_for?(conversation.inbox) + Rails.logger.info( + "[Captain::Hermes::NotifyPaymentConfirmedJob] inbox #{conversation.inbox_id} " \ + 'not Hermes-enabled — skipping (Captain interno cuida)' + ) + return + end + + Captain::Hermes::Client.new(conversation.inbox).notify_event( + conversation: conversation, + event_type: 'payment_confirmed', + system_message: build_system_message(reservation) + ) + end + + private + + def build_system_message(reservation) + deposit = reservation.metadata.to_h['deposit_amount'].to_f + total = reservation.total_amount.to_f + suite = reservation.suite_identifier.to_s + check_in = reservation.check_in_at&.strftime('%d/%m/%Y às %Hh%M') + + [ + '[SISTEMA: pagamento_confirmado]', + "Pix da reserva ##{reservation.id} acabou de cair pelo banco.", + "Sinal R$ #{format('%.2f', deposit)} de R$ #{format('%.2f', total)} (#{suite}, check-in #{check_in}).", + 'Mande mensagem espontânea celebrando a reserva confirmada e dando próximos passos curtos. Tom íntimo, sem voltar a oferecer outras coisas.' + ].join("\n") + end +end diff --git a/enterprise/app/services/captain/hermes/client.rb b/enterprise/app/services/captain/hermes/client.rb index 8e926a092..7a5c7f8a2 100644 --- a/enterprise/app/services/captain/hermes/client.rb +++ b/enterprise/app/services/captain/hermes/client.rb @@ -37,6 +37,27 @@ class Captain::Hermes::Client raise DispatchError, "Network error contacting Hermes (#{e.class}): #{e.message}" end + # Notificação proativa de evento do Captain pro Hermes — usado pra eventos + # do sistema (Pix pago, reserva expirando, etc) onde o agente deve mandar + # mensagem espontânea sem o cliente ter falado nada. + # + # `system_message` deve começar com `[SISTEMA: ]` pra Valentina + # diferenciar de fala real do cliente (ver regra correspondente em SOUL.md). + def notify_event(conversation:, event_type:, system_message:) + payload = build_event_payload(conversation, event_type, system_message) + body = payload.to_json + headers = signed_headers(body) + + Rails.logger.info "[Captain::Hermes::Client] notifying event #{event_type} (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 @@ -45,11 +66,28 @@ class Captain::Hermes::Client Captain::Hermes.webhook_url_for(inbox) end - def build_payload(message:, conversation:) + def build_payload(message:, conversation:) # rubocop:disable Metrics/MethodLength, Metrics/AbcSize + contact = conversation.contact + contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access + cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '') + history = contact_history_snapshot(contact, conversation) + { - message: message.content.to_s, - contact_name: conversation.contact&.name, + message: text_for_hermes(message), + image_urls: image_urls_for_hermes(message), + contact_name: contact&.name, + contact_first_name: contact&.name.to_s.split.first, contact_id: conversation.contact_id, + contact_cpf_present: cpf_digits.length == 11, + contact_email_present: contact&.email.to_s.include?('@'), + contact_total_reservas: contact_attrs[:total_reservas].to_i, + contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence, + last_reservation_date: history[:last_reservation_date], + last_reservation_status: history[:last_reservation_status], + last_reservation_amount: history[:last_reservation_amount], + last_reservation_suite: history[:last_reservation_suite], + last_conversation_at: history[:last_conversation_at], + total_conversations: history[:total_conversations], conversation_id: conversation.display_id, conversation_internal_id: conversation.id, inbox_id: inbox.id, @@ -60,6 +98,101 @@ class Captain::Hermes::Client } end + # Constroi payload pra notificação de evento sistema. Reusa todo o [ctx] + # do build_payload normal; só substitui `message` pelo system_message e + # marca `is_system_event=true` pra debug/logging. + def build_event_payload(conversation, event_type, system_message) # rubocop:disable Metrics/AbcSize, Metrics/MethodLength + contact = conversation.contact + contact_attrs = contact&.custom_attributes.to_h.with_indifferent_access + cpf_digits = contact_attrs[:cpf].to_s.gsub(/\D/, '') + history = contact_history_snapshot(contact, conversation) + + { + message: system_message, + image_urls: [], + is_system_event: true, + event_type: event_type, + contact_name: contact&.name, + contact_first_name: contact&.name.to_s.split.first, + contact_id: conversation.contact_id, + contact_cpf_present: cpf_digits.length == 11, + contact_email_present: contact&.email.to_s.include?('@'), + contact_total_reservas: contact_attrs[:total_reservas].to_i, + contact_ultima_suite: contact_attrs[:ultima_suite].to_s.presence, + last_reservation_date: history[:last_reservation_date], + last_reservation_status: history[:last_reservation_status], + last_reservation_amount: history[:last_reservation_amount], + last_reservation_suite: history[:last_reservation_suite], + last_conversation_at: history[:last_conversation_at], + total_conversations: history[:total_conversations], + 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: 0, + timestamp: Time.current.to_i + } + end + + # Resolve texto da message pro Hermes consumir. Reusa + # Captain::OpenAiMessageBuilderService que JÁ implementa transcrição de + # áudio (Whisper) + placeholder pra outros attachments. Garante que + # mensagem nunca chega vazia mesmo quando cliente manda só áudio/foto. + # Imagens viram URL/base64 dentro do builder mas pra Hermes via texto, o + # generate_text_content normaliza pra "[image]" placeholder — ainda + # útil pro LLM saber que veio anexo visual. + def text_for_hermes(message) + raw = message.content.to_s + return raw if message.attachments.blank? + + Captain::OpenAiMessageBuilderService.new(message: message).generate_text_content.presence || raw + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::Client] text_for_hermes fallback: #{e.class} - #{e.message}") + message.content.to_s + end + + # URLs públicas das imagens que vieram nessa message. Plugin captain-webhook + # do Hermes baixa essas URLs localmente e popula event.media_urls — daí o + # gpt-5.3-codex (multimodal) consegue ler. Vídeo/PDF/etc ficam de fora por + # enquanto — só imagem é suportada pro LLM ver de fato. + def image_urls_for_hermes(message) + return [] if message.attachments.blank? + + message.attachments.where(file_type: :image).filter_map do |att| + next nil unless att.file.attached? + + att.download_url.presence || att.external_url.presence || att.file_url + end + rescue StandardError => e + Rails.logger.warn("[Captain::Hermes::Client] image_urls_for_hermes fallback: #{e.class} - #{e.message}") + [] + end + + # Snapshot eager pra alimentar o [ctx]. Determinístico (lido do DB), só + # campos estruturados — pra detalhes livres o agente chama + # `get_contact_history` MCP. Limita a últimas reservation/conversation pra + # não estourar token budget. + def contact_history_snapshot(contact, current_conversation) + return {} if contact.blank? + + last_res = Captain::Reservation + .where(contact_id: contact.id) + .where.not(status: 'draft') + .order(check_in_at: :desc) + .first + other_convs = contact.conversations.where.not(id: current_conversation.id) + + { + last_reservation_date: last_res&.check_in_at&.to_date&.iso8601, + last_reservation_status: last_res&.status, + last_reservation_amount: last_res&.total_amount&.to_f, + last_reservation_suite: last_res&.suite_identifier, + last_conversation_at: other_convs.maximum(:last_activity_at)&.iso8601, + total_conversations: other_convs.count + }.compact + end + def signed_headers(body) headers = { 'Content-Type' => 'application/json; charset=utf-8' } diff --git a/enterprise/app/services/captain/payments/confirmation_service.rb b/enterprise/app/services/captain/payments/confirmation_service.rb index f132eec1f..14c0a322b 100644 --- a/enterprise/app/services/captain/payments/confirmation_service.rb +++ b/enterprise/app/services/captain/payments/confirmation_service.rb @@ -20,6 +20,7 @@ class Captain::Payments::ConfirmationService end enqueue_roulette_offer! unless was_already_paid + notify_hermes_proactively! unless was_already_paid Rails.logger.info "[PaymentConfirmation] Reserva #{@reservation.id} confirmada (#{source_label})" end @@ -89,4 +90,14 @@ class Captain::Payments::ConfirmationService rescue StandardError => e Rails.logger.warn("[PaymentConfirmation] falha ao enfileirar roleta reserva=#{reservation.id}: #{e.class} - #{e.message}") end + + # Notifica o Hermes Agent (se a inbox estiver no fluxo Hermes) pra mandar + # mensagem espontânea pro cliente. Coexiste com o fluxo Captain interno — + # se a inbox NÃO estiver no Hermes, o job ignora silenciosamente. Side + # effect: nunca bloqueia a confirmação. + def notify_hermes_proactively! + Captain::Hermes::NotifyPaymentConfirmedJob.perform_later(reservation.id) + rescue StandardError => e + Rails.logger.warn("[PaymentConfirmation] falha ao notificar Hermes reserva=#{reservation.id}: #{e.class} - #{e.message}") + end end From d35084334c7094c268cc5668ccf11c926777269b Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 20:24:02 -0300 Subject: [PATCH 13/63] feat(captain/mcp): tools read-only pro Skill Construtor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 tools novas (admin scope, read-only) usadas pelo profile Hermes "construtor" durante criacao guiada de novos agentes: - list_assistants: lista todos os assistants da conta com badge engine (Captain interno vs Hermes), counts de scenarios/FAQs, marca/unidade - get_assistant_pricing: retorna tabela de preços parsed (Captain::Mcp:: PricingTables se Hermes, scenario fallback se Captain interno) - get_assistant_faqs: retorna FAQs aprovados em markdown (até 50) - save_agent_spec: salva JSON da especificacao em /tmp/agent-specs/.json (NÃO cria filesystem do profile nem registros DB — só persiste spec pra revisao/provisionamento separado depois) Construtor coleta perguntas socraticas, oferece "copiar de existente" pra acelerar quando marca for igual (Prime VL/AL/ADE preços iguais), e ao final salva spec.json pra admin revisar antes de provisionar. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../app/services/captain/mcp/tool_registry.rb | 7 +- .../mcp/tools/get_assistant_faqs_tool.rb | 57 +++++++++++ .../mcp/tools/get_assistant_pricing_tool.rb | 99 +++++++++++++++++++ .../captain/mcp/tools/list_assistants_tool.rb | 82 +++++++++++++++ .../captain/mcp/tools/save_agent_spec_tool.rb | 71 +++++++++++++ 5 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb create mode 100644 enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb diff --git a/enterprise/app/services/captain/mcp/tool_registry.rb b/enterprise/app/services/captain/mcp/tool_registry.rb index 747f5cac2..2d791d321 100644 --- a/enterprise/app/services/captain/mcp/tool_registry.rb +++ b/enterprise/app/services/captain/mcp/tool_registry.rb @@ -17,7 +17,12 @@ class Captain::Mcp::ToolRegistry Captain::Mcp::Tools::CheckPixPaymentTool, Captain::Mcp::Tools::SendSuiteImagesTool, Captain::Mcp::Tools::RescheduleReservationTool, - Captain::Mcp::Tools::ReactToMessageTool + Captain::Mcp::Tools::ReactToMessageTool, + # Construtor (admin scope) — usadas pelo profile Hermes "construtor" pra criar novos agentes + Captain::Mcp::Tools::ListAssistantsTool, + Captain::Mcp::Tools::GetAssistantPricingTool, + Captain::Mcp::Tools::GetAssistantFaqsTool, + Captain::Mcp::Tools::SaveAgentSpecTool # Captain::Mcp::Tools::HandoffTool — fluxo via automation hoje, MCP futuro ].freeze diff --git a/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb b/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb new file mode 100644 index 000000000..d7b45f448 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_assistant_faqs_tool.rb @@ -0,0 +1,57 @@ +# Tool MCP: retorna FAQs aprovadas de um assistente existente. +# +# Caso de uso: Construtor pergunta "copiar FAQs de outro assistente?". +# Tool retorna lista markdown de Q&A pra usuário avaliar e (próxima +# tool) salvar no spec do novo agente. +class Captain::Mcp::Tools::GetAssistantFaqsTool < Captain::Mcp::Tools::BaseTool + MAX_FAQS = 50 + + class << self + def name + 'get_assistant_faqs' + end + + def description + 'Retorna FAQs (perguntas/respostas) aprovadas de um assistente existente. ' \ + 'Use quando o usuário decidir copiar FAQs de outro assistente durante ' \ + 'criação. Retorna até 50 FAQs em markdown.' + end + + def input_schema + { + type: 'object', + properties: { + assistant_id: { + type: 'integer', + description: 'ID do assistente fonte (pegue via list_assistants).' + } + }, + required: ['assistant_id'] + } + end + end + + def call(args, _context:) # rubocop:disable Metrics/AbcSize + assistant = Captain::Assistant.find_by(id: args['assistant_id']) + return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank? + + faqs = assistant.responses + .where(documentable_type: nil) + .where(status: 'approved') + .order(:id) + .limit(MAX_FAQS) + + return text_response("_(#{assistant.name} não tem FAQs aprovados)_") if faqs.empty? + + lines = ["# FAQs de #{assistant.name} (#{faqs.size})", ''] + faqs.each_with_index do |f, i| + lines << "**#{i + 1}. #{f.question}**" + lines << f.answer.to_s.gsub(/\s+/, ' ').strip + lines << '' + end + text_response(lines.join("\n")) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::GetAssistantFaqsTool] error: #{e.class}: #{e.message}") + error_response("Erro ao buscar FAQs: #{e.message}") + end +end diff --git a/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb b/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb new file mode 100644 index 000000000..3b8e7ffb4 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/get_assistant_pricing_tool.rb @@ -0,0 +1,99 @@ +# Tool MCP: retorna tabela de preços de um assistente existente. +# +# Caso de uso: Construtor copia tabela durante criação de novo agente +# (mesma marca → mesma tabela). +# +# Estratégia de leitura (em ordem de tentativa): +# 1. Se assistant tem unit vinculada e Captain::Mcp::PricingTables +# conhece essa unit → retorna tabela estruturada (Hermes-friendly) +# 2. Senão tenta extrair markdown de scenarios do assistant (caminho +# legado — Captain interno usa scenarios pra guardar tabela) +# 3. Senão retorna mensagem de "não encontrado" +class Captain::Mcp::Tools::GetAssistantPricingTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'get_assistant_pricing' + end + + def description + 'Retorna a tabela de preços de um assistente existente em markdown. ' \ + 'Use quando o usuário (na criação de novo agente) decidir copiar ' \ + 'a tabela de outro assistente. Retorna estrutura categórias × períodos ' \ + 'com regras de pessoa extra.' + end + + def input_schema + { + type: 'object', + properties: { + assistant_id: { + type: 'integer', + description: 'ID do assistente fonte. Pegue via list_assistants.' + } + }, + required: ['assistant_id'] + } + end + end + + def call(args, _context:) + assistant = Captain::Assistant.find_by(id: args['assistant_id']) + return error_response("Assistente #{args['assistant_id']} não encontrado.") if assistant.blank? + + text_response(extract_pricing_markdown(assistant)) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::GetAssistantPricingTool] error: #{e.class}: #{e.message}") + error_response("Erro ao buscar tabela de preços: #{e.message}") + end + + private + + def extract_pricing_markdown(assistant) + structured = structured_pricing_for(assistant) + return structured if structured.present? + + scenario = pricing_scenario_for(assistant) + return scenario if scenario.present? + + "_(assistente #{assistant.name} não tem tabela de preços estruturada nem em scenario)_" + end + + # Lookup em Captain::Mcp::PricingTables (Hermes-side hardcoded). + def structured_pricing_for(assistant) + inbox = CaptainInbox.find_by(captain_assistant_id: assistant.id) + return nil if inbox.blank? + + unit = Captain::Unit.find_by(inbox_id: inbox.inbox_id) + return nil if unit.blank? + + table = Captain::Mcp::PricingTables::TABLES[unit.id] + return nil if table.blank? + + format_structured_table(unit, table) + end + + def format_structured_table(unit, table) + lines = ["# Tabela de preços — #{unit.name}", ''] + lines << '| Categoria | 3h | Pernoite Promo | Pernoite Integral | Diária | Pessoa extra a partir |' + lines << '|---|---|---|---|---|---|' + table[:categories].each do |key, data| + prices = data[:prices] + lines << "| #{key.tr('_', ' ').capitalize} | #{prices['3h']} | #{prices['pernoite_promo']} | " \ + "#{prices['pernoite_integral']} | #{prices['diaria']} | #{data[:extra_person_starts_at]}ª pessoa |" + end + lines << '' + lines << "**Taxa pessoa extra:** R$ #{table[:extra_person_fee]}" + lines.join("\n") + end + + # Captain interno guarda a tabela no instruction de algum scenario + # (geralmente o de reservas/preços). Retorna o markdown bruto pra + # usuário copiar ou Construtor parsear. + def pricing_scenario_for(assistant) + candidate = assistant.scenarios.where('LOWER(title) ~ ?', '(preç|tabela|reserva|valor)').first || + assistant.scenarios.first + return nil if candidate.blank? + + "# Scenario fonte — #{candidate.title}\n\n#{candidate.instruction}" + end +end diff --git a/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb b/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb new file mode 100644 index 000000000..80756627b --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/list_assistants_tool.rb @@ -0,0 +1,82 @@ +# Tool MCP: lista assistentes existentes pro agente Construtor consultar. +# +# Caso de uso: durante criação de novo agente Hermes via skill socrática, +# o Construtor pergunta "quer copiar tabela de preços de outro assistente?". +# Esta tool retorna todos os assistentes cadastrados com metadata útil pra +# escolha (nome, marca, engine, scenarios count, FAQs count). +# +# Read-only. Scope: account_id obrigatório (vem do header X-Captain-Account-Id +# ou body context). +class Captain::Mcp::Tools::ListAssistantsTool < Captain::Mcp::Tools::BaseTool + class << self + def name + 'list_assistants' + end + + def description + 'Lista todos os assistentes existentes da conta. Use durante criação ' \ + 'de novo agente pra oferecer "copiar tabela/regras/FAQs de outro ' \ + 'assistente". Retorna nome, id, marca/unidade, engine (interno ou hermes), ' \ + 'qtd de scenarios e FAQs pra cada um.' + end + + def input_schema + { + type: 'object', + properties: { + account_id: { + type: 'integer', + description: 'ID da conta. Default: account_id do contexto MCP.' + } + } + } + end + end + + def call(args, context:) + account_id = args['account_id'].presence || context[:account_id] + return error_response('account_id obrigatório.') if account_id.blank? + + account = Account.find_by(id: account_id) + return error_response("Account #{account_id} não encontrada.") if account.blank? + + rows = account.captain_assistants.order(:id).map { |a| describe(a) } + text_response(format_markdown(rows)) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::ListAssistantsTool] error: #{e.class}: #{e.message}") + error_response("Erro ao listar assistentes: #{e.message}") + end + + private + + def describe(assistant) + inboxes = CaptainInbox.where(captain_assistant_id: assistant.id).filter_map(&:inbox) + units = inboxes.filter_map { |i| Captain::Unit.find_by(inbox_id: i.id) } + { + id: assistant.id, + name: assistant.name, + engine: assistant.config.to_h['engine_type'].presence || 'internal', + scenarios: assistant.scenarios.count, + faqs: assistant.responses.count, + inboxes: inboxes.map(&:name), + units: units.map(&:name), + brand: units.first&.brand&.name + } + end + + def format_markdown(rows) + return '_(nenhum assistente cadastrado nesta conta)_' if rows.empty? + + lines = ['# Assistentes da conta', ''] + rows.each do |r| + engine_badge = r[:engine] == 'hermes' ? '⚡ Hermes' : '🧠 Captain interno' + lines << "## ##{r[:id]} — #{r[:name]} · #{engine_badge}" + lines << "- Marca: #{r[:brand].presence || '_não vinculada_'}" + lines << "- Inbox(es): #{r[:inboxes].join(', ').presence || '_nenhuma_'}" + lines << "- Unidade(s): #{r[:units].join(', ').presence || '_nenhuma_'}" + lines << "- Scenarios: #{r[:scenarios]} · FAQs: #{r[:faqs]}" + lines << '' + end + lines.join("\n") + end +end diff --git a/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb b/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb new file mode 100644 index 000000000..5a84b2ed2 --- /dev/null +++ b/enterprise/app/services/captain/mcp/tools/save_agent_spec_tool.rb @@ -0,0 +1,71 @@ +# Tool MCP: salva especificação completa de um novo agente Hermes em JSON. +# +# Caso de uso: ao final do fluxo socrático do Construtor, ele junta tudo +# que coletou (persona, marca, tabela, regras, FAQs, identidade da unidade) +# num JSON e chama esta tool pra persistir em /tmp/agent-specs/.json. +# +# **NÃO cria filesystem do profile Hermes** nem registros no Captain DB. +# Apenas salva a especificação. Provisionamento real é etapa SEPARADA +# (próxima sessão) — Construtor só coleta e prepara o JSON. +# +# Útil pra UI Captain depois listar specs prontas e o admin clicar +# "provisionar" — ou pra revisor humano validar antes de criar. +class Captain::Mcp::Tools::SaveAgentSpecTool < Captain::Mcp::Tools::BaseTool + SPEC_DIR = '/tmp/agent-specs'.freeze + + class << self + def name + 'save_agent_spec' + end + + def description + 'Salva especificação completa de um novo agente Hermes em JSON. Use ao ' \ + 'final do fluxo socrático quando tiver coletado TUDO: name, persona, ' \ + 'brand, unit, pricing, rules, faqs, identity. Não cria o agente — só ' \ + 'salva o spec pra revisão/provisionamento separado depois.' + end + + def input_schema + { + type: 'object', + properties: { + slug: { + type: 'string', + description: 'Slug único pro agente (ex: "jasmine_prime_al"). Lowercase, snake_case. Vai ser nome do arquivo.' + }, + spec: { + type: 'object', + description: 'JSON completo da especificação — persona, marca, unidade, tabela, regras, FAQs, identidade. ' \ + 'Estrutura livre, mas inclua todos os campos que coletou no fluxo.' + } + }, + required: %w[slug spec] + } + end + end + + def call(args, _context:) # rubocop:disable Metrics/AbcSize + slug = args['slug'].to_s.strip.downcase.gsub(/[^a-z0-9_]/, '_').squeeze('_') + return error_response('slug inválido (use lowercase, snake_case, só letras/números/underscore).') if slug.blank? || slug.length < 3 + + spec = args['spec'] + return error_response('spec deve ser um objeto JSON.') unless spec.is_a?(Hash) + + FileUtils.mkdir_p(SPEC_DIR) + path = File.join(SPEC_DIR, "#{slug}.json") + enriched = spec.merge( + 'slug' => slug, + 'saved_at' => Time.current.iso8601, + 'saved_by_tool' => 'mcp_save_agent_spec' + ) + File.write(path, JSON.pretty_generate(enriched)) + + text_response( + "Spec do agente '#{slug}' salvo em #{path}. " \ + 'Próximo passo (separado): admin revisa o JSON e dispara provisionamento real.' + ) + rescue StandardError => e + Rails.logger.error("[Captain::Mcp::SaveAgentSpecTool] error: #{e.class}: #{e.message}") + error_response("Erro ao salvar spec: #{e.message}") + end +end From 40fd0c8f507d55053d5cc575b811f9864e0e5f14 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Fri, 1 May 2026 21:00:41 -0300 Subject: [PATCH 14/63] feat(captain/hermes-builder): UI Vue + endpoints pra chat com Construtor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tela "Construtor" no painel Captain (acessivel em sidebar pra admins) que permite criar novo agente Hermes via chat guiado com agente Construtor (profile Hermes separado). Backend (admin scope): - POST /api/v1/accounts/:id/captain/hermes_builder — manda mensagem do admin pro gateway do Construtor (Hermes na porta 8646) - GET — retorna historico da sessao (Rails.cache, TTL 4h) - DELETE /reset — limpa sessao - POST /webhooks/captain/builder_callback — recebe respostas async do Construtor via plugin captain-http-callback do Hermes - HermesBuilder::Storage (Rails.cache) — persiste msgs por session_key (account_id + user_id) com role/content/created_at - HermesBuilder::Dispatcher — encaminha pro webhook do Construtor com HMAC opcional via ENV HERMES_BUILDER_WEBHOOK_SECRET Frontend: - Pagina Vue HermesBuilder/Index.vue — chat simples com: * Lista de mensagens com bubbles user/construtor * Indicador "digitando..." enquanto aguarda resposta * Input com Enter pra enviar / Shift+Enter pra nova linha * Polling 2s pra novas msgs * Botao Limpar conversa - API client em api/captain/hermesBuilder.js - Rota captain_hermes_builder_index (admin only) - Item no sidebar Captain "Construtor (Hermes)" - i18n keys CAPTAIN.HERMES_BUILDER em pt_BR + en UX flow: Admin abre tela → digita "olá" → Construtor pergunta nome → admin responde → marca, persona, tabela (com opcao copiar de existente), regras, FAQs, identidade → resumo → confirmar → Construtor chama save_agent_spec → JSON salvo em /tmp/agent-specs/.json pra revisao posterior. NAO cria filesystem do profile nem registros DB (etapa SEPARADA, prox sessao). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../hermes_builder_callback_controller.rb | 64 ++++ .../dashboard/api/captain/hermesBuilder.js | 22 ++ .../components-next/sidebar/Sidebar.vue | 6 + .../dashboard/i18n/locale/en/captain.json | 14 + .../dashboard/i18n/locale/en/settings.json | 1 + .../dashboard/i18n/locale/pt_BR/captain.json | 14 + .../dashboard/i18n/locale/pt_BR/settings.json | 1 + .../dashboard/captain/builder/Index.vue | 316 ++++++++++++++++++ .../dashboard/captain/captain.routes.js | 14 + config/routes.rb | 6 + .../captain/hermes_builder_controller.rb | 54 +++ .../app/services/hermes_builder/dispatcher.rb | 45 +++ .../app/services/hermes_builder/storage.rb | 32 ++ 13 files changed, 589 insertions(+) create mode 100644 app/controllers/webhooks/captain/hermes_builder_callback_controller.rb create mode 100644 app/javascript/dashboard/api/captain/hermesBuilder.js create mode 100644 app/javascript/dashboard/routes/dashboard/captain/builder/Index.vue create mode 100644 enterprise/app/controllers/enterprise/api/v1/accounts/captain/hermes_builder_controller.rb create mode 100644 enterprise/app/services/hermes_builder/dispatcher.rb create mode 100644 enterprise/app/services/hermes_builder/storage.rb diff --git a/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb b/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb new file mode 100644 index 000000000..362ff2297 --- /dev/null +++ b/app/controllers/webhooks/captain/hermes_builder_callback_controller.rb @@ -0,0 +1,64 @@ +# Recebe callback do Hermes Construtor (plugin captain-http-callback). +# +# Construtor responde async via POST pra esta URL com: +# { content: "", reply_to: ..., metadata: {...}, timestamp: ... } +# +# Este controller identifica a sessão do admin (por session_id no metadata +# OU pelo cache key derivado de account_id que veio na query string) e +# armazena a resposta no Rails.cache pra UI poder ler via polling. +class Webhooks::Captain::HermesBuilderCallbackController < ApplicationController + skip_before_action :verify_authenticity_token, raise: false + + def process_payload + content = params[:content].to_s.strip + return head :bad_request if content.blank? + + session_key = resolve_session_key + if session_key.blank? + Rails.logger.warn('[HermesBuilder::Callback] no session_key resolvable — ignorando') + return head :ok + end + + HermesBuilder::Storage.append(session_key, role: 'construtor', content: content) + Rails.logger.info("[HermesBuilder::Callback] reply received for #{session_key} (#{content.length} chars)") + + head :ok + rescue StandardError => e + Rails.logger.error("[HermesBuilder::Callback] error: #{e.class}: #{e.message}") + head :internal_server_error + end + + private + + # Estratégia: usar o session_id do metadata (Hermes propaga o chat_id). + # Fallback: account_id da query string + último user que mandou msg + # (raro, mas evita perder resposta). + def resolve_session_key + chat_id = params[:metadata]&.[](:chat_id) || params.dig(:metadata, 'chat_id') + if chat_id.is_a?(String) && chat_id.include?('builder-') + # Formato: webhook:construtor-admin:session:builder-- + session_id = chat_id.split(':').last + return "hermes_builder:#{session_id}" if session_id.start_with?('builder-') + end + + account_id = params[:account_id] + return nil if account_id.blank? + + # Fallback: pega últimas 5 sessões do account, retorna a mais recente + # com mensagens. Aceitável pra MVP com 1 admin testando por vez. + recent_session_key_for(account_id) + end + + def recent_session_key_for(account_id) + return nil unless Rails.cache.respond_to?(:redis) + + pattern = "hermes_builder:builder-#{account_id}-*" + keys = Rails.cache.redis.with { |c| c.keys(pattern) } + return nil if keys.blank? + + keys.first.sub(/^.*?(hermes_builder:.*)$/, '\1') + rescue StandardError => e + Rails.logger.warn("[HermesBuilder::Callback] recent_session_key fallback failed: #{e.class} - #{e.message}") + nil + end +end diff --git a/app/javascript/dashboard/api/captain/hermesBuilder.js b/app/javascript/dashboard/api/captain/hermesBuilder.js new file mode 100644 index 000000000..4782271c0 --- /dev/null +++ b/app/javascript/dashboard/api/captain/hermesBuilder.js @@ -0,0 +1,22 @@ +/* global axios */ +import ApiClient from '../ApiClient'; + +class HermesBuilder extends ApiClient { + constructor() { + super('captain/hermes_builder', { accountScoped: true }); + } + + fetchMessages() { + return axios.get(this.url); + } + + sendMessage(text) { + return axios.post(this.url, { text }); + } + + reset() { + return axios.delete(`${this.url}/reset`); + } +} + +export default new HermesBuilder(); diff --git a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue index e346e138b..7ac74128f 100644 --- a/app/javascript/dashboard/components-next/sidebar/Sidebar.vue +++ b/app/javascript/dashboard/components-next/sidebar/Sidebar.vue @@ -424,6 +424,12 @@ const menuItems = computed(() => { activeOn: ['captain_roleta_index'], to: accountScopedRoute('captain_roleta_index'), }, + { + name: 'HermesBuilder', + label: t('SIDEBAR.CAPTAIN_HERMES_BUILDER'), + activeOn: ['captain_hermes_builder_index'], + to: accountScopedRoute('captain_hermes_builder_index'), + }, { name: 'Funnel', label: t('SIDEBAR.CAPTAIN_FUNNEL'), diff --git a/app/javascript/dashboard/i18n/locale/en/captain.json b/app/javascript/dashboard/i18n/locale/en/captain.json index f80e866b0..fba5e0fa8 100644 --- a/app/javascript/dashboard/i18n/locale/en/captain.json +++ b/app/javascript/dashboard/i18n/locale/en/captain.json @@ -436,6 +436,20 @@ } }, "CAPTAIN": { + "HERMES_BUILDER": { + "TITLE": "Agent Builder", + "DESCRIPTION": "Create new Hermes agents through a guided chat with the Builder.", + "HEADER_TITLE": "Agent Builder", + "HEADER_DESCRIPTION": "Chat with the Builder to create a new Hermes agent. It asks questions and saves the spec as JSON for review at the end.", + "RESET": "Clear conversation", + "RESET_CONFIRM": "Clear current conversation with the Builder?", + "EMPTY_STATE": "Type \"hello\" to start. The Builder will guide you.", + "PLACEHOLDER": "Type and press Enter to send (Shift+Enter for new line)", + "SEND": "Send", + "SESSION_LABEL": "Session:", + "SEND_FAILED": "Send failed: {message}", + "RESET_FAILED": "Failed to clear session." + }, "BANNER": { "RESPONSES": "You have used more than 80% of your responses limit. To continue using Captain AI, please upgrade.", "DOCUMENTS": "Documents limit reached. Please upgrade to continue using Captain AI." diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index 065b4e0f3..d4bcc4ce5 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -350,6 +350,7 @@ "CAPTAIN_GALLERY": "Gallery", "CAPTAIN_RESERVATIONS": "Reservations", "CAPTAIN_ROLETA": "Roulette — Redeem", + "CAPTAIN_HERMES_BUILDER": "Builder (Hermes)", "CAPTAIN_FUNNEL": "Conversion Funnel", "CAPTAIN_LIFECYCLE": "Customer Journey", "CAPTAIN_REPORTS": "AI Reports", diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json index 183529ed2..b6a123cc1 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/captain.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/captain.json @@ -437,6 +437,20 @@ } }, "CAPTAIN": { + "HERMES_BUILDER": { + "TITLE": "Construtor de Agentes", + "DESCRIPTION": "Crie novos agentes Hermes via chat guiado com o Construtor.", + "HEADER_TITLE": "Construtor de Agentes", + "HEADER_DESCRIPTION": "Converse com o Construtor pra criar um novo agente Hermes. Ele faz perguntas e ao final salva a especificação em JSON pra revisão.", + "RESET": "Limpar conversa", + "RESET_CONFIRM": "Limpar conversa atual com o Construtor?", + "EMPTY_STATE": "Mande \"olá\" pra começar. O Construtor vai te guiar.", + "PLACEHOLDER": "Escreva e Enter pra enviar (Shift+Enter pula linha)", + "SEND": "Enviar", + "SESSION_LABEL": "Sessão:", + "SEND_FAILED": "Erro ao enviar: {message}", + "RESET_FAILED": "Falha ao limpar sessão." + }, "BANNER": { "RESPONSES": "Você usou mais de 80% do seu limite de respostas. Para continuar usando o Capitão IA, faça um upgrade.", "DOCUMENTS": "Limite de documentos atingido. Faça um upgrade para continuar usando o Capitão IA." diff --git a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json index ccb476592..aab1b7116 100644 --- a/app/javascript/dashboard/i18n/locale/pt_BR/settings.json +++ b/app/javascript/dashboard/i18n/locale/pt_BR/settings.json @@ -349,6 +349,7 @@ "CAPTAIN_GALLERY": "Galeria", "CAPTAIN_RESERVATIONS": "Reservas", "CAPTAIN_ROLETA": "Roleta — Resgate", + "CAPTAIN_HERMES_BUILDER": "Construtor (Hermes)", "CAPTAIN_FUNNEL": "Funil de Conversão", "CAPTAIN_LIFECYCLE": "Jornada do Cliente", "CAPTAIN_REPORTS": "Relatórios IA", diff --git a/app/javascript/dashboard/routes/dashboard/captain/builder/Index.vue b/app/javascript/dashboard/routes/dashboard/captain/builder/Index.vue new file mode 100644 index 000000000..d496d6946 --- /dev/null +++ b/app/javascript/dashboard/routes/dashboard/captain/builder/Index.vue @@ -0,0 +1,316 @@ + + +