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)