feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
"""Generic webhook platform adapter.
|
|
|
|
|
|
|
|
|
|
Runs an aiohttp HTTP server that receives webhook POSTs from external
|
|
|
|
|
services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC signatures,
|
|
|
|
|
transforms payloads into agent prompts, and routes responses back to the
|
|
|
|
|
source or to another configured platform.
|
|
|
|
|
|
|
|
|
|
Configuration lives in config.yaml under platforms.webhook.extra.routes.
|
|
|
|
|
Each route defines:
|
|
|
|
|
- events: which event types to accept (header-based filtering)
|
|
|
|
|
- secret: HMAC secret for signature validation (REQUIRED)
|
|
|
|
|
- prompt: template string formatted with the webhook payload
|
|
|
|
|
- skills: optional list of skills to load for the agent
|
|
|
|
|
- deliver: where to send the response (github_comment, telegram, etc.)
|
|
|
|
|
- deliver_extra: additional delivery config (repo, pr_number, chat_id)
|
|
|
|
|
|
|
|
|
|
Security:
|
|
|
|
|
- HMAC secret is required per route (validated at startup)
|
|
|
|
|
- Rate limiting per route (fixed-window, configurable)
|
|
|
|
|
- Idempotency cache prevents duplicate agent runs on webhook retries
|
|
|
|
|
- Body size limits checked before reading payload
|
|
|
|
|
- Set secret to "INSECURE_NO_AUTH" to skip validation (testing only)
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import asyncio
|
|
|
|
|
import hashlib
|
|
|
|
|
import hmac
|
|
|
|
|
import json
|
|
|
|
|
import logging
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
import os
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
import re
|
|
|
|
|
import subprocess
|
|
|
|
|
import time
|
|
|
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
from aiohttp import web
|
|
|
|
|
|
|
|
|
|
AIOHTTP_AVAILABLE = True
|
|
|
|
|
except ImportError:
|
|
|
|
|
AIOHTTP_AVAILABLE = False
|
|
|
|
|
web = None # type: ignore[assignment]
|
|
|
|
|
|
|
|
|
|
from gateway.config import Platform, PlatformConfig
|
|
|
|
|
from gateway.platforms.base import (
|
|
|
|
|
BasePlatformAdapter,
|
|
|
|
|
MessageEvent,
|
|
|
|
|
MessageType,
|
|
|
|
|
SendResult,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
|
|
|
|
DEFAULT_HOST = "0.0.0.0"
|
|
|
|
|
DEFAULT_PORT = 8644
|
|
|
|
|
_INSECURE_NO_AUTH = "INSECURE_NO_AUTH"
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
_DYNAMIC_ROUTES_FILENAME = "webhook_subscriptions.json"
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def check_webhook_requirements() -> bool:
|
|
|
|
|
"""Check if webhook adapter dependencies are available."""
|
|
|
|
|
return AIOHTTP_AVAILABLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class WebhookAdapter(BasePlatformAdapter):
|
|
|
|
|
"""Generic webhook receiver that triggers agent runs from HTTP POSTs."""
|
|
|
|
|
|
|
|
|
|
def __init__(self, config: PlatformConfig):
|
|
|
|
|
super().__init__(config, Platform.WEBHOOK)
|
|
|
|
|
self._host: str = config.extra.get("host", DEFAULT_HOST)
|
|
|
|
|
self._port: int = int(config.extra.get("port", DEFAULT_PORT))
|
|
|
|
|
self._global_secret: str = config.extra.get("secret", "")
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
self._static_routes: Dict[str, dict] = config.extra.get("routes", {})
|
|
|
|
|
self._dynamic_routes: Dict[str, dict] = {}
|
|
|
|
|
self._dynamic_routes_mtime: float = 0.0
|
|
|
|
|
self._routes: Dict[str, dict] = dict(self._static_routes)
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
self._runner = None
|
|
|
|
|
|
|
|
|
|
# Delivery info keyed by session chat_id — consumed by send()
|
|
|
|
|
self._delivery_info: Dict[str, dict] = {}
|
|
|
|
|
|
|
|
|
|
# Reference to gateway runner for cross-platform delivery (set externally)
|
|
|
|
|
self.gateway_runner = None
|
|
|
|
|
|
|
|
|
|
# Idempotency: TTL cache of recently processed delivery IDs.
|
|
|
|
|
# Prevents duplicate agent runs when webhook providers retry.
|
|
|
|
|
self._seen_deliveries: Dict[str, float] = {}
|
|
|
|
|
self._idempotency_ttl: int = 3600 # 1 hour
|
|
|
|
|
|
|
|
|
|
# Rate limiting: per-route timestamps in a fixed window.
|
|
|
|
|
self._rate_counts: Dict[str, List[float]] = {}
|
|
|
|
|
self._rate_limit: int = int(config.extra.get("rate_limit", 30)) # per minute
|
|
|
|
|
|
|
|
|
|
# Body size limit (auth-before-body pattern)
|
|
|
|
|
self._max_body_bytes: int = int(
|
|
|
|
|
config.extra.get("max_body_bytes", 1_048_576)
|
|
|
|
|
) # 1MB
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Lifecycle
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def connect(self) -> bool:
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
# Load agent-created subscriptions before validating
|
|
|
|
|
self._reload_dynamic_routes()
|
|
|
|
|
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
# Validate routes at startup — secret is required per route
|
|
|
|
|
for name, route in self._routes.items():
|
|
|
|
|
secret = route.get("secret", self._global_secret)
|
|
|
|
|
if not secret:
|
|
|
|
|
raise ValueError(
|
|
|
|
|
f"[webhook] Route '{name}' has no HMAC secret. "
|
|
|
|
|
f"Set 'secret' on the route or globally. "
|
|
|
|
|
f"For testing without auth, set secret to '{_INSECURE_NO_AUTH}'."
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
app = web.Application()
|
|
|
|
|
app.router.add_get("/health", self._handle_health)
|
|
|
|
|
app.router.add_post("/webhooks/{route_name}", self._handle_webhook)
|
|
|
|
|
|
feat: add profiles — run multiple isolated Hermes instances (#3681)
Each profile is a fully independent HERMES_HOME with its own config,
API keys, memory, sessions, skills, gateway, cron, and state.db.
Core module: hermes_cli/profiles.py (~900 lines)
- Profile CRUD: create, delete, list, show, rename
- Three clone levels: blank, --clone (config), --clone-all (everything)
- Export/import: tar.gz archive for backup and migration
- Wrapper alias scripts (~/.local/bin/<name>)
- Collision detection for alias names
- Sticky default via ~/.hermes/active_profile
- Skill seeding via subprocess (handles module-level caching)
- Auto-stop gateway on delete with disable-before-stop for services
- Tab completion generation for bash and zsh
CLI integration (hermes_cli/main.py):
- _apply_profile_override(): pre-import -p/--profile flag + sticky default
- Full 'hermes profile' subcommand: list, use, create, delete, show,
alias, rename, export, import
- 'hermes completion bash/zsh' command
- Multi-profile skill sync in hermes update
Display (cli.py, banner.py, gateway/run.py):
- CLI prompt: 'coder ❯' when using a non-default profile
- Banner shows profile name
- Gateway startup log includes profile name
Gateway safety:
- Token locks: Discord, Slack, WhatsApp, Signal (extends Telegram pattern)
- Port conflict detection: API server, webhook adapter
Diagnostics (hermes_cli/doctor.py):
- Profile health section: lists profiles, checks config, .env, aliases
- Orphan alias detection: warns when wrapper points to deleted profile
Tests (tests/hermes_cli/test_profiles.py):
- 71 automated tests covering: validation, CRUD, clone levels, rename,
export/import, active profile, isolation, alias collision, completion
- Full suite: 6760 passed, 0 new failures
Documentation:
- website/docs/user-guide/profiles.md: full user guide (12 sections)
- website/docs/reference/profile-commands.md: command reference (12 commands)
- website/docs/reference/faq.md: 6 profile FAQ entries
- website/sidebars.ts: navigation updated
2026-03-29 10:41:20 -07:00
|
|
|
# Port conflict detection — fail fast if port is already in use
|
|
|
|
|
import socket as _socket
|
|
|
|
|
try:
|
|
|
|
|
with _socket.socket(_socket.AF_INET, _socket.SOCK_STREAM) as _s:
|
|
|
|
|
_s.settimeout(1)
|
|
|
|
|
_s.connect(('127.0.0.1', self._port))
|
|
|
|
|
logger.error('[webhook] Port %d already in use. Set a different port in config.yaml: platforms.webhook.port', self._port)
|
|
|
|
|
return False
|
|
|
|
|
except (ConnectionRefusedError, OSError):
|
|
|
|
|
pass # port is free
|
|
|
|
|
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
self._runner = web.AppRunner(app)
|
|
|
|
|
await self._runner.setup()
|
|
|
|
|
site = web.TCPSite(self._runner, self._host, self._port)
|
|
|
|
|
await site.start()
|
|
|
|
|
self._mark_connected()
|
|
|
|
|
|
|
|
|
|
route_names = ", ".join(self._routes.keys()) or "(none configured)"
|
|
|
|
|
logger.info(
|
|
|
|
|
"[webhook] Listening on %s:%d — routes: %s",
|
|
|
|
|
self._host,
|
|
|
|
|
self._port,
|
|
|
|
|
route_names,
|
|
|
|
|
)
|
|
|
|
|
return True
|
|
|
|
|
|
|
|
|
|
async def disconnect(self) -> None:
|
|
|
|
|
if self._runner:
|
|
|
|
|
await self._runner.cleanup()
|
|
|
|
|
self._runner = None
|
|
|
|
|
self._mark_disconnected()
|
|
|
|
|
logger.info("[webhook] Disconnected")
|
|
|
|
|
|
|
|
|
|
async def send(
|
|
|
|
|
self,
|
|
|
|
|
chat_id: str,
|
|
|
|
|
content: str,
|
|
|
|
|
reply_to: Optional[str] = None,
|
|
|
|
|
metadata: Optional[Dict[str, Any]] = None,
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
"""Deliver the agent's response to the configured destination.
|
|
|
|
|
|
|
|
|
|
chat_id is ``webhook:{route}:{delivery_id}`` — we pop the delivery
|
|
|
|
|
info stored during webhook receipt so it doesn't leak memory.
|
|
|
|
|
"""
|
|
|
|
|
delivery = self._delivery_info.pop(chat_id, {})
|
|
|
|
|
deliver_type = delivery.get("deliver", "log")
|
|
|
|
|
|
|
|
|
|
if deliver_type == "log":
|
|
|
|
|
logger.info("[webhook] Response for %s: %s", chat_id, content[:200])
|
|
|
|
|
return SendResult(success=True)
|
|
|
|
|
|
|
|
|
|
if deliver_type == "github_comment":
|
|
|
|
|
return await self._deliver_github_comment(content, delivery)
|
|
|
|
|
|
|
|
|
|
# Cross-platform delivery (telegram, discord, etc.)
|
|
|
|
|
if self.gateway_runner and deliver_type in (
|
|
|
|
|
"telegram",
|
|
|
|
|
"discord",
|
|
|
|
|
"slack",
|
|
|
|
|
"signal",
|
|
|
|
|
"sms",
|
|
|
|
|
):
|
|
|
|
|
return await self._deliver_cross_platform(
|
|
|
|
|
deliver_type, content, delivery
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.warning("[webhook] Unknown deliver type: %s", deliver_type)
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False, error=f"Unknown deliver type: {deliver_type}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
async def get_chat_info(self, chat_id: str) -> Dict[str, Any]:
|
|
|
|
|
return {"name": chat_id, "type": "webhook"}
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# HTTP handlers
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def _handle_health(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""GET /health — simple health check."""
|
|
|
|
|
return web.json_response({"status": "ok", "platform": "webhook"})
|
|
|
|
|
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
def _reload_dynamic_routes(self) -> None:
|
|
|
|
|
"""Reload agent-created subscriptions from disk if the file changed."""
|
|
|
|
|
from pathlib import Path as _Path
|
|
|
|
|
hermes_home = _Path(
|
|
|
|
|
os.getenv("HERMES_HOME", str(_Path.home() / ".hermes"))
|
|
|
|
|
).expanduser()
|
|
|
|
|
subs_path = hermes_home / _DYNAMIC_ROUTES_FILENAME
|
|
|
|
|
if not subs_path.exists():
|
|
|
|
|
if self._dynamic_routes:
|
|
|
|
|
self._dynamic_routes = {}
|
|
|
|
|
self._routes = dict(self._static_routes)
|
|
|
|
|
logger.debug("[webhook] Dynamic subscriptions file removed, cleared dynamic routes")
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
mtime = subs_path.stat().st_mtime
|
|
|
|
|
if mtime <= self._dynamic_routes_mtime:
|
|
|
|
|
return # No change
|
|
|
|
|
data = json.loads(subs_path.read_text(encoding="utf-8"))
|
|
|
|
|
if not isinstance(data, dict):
|
|
|
|
|
return
|
|
|
|
|
# Merge: static routes take precedence over dynamic ones
|
|
|
|
|
self._dynamic_routes = {
|
|
|
|
|
k: v for k, v in data.items()
|
|
|
|
|
if k not in self._static_routes
|
|
|
|
|
}
|
|
|
|
|
self._routes = {**self._dynamic_routes, **self._static_routes}
|
|
|
|
|
self._dynamic_routes_mtime = mtime
|
|
|
|
|
logger.info(
|
|
|
|
|
"[webhook] Reloaded %d dynamic route(s): %s",
|
|
|
|
|
len(self._dynamic_routes),
|
|
|
|
|
", ".join(self._dynamic_routes.keys()) or "(none)",
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("[webhook] Failed to reload dynamic routes: %s", e)
|
|
|
|
|
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
async def _handle_webhook(self, request: "web.Request") -> "web.Response":
|
|
|
|
|
"""POST /webhooks/{route_name} — receive and process a webhook event."""
|
feat(webhook): hermes webhook CLI + skill for event-driven subscriptions (#3578)
Adds 'hermes webhook' CLI subcommand and a skill — zero new model tools.
CLI commands (require webhook platform to be enabled):
hermes webhook subscribe <name> [--events, --prompt, --deliver, ...]
hermes webhook list
hermes webhook remove <name>
hermes webhook test <name>
All commands gate on webhook platform being enabled in config. If not
configured, prints setup instructions (gateway setup wizard, manual
config.yaml, or env vars).
The agent uses these via terminal tool, guided by the webhook-subscriptions
skill which documents setup, common patterns (GitHub, Stripe, CI/CD,
monitoring), prompt template syntax, security, and troubleshooting.
Adapter enhancement: webhook.py hot-reloads dynamic subscriptions from
~/.hermes/webhook_subscriptions.json on each incoming request (mtime-gated).
Static config.yaml routes always take precedence.
Docs: updated webhooks.md with Dynamic Subscriptions section, added
hermes webhook to cli-commands.md reference.
No new model tools. No toolset changes.
24 new tests for CLI CRUD, persistence, enabled-gate, and adapter
dynamic route loading.
2026-03-28 14:33:35 -07:00
|
|
|
# Hot-reload dynamic subscriptions on each request (mtime-gated, cheap)
|
|
|
|
|
self._reload_dynamic_routes()
|
|
|
|
|
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
route_name = request.match_info.get("route_name", "")
|
|
|
|
|
route_config = self._routes.get(route_name)
|
|
|
|
|
|
|
|
|
|
if not route_config:
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": f"Unknown route: {route_name}"}, status=404
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Auth-before-body ─────────────────────────────────────
|
|
|
|
|
# Check Content-Length before reading the full payload.
|
|
|
|
|
content_length = request.content_length or 0
|
|
|
|
|
if content_length > self._max_body_bytes:
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": "Payload too large"}, status=413
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Rate limiting ────────────────────────────────────────
|
|
|
|
|
now = time.time()
|
|
|
|
|
window = self._rate_counts.setdefault(route_name, [])
|
|
|
|
|
window[:] = [t for t in window if now - t < 60]
|
|
|
|
|
if len(window) >= self._rate_limit:
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": "Rate limit exceeded"}, status=429
|
|
|
|
|
)
|
|
|
|
|
window.append(now)
|
|
|
|
|
|
|
|
|
|
# Read body
|
|
|
|
|
try:
|
|
|
|
|
raw_body = await request.read()
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("[webhook] Failed to read body: %s", e)
|
|
|
|
|
return web.json_response({"error": "Bad request"}, status=400)
|
|
|
|
|
|
|
|
|
|
# Validate HMAC signature (skip for INSECURE_NO_AUTH testing mode)
|
|
|
|
|
secret = route_config.get("secret", self._global_secret)
|
|
|
|
|
if secret and secret != _INSECURE_NO_AUTH:
|
|
|
|
|
if not self._validate_signature(request, raw_body, secret):
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[webhook] Invalid signature for route %s", route_name
|
|
|
|
|
)
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": "Invalid signature"}, status=401
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Parse payload
|
|
|
|
|
try:
|
|
|
|
|
payload = json.loads(raw_body)
|
|
|
|
|
except json.JSONDecodeError:
|
|
|
|
|
# Try form-encoded as fallback
|
|
|
|
|
try:
|
|
|
|
|
import urllib.parse
|
|
|
|
|
|
|
|
|
|
payload = dict(
|
|
|
|
|
urllib.parse.parse_qsl(raw_body.decode("utf-8"))
|
|
|
|
|
)
|
|
|
|
|
except Exception:
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"error": "Cannot parse body"}, status=400
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Check event type filter
|
|
|
|
|
event_type = (
|
|
|
|
|
request.headers.get("X-GitHub-Event", "")
|
|
|
|
|
or request.headers.get("X-GitLab-Event", "")
|
|
|
|
|
or payload.get("event_type", "")
|
|
|
|
|
or "unknown"
|
|
|
|
|
)
|
|
|
|
|
allowed_events = route_config.get("events", [])
|
|
|
|
|
if allowed_events and event_type not in allowed_events:
|
|
|
|
|
logger.debug(
|
|
|
|
|
"[webhook] Ignoring event %s for route %s (allowed: %s)",
|
|
|
|
|
event_type,
|
|
|
|
|
route_name,
|
|
|
|
|
allowed_events,
|
|
|
|
|
)
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"status": "ignored", "event": event_type}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Format prompt from template
|
|
|
|
|
prompt_template = route_config.get("prompt", "")
|
|
|
|
|
prompt = self._render_prompt(
|
|
|
|
|
prompt_template, payload, event_type, route_name
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Inject skill content if configured.
|
|
|
|
|
# We call build_skill_invocation_message() directly rather than
|
|
|
|
|
# using /skill-name slash commands — the gateway's command parser
|
|
|
|
|
# would intercept those and break the flow.
|
|
|
|
|
skills = route_config.get("skills", [])
|
|
|
|
|
if skills:
|
|
|
|
|
try:
|
|
|
|
|
from agent.skill_commands import (
|
|
|
|
|
build_skill_invocation_message,
|
|
|
|
|
get_skill_commands,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
skill_cmds = get_skill_commands()
|
|
|
|
|
for skill_name in skills:
|
|
|
|
|
cmd_key = f"/{skill_name}"
|
|
|
|
|
if cmd_key in skill_cmds:
|
|
|
|
|
skill_content = build_skill_invocation_message(
|
|
|
|
|
cmd_key, user_instruction=prompt
|
|
|
|
|
)
|
|
|
|
|
if skill_content:
|
|
|
|
|
prompt = skill_content
|
|
|
|
|
break # Load the first matching skill
|
|
|
|
|
else:
|
|
|
|
|
logger.warning(
|
|
|
|
|
"[webhook] Skill '%s' not found", skill_name
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.warning("[webhook] Skill loading failed: %s", e)
|
|
|
|
|
|
|
|
|
|
# Build a unique delivery ID
|
|
|
|
|
delivery_id = request.headers.get(
|
|
|
|
|
"X-GitHub-Delivery",
|
|
|
|
|
request.headers.get("X-Request-ID", str(int(time.time() * 1000))),
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ── Idempotency ─────────────────────────────────────────
|
|
|
|
|
# Skip duplicate deliveries (webhook retries).
|
|
|
|
|
now = time.time()
|
|
|
|
|
# Prune expired entries
|
|
|
|
|
self._seen_deliveries = {
|
|
|
|
|
k: v
|
|
|
|
|
for k, v in self._seen_deliveries.items()
|
|
|
|
|
if now - v < self._idempotency_ttl
|
|
|
|
|
}
|
|
|
|
|
if delivery_id in self._seen_deliveries:
|
|
|
|
|
logger.info(
|
|
|
|
|
"[webhook] Skipping duplicate delivery %s", delivery_id
|
|
|
|
|
)
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{"status": "duplicate", "delivery_id": delivery_id},
|
|
|
|
|
status=200,
|
|
|
|
|
)
|
|
|
|
|
self._seen_deliveries[delivery_id] = now
|
|
|
|
|
|
|
|
|
|
# Use delivery_id in session key so concurrent webhooks on the
|
|
|
|
|
# same route get independent agent runs (not queued/interrupted).
|
|
|
|
|
session_chat_id = f"webhook:{route_name}:{delivery_id}"
|
|
|
|
|
|
|
|
|
|
# Store delivery info for send() — consumed (popped) on delivery
|
|
|
|
|
deliver_config = {
|
|
|
|
|
"deliver": route_config.get("deliver", "log"),
|
|
|
|
|
"deliver_extra": self._render_delivery_extra(
|
|
|
|
|
route_config.get("deliver_extra", {}), payload
|
|
|
|
|
),
|
|
|
|
|
"payload": payload,
|
|
|
|
|
}
|
|
|
|
|
self._delivery_info[session_chat_id] = deliver_config
|
|
|
|
|
|
|
|
|
|
# Build source and event
|
|
|
|
|
source = self.build_source(
|
|
|
|
|
chat_id=session_chat_id,
|
|
|
|
|
chat_name=f"webhook/{route_name}",
|
|
|
|
|
chat_type="webhook",
|
|
|
|
|
user_id=f"webhook:{route_name}",
|
|
|
|
|
user_name=route_name,
|
|
|
|
|
)
|
|
|
|
|
event = MessageEvent(
|
|
|
|
|
text=prompt,
|
|
|
|
|
message_type=MessageType.TEXT,
|
|
|
|
|
source=source,
|
|
|
|
|
raw_message=payload,
|
|
|
|
|
message_id=delivery_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
logger.info(
|
|
|
|
|
"[webhook] %s event=%s route=%s prompt_len=%d delivery=%s",
|
|
|
|
|
request.method,
|
|
|
|
|
event_type,
|
|
|
|
|
route_name,
|
|
|
|
|
len(prompt),
|
|
|
|
|
delivery_id,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Non-blocking — return 202 Accepted immediately
|
2026-03-26 14:36:24 -07:00
|
|
|
task = asyncio.create_task(self.handle_message(event))
|
|
|
|
|
self._background_tasks.add(task)
|
|
|
|
|
task.add_done_callback(self._background_tasks.discard)
|
feat(gateway): add webhook platform adapter for external event triggers
Add a generic webhook platform adapter that receives HTTP POSTs from
external services (GitHub, GitLab, JIRA, Stripe, etc.), validates HMAC
signatures, transforms payloads into agent prompts, and routes responses
back to the source or to another platform.
Features:
- Configurable routes with per-route HMAC secrets, event filters,
prompt templates with dot-notation payload access, skill loading,
and pluggable delivery (github_comment, telegram, discord, log)
- HMAC signature validation (GitHub SHA-256, GitLab token, generic)
- Rate limiting (30 req/min per route, configurable)
- Idempotency cache (1hr TTL, prevents duplicate runs on retries)
- Body size limits (1MB default, checked before reading payload)
- Setup wizard integration with security warnings and docs links
- 33 tests (29 unit + 4 integration), all passing
Security:
- HMAC secret required per route (startup validation)
- Setup wizard warns about internet exposure for webhook/SMS platforms
- Sandboxing (Docker/VM) recommended in docs for public-facing deployments
Files changed:
- gateway/config.py — Platform.WEBHOOK enum + env var overrides
- gateway/platforms/webhook.py — WebhookAdapter (~420 lines)
- gateway/run.py — factory wiring + auth bypass for webhook events
- hermes_cli/config.py — WEBHOOK_* env var definitions
- hermes_cli/setup.py — webhook section in setup_gateway()
- tests/gateway/test_webhook_adapter.py — 29 unit tests
- tests/gateway/test_webhook_integration.py — 4 integration tests
- website/docs/user-guide/messaging/webhooks.md — full user docs
- website/docs/reference/environment-variables.md — WEBHOOK_* vars
- website/sidebars.ts — nav entry
2026-03-20 06:33:36 -07:00
|
|
|
|
|
|
|
|
return web.json_response(
|
|
|
|
|
{
|
|
|
|
|
"status": "accepted",
|
|
|
|
|
"route": route_name,
|
|
|
|
|
"event": event_type,
|
|
|
|
|
"delivery_id": delivery_id,
|
|
|
|
|
},
|
|
|
|
|
status=202,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Signature validation
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _validate_signature(
|
|
|
|
|
self, request: "web.Request", body: bytes, secret: str
|
|
|
|
|
) -> bool:
|
|
|
|
|
"""Validate webhook signature (GitHub, GitLab, generic HMAC-SHA256)."""
|
|
|
|
|
# GitHub: X-Hub-Signature-256 = sha256=<hex>
|
|
|
|
|
gh_sig = request.headers.get("X-Hub-Signature-256", "")
|
|
|
|
|
if gh_sig:
|
|
|
|
|
expected = "sha256=" + hmac.new(
|
|
|
|
|
secret.encode(), body, hashlib.sha256
|
|
|
|
|
).hexdigest()
|
|
|
|
|
return hmac.compare_digest(gh_sig, expected)
|
|
|
|
|
|
|
|
|
|
# GitLab: X-Gitlab-Token = <plain secret>
|
|
|
|
|
gl_token = request.headers.get("X-Gitlab-Token", "")
|
|
|
|
|
if gl_token:
|
|
|
|
|
return hmac.compare_digest(gl_token, secret)
|
|
|
|
|
|
|
|
|
|
# Generic: X-Webhook-Signature = <hex HMAC-SHA256>
|
|
|
|
|
generic_sig = request.headers.get("X-Webhook-Signature", "")
|
|
|
|
|
if generic_sig:
|
|
|
|
|
expected = hmac.new(
|
|
|
|
|
secret.encode(), body, hashlib.sha256
|
|
|
|
|
).hexdigest()
|
|
|
|
|
return hmac.compare_digest(generic_sig, expected)
|
|
|
|
|
|
|
|
|
|
# No recognised signature header but secret is configured → reject
|
|
|
|
|
logger.debug(
|
|
|
|
|
"[webhook] Secret configured but no signature header found"
|
|
|
|
|
)
|
|
|
|
|
return False
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Prompt rendering
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def _render_prompt(
|
|
|
|
|
self,
|
|
|
|
|
template: str,
|
|
|
|
|
payload: dict,
|
|
|
|
|
event_type: str,
|
|
|
|
|
route_name: str,
|
|
|
|
|
) -> str:
|
|
|
|
|
"""Render a prompt template with the webhook payload.
|
|
|
|
|
|
|
|
|
|
Supports dot-notation access into nested dicts:
|
|
|
|
|
``{pull_request.title}`` → ``payload["pull_request"]["title"]``
|
|
|
|
|
"""
|
|
|
|
|
if not template:
|
|
|
|
|
truncated = json.dumps(payload, indent=2)[:4000]
|
|
|
|
|
return (
|
|
|
|
|
f"Webhook event '{event_type}' on route "
|
|
|
|
|
f"'{route_name}':\n\n```json\n{truncated}\n```"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
def _resolve(match: re.Match) -> str:
|
|
|
|
|
key = match.group(1)
|
|
|
|
|
value: Any = payload
|
|
|
|
|
for part in key.split("."):
|
|
|
|
|
if isinstance(value, dict):
|
|
|
|
|
value = value.get(part, f"{{{key}}}")
|
|
|
|
|
else:
|
|
|
|
|
return f"{{{key}}}"
|
|
|
|
|
if isinstance(value, (dict, list)):
|
|
|
|
|
return json.dumps(value, indent=2)[:2000]
|
|
|
|
|
return str(value)
|
|
|
|
|
|
|
|
|
|
return re.sub(r"\{([a-zA-Z0-9_.]+)\}", _resolve, template)
|
|
|
|
|
|
|
|
|
|
def _render_delivery_extra(
|
|
|
|
|
self, extra: dict, payload: dict
|
|
|
|
|
) -> dict:
|
|
|
|
|
"""Render delivery_extra template values with payload data."""
|
|
|
|
|
rendered: Dict[str, Any] = {}
|
|
|
|
|
for key, value in extra.items():
|
|
|
|
|
if isinstance(value, str):
|
|
|
|
|
rendered[key] = self._render_prompt(value, payload, "", "")
|
|
|
|
|
else:
|
|
|
|
|
rendered[key] = value
|
|
|
|
|
return rendered
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Response delivery
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
async def _deliver_github_comment(
|
|
|
|
|
self, content: str, delivery: dict
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
"""Post agent response as a GitHub PR/issue comment via ``gh`` CLI."""
|
|
|
|
|
extra = delivery.get("deliver_extra", {})
|
|
|
|
|
repo = extra.get("repo", "")
|
|
|
|
|
pr_number = extra.get("pr_number", "")
|
|
|
|
|
|
|
|
|
|
if not repo or not pr_number:
|
|
|
|
|
logger.error(
|
|
|
|
|
"[webhook] github_comment delivery missing repo or pr_number"
|
|
|
|
|
)
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False, error="Missing repo or pr_number"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
[
|
|
|
|
|
"gh",
|
|
|
|
|
"pr",
|
|
|
|
|
"comment",
|
|
|
|
|
str(pr_number),
|
|
|
|
|
"--repo",
|
|
|
|
|
repo,
|
|
|
|
|
"--body",
|
|
|
|
|
content,
|
|
|
|
|
],
|
|
|
|
|
capture_output=True,
|
|
|
|
|
text=True,
|
|
|
|
|
timeout=30,
|
|
|
|
|
)
|
|
|
|
|
if result.returncode == 0:
|
|
|
|
|
logger.info(
|
|
|
|
|
"[webhook] Posted comment on %s#%s", repo, pr_number
|
|
|
|
|
)
|
|
|
|
|
return SendResult(success=True)
|
|
|
|
|
else:
|
|
|
|
|
logger.error(
|
|
|
|
|
"[webhook] gh pr comment failed: %s", result.stderr
|
|
|
|
|
)
|
|
|
|
|
return SendResult(success=False, error=result.stderr)
|
|
|
|
|
except FileNotFoundError:
|
|
|
|
|
logger.error(
|
|
|
|
|
"[webhook] 'gh' CLI not found — install GitHub CLI for "
|
|
|
|
|
"github_comment delivery"
|
|
|
|
|
)
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False, error="gh CLI not installed"
|
|
|
|
|
)
|
|
|
|
|
except Exception as e:
|
|
|
|
|
logger.error("[webhook] github_comment delivery error: %s", e)
|
|
|
|
|
return SendResult(success=False, error=str(e))
|
|
|
|
|
|
|
|
|
|
async def _deliver_cross_platform(
|
|
|
|
|
self, platform_name: str, content: str, delivery: dict
|
|
|
|
|
) -> SendResult:
|
|
|
|
|
"""Route response to another platform (telegram, discord, etc.)."""
|
|
|
|
|
if not self.gateway_runner:
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False,
|
|
|
|
|
error="No gateway runner for cross-platform delivery",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
try:
|
|
|
|
|
target_platform = Platform(platform_name)
|
|
|
|
|
except ValueError:
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False, error=f"Unknown platform: {platform_name}"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
adapter = self.gateway_runner.adapters.get(target_platform)
|
|
|
|
|
if not adapter:
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False,
|
|
|
|
|
error=f"Platform {platform_name} not connected",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
# Use home channel if no specific chat_id in deliver_extra
|
|
|
|
|
extra = delivery.get("deliver_extra", {})
|
|
|
|
|
chat_id = extra.get("chat_id", "")
|
|
|
|
|
if not chat_id:
|
|
|
|
|
home = self.gateway_runner.config.get_home_channel(target_platform)
|
|
|
|
|
if home:
|
|
|
|
|
chat_id = home.chat_id
|
|
|
|
|
else:
|
|
|
|
|
return SendResult(
|
|
|
|
|
success=False,
|
|
|
|
|
error=f"No chat_id or home channel for {platform_name}",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return await adapter.send(chat_id, content)
|