""" Aetheel Webhook Receiver ======================== HTTP endpoint that accepts POST requests from external systems and routes them through the AI handler as synthetic messages. Inspired by OpenClaw's /hooks/* gateway endpoints. External systems (GitHub, Jira, email services, custom scripts) can POST JSON payloads to wake the agent and trigger actions. Endpoints: POST /hooks/wake — Wake the agent with a text prompt POST /hooks/agent — Send a message to a specific agent session All endpoints require bearer token auth via the hooks.token config value. Usage: from webhooks.receiver import WebhookReceiver receiver = WebhookReceiver( ai_handler_fn=ai_handler, send_fn=send_to_channel, config=cfg.webhooks, ) receiver.start_async() # Runs in background thread """ import asyncio import json import logging import threading import uuid from dataclasses import dataclass from aiohttp import web logger = logging.getLogger("aetheel.webhooks") @dataclass class WebhookConfig: """Webhook receiver configuration.""" enabled: bool = False port: int = 8090 host: str = "127.0.0.1" token: str = "" # Bearer token for auth class WebhookReceiver: """ HTTP server that receives webhook POSTs from external systems. Endpoints: POST /hooks/wake — { "text": "Check my email" } POST /hooks/agent — { "message": "...", "channel": "slack", "agentId": "..." } GET /hooks/health — Health check (no auth required) """ def __init__( self, ai_handler_fn, send_fn, config: WebhookConfig, ): self._ai_handler = ai_handler_fn self._send_fn = send_fn self._config = config self._app = web.Application() self._runner: web.AppRunner | None = None self._thread: threading.Thread | None = None self._loop: asyncio.AbstractEventLoop | None = None self._setup_routes() def _setup_routes(self) -> None: self._app.router.add_post("/hooks/wake", self._handle_wake) self._app.router.add_post("/hooks/agent", self._handle_agent) self._app.router.add_get("/hooks/health", self._handle_health) # ------------------------------------------------------------------- # Auth # ------------------------------------------------------------------- def _check_auth(self, request: web.Request) -> bool: """Verify bearer token from Authorization header or query param.""" if not self._config.token: return True # No token configured = open access (dev mode) # Check Authorization header auth = request.headers.get("Authorization", "") if auth.startswith("Bearer ") and auth[7:] == self._config.token: return True # Check query param fallback if request.query.get("token") == self._config.token: return True return False # ------------------------------------------------------------------- # Handlers # ------------------------------------------------------------------- async def _handle_health(self, request: web.Request) -> web.Response: """Health check — no auth required.""" return web.json_response({"status": "ok"}) async def _handle_wake(self, request: web.Request) -> web.Response: """ Wake the agent with a text prompt. POST /hooks/wake Body: { "text": "Check my email for urgent items" } Auth: Bearer The text is routed through ai_handler as a synthetic message. Response includes the agent's reply. """ if not self._check_auth(request): return web.json_response({"error": "unauthorized"}, status=401) if request.method != "POST": return web.json_response({"error": "method not allowed"}, status=405) try: body = await request.json() except json.JSONDecodeError: return web.json_response({"error": "invalid JSON"}, status=400) text = body.get("text", "").strip() if not text: return web.json_response({"error": "text is required"}, status=400) # Import here to avoid circular dependency from adapters.base import IncomingMessage msg = IncomingMessage( text=text, user_id="webhook", user_name=body.get("sender", "Webhook"), channel_id=body.get("channel_id", "webhook"), channel_name="webhook", conversation_id=f"webhook-{uuid.uuid4().hex[:8]}", source="webhook", is_dm=True, raw_event={"webhook": True, "body": body}, ) loop = asyncio.get_event_loop() response_text = await loop.run_in_executor( None, self._ai_handler, msg ) # Optionally deliver to a channel channel = body.get("channel") channel_id = body.get("channel_id") if channel and channel_id and response_text: try: self._send_fn(channel_id, response_text, None, channel) except Exception as e: logger.warning(f"Failed to deliver webhook response: {e}") return web.json_response({ "status": "ok", "response": response_text or "", }) async def _handle_agent(self, request: web.Request) -> web.Response: """ Send a message to a specific agent session. POST /hooks/agent Body: { "message": "Research Python 3.14 features", "channel": "slack", "channel_id": "C123456", "agent_id": "main" } Auth: Bearer """ if not self._check_auth(request): return web.json_response({"error": "unauthorized"}, status=401) try: body = await request.json() except json.JSONDecodeError: return web.json_response({"error": "invalid JSON"}, status=400) message = body.get("message", "").strip() if not message: return web.json_response({"error": "message is required"}, status=400) channel = body.get("channel", "webhook") channel_id = body.get("channel_id", "webhook") from adapters.base import IncomingMessage msg = IncomingMessage( text=message, user_id="webhook", user_name=body.get("sender", "Webhook"), channel_id=channel_id, channel_name=channel, conversation_id=f"webhook-agent-{uuid.uuid4().hex[:8]}", source="webhook", is_dm=True, raw_event={"webhook": True, "agent_id": body.get("agent_id"), "body": body}, ) loop = asyncio.get_event_loop() response_text = await loop.run_in_executor( None, self._ai_handler, msg ) # Deliver to the specified channel if channel_id != "webhook" and response_text: try: self._send_fn(channel_id, response_text, None, channel) except Exception as e: logger.warning(f"Failed to deliver agent response: {e}") return web.json_response({ "status": "ok", "response": response_text or "", }) # ------------------------------------------------------------------- # Server lifecycle # ------------------------------------------------------------------- def start(self) -> None: """Start the webhook server (blocking).""" asyncio.run(self._run_server()) def start_async(self) -> None: """Start the webhook server in a background thread.""" self._thread = threading.Thread( target=self._run_async, daemon=True, name="webhooks" ) self._thread.start() def _run_async(self) -> None: self._loop = asyncio.new_event_loop() asyncio.set_event_loop(self._loop) self._loop.run_until_complete(self._run_server()) async def _run_server(self) -> None: self._runner = web.AppRunner(self._app) await self._runner.setup() site = web.TCPSite(self._runner, self._config.host, self._config.port) await site.start() logger.info( f"Webhook receiver running at " f"http://{self._config.host}:{self._config.port}/hooks/" ) try: while True: await asyncio.sleep(3600) except asyncio.CancelledError: pass def stop(self) -> None: if self._runner: if self._loop and self._loop.is_running(): asyncio.run_coroutine_threadsafe( self._runner.cleanup(), self._loop ) logger.info("Webhook receiver stopped")