"""Webhook support — receive incoming webhooks and send outgoing webhooks. Incoming: FastAPI endpoint that receives webhooks and routes them to the LLM. Outgoing: Send webhook notifications when events happen (goal completed, etc.). Webhooks allow external systems to trigger the LLM or be notified by it. """ from __future__ import annotations import hashlib import hmac import json import logging import time import urllib.request from dataclasses import dataclass, field from typing import Any, Callable logger = logging.getLogger(__name__) @dataclass class WebhookEndpoint: """A registered incoming webhook endpoint.""" path: str secret: str = "" handler: Callable[[dict], dict] | None = None calls: int = 0 last_call: float = 0.0 class WebhookManager: """Manages incoming and outgoing webhooks. Incoming webhooks are registered with a path and optional secret. When a POST request hits the path, the handler is called. Outgoing webhooks are sent to external URLs to notify them of events. """ def __init__(self) -> None: self._endpoints: dict[str, WebhookEndpoint] = {} self._outgoing: list[dict[str, str]] = [] self._stats = { "incoming_received": 0, "incoming_processed": 0, "incoming_rejected": 0, "outgoing_sent": 0, "outgoing_failed": 0, } def register_endpoint(self, path: str, handler: Callable[[dict], dict], secret: str = "") -> None: """Register an incoming webhook endpoint.""" self._endpoints[path] = WebhookEndpoint(path=path, secret=secret, handler=handler) logger.info("Registered webhook endpoint: %s", path) def handle_request(self, path: str, body: dict, signature: str = "") -> dict: """Handle an incoming webhook request. Args: path: webhook path body: request body signature: HMAC signature for verification Returns: Response dict """ self._stats["incoming_received"] += 1 endpoint = self._endpoints.get(path) if endpoint is None: self._stats["incoming_rejected"] += 1 return {"error": "Unknown webhook endpoint", "path": path} # Verify signature if secret is set if endpoint.secret: expected = hmac.new( endpoint.secret.encode(), json.dumps(body, sort_keys=True).encode(), hashlib.sha256 ).hexdigest() if not hmac.compare_digest(expected, signature): self._stats["incoming_rejected"] += 1 return {"error": "Invalid signature"} endpoint.calls += 1 endpoint.last_call = time.time() try: result = endpoint.handler(body) if endpoint.handler else {"received": True} self._stats["incoming_processed"] += 1 return result except Exception as e: logger.error("Webhook handler error: %s", e) return {"error": str(e)} def send(self, url: str, event: str, data: dict, secret: str = "") -> bool: """Send an outgoing webhook notification. Args: url: target URL event: event name data: payload secret: HMAC secret for signing Returns: True if sent successfully """ payload = json.dumps({"event": event, "data": data, "timestamp": time.time()}).encode() headers = {"Content-Type": "application/json"} if secret: signature = hmac.new(secret.encode(), payload, hashlib.sha256).hexdigest() headers["X-Webhook-Signature"] = signature try: req = urllib.request.Request(url, data=payload, method="POST", headers=headers) with urllib.request.urlopen(req, timeout=10) as resp: if resp.status < 300: self._stats["outgoing_sent"] += 1 self._outgoing.append({"url": url, "event": event, "time": str(time.time())}) logger.info("Webhook sent to %s: %s", url, event) return True except Exception as e: logger.error("Webhook send failed: %s", e) self._stats["outgoing_failed"] += 1 return False def notify_event(self, event: str, data: dict) -> None: """Notify all registered outgoing webhooks of an event.""" for hook in list(self._outgoing): self.send(hook["url"], event, data) def list_endpoints(self) -> list[str]: return list(self._endpoints.keys()) def get_stats(self) -> dict[str, Any]: return {**self._stats, "endpoints": len(self._endpoints)}