feat(hermes): plugin captain-http-callback (HTTP delivery adapter)
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/<rota> 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) <noreply@anthropic.com>
This commit is contained in:
parent
7700afd508
commit
89b471831d
76
hermes-plugins/captain-http-callback/README.md
Normal file
76
hermes-plugins/captain-http-callback/README.md
Normal file
@ -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/<rota> 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 <url-configurada> 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 <url-configurada> HTTP/1.1
|
||||
Content-Type: application/json; charset=utf-8
|
||||
X-Hermes-Callback-Signature: sha256=<hex-hmac> (opcional, se signing_secret configurado)
|
||||
|
||||
{
|
||||
"content": "<resposta gerada pelo agente>",
|
||||
"reply_to": "<id da mensagem original ou null>",
|
||||
"metadata": { ... },
|
||||
"timestamp": <unix epoch>
|
||||
}
|
||||
```
|
||||
|
||||
## 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=<gere com: openssl rand -hex 32>
|
||||
```
|
||||
|
||||
## 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.
|
||||
3
hermes-plugins/captain-http-callback/__init__.py
Normal file
3
hermes-plugins/captain-http-callback/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .adapter import register
|
||||
|
||||
__all__ = ["register"]
|
||||
216
hermes-plugins/captain-http-callback/adapter.py
Normal file
216
hermes-plugins/captain-http-callback/adapter.py
Normal file
@ -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=<hex>``
|
||||
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/<route> (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."
|
||||
),
|
||||
)
|
||||
14
hermes-plugins/captain-http-callback/plugin.yaml
Normal file
14
hermes-plugins/captain-http-callback/plugin.yaml
Normal file
@ -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)
|
||||
Loading…
Reference in New Issue
Block a user