iachat/hermes-plugins/captain-webhook/adapter.py
Rodribm10 d781f4a048 feat(hermes): plugin captain-webhook (stable session_chat_id)
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) <noreply@anthropic.com>
2026-05-01 15:16:05 -03:00

199 lines
7.9 KiB
Python

"""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:<route_name>:<delivery_id>"
# 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."
),
)