feat: config-driven architecture, install wizard, live runtime switching, usage tracking, auto-failover
Major changes: - Config-driven adapters: all channels (Slack, Discord, Telegram, WebChat, Webhooks) controlled via config.json with enabled flags and token auto-detection, no CLI flags required - Runtime engine field: runtime.engine selects opencode/claude from config - Interactive install script: 8-phase setup wizard with AI runtime detection/installation, token setup, identity file personalization (personality presets), aetheel CLI command, background service (launchd/systemd) - Live runtime switching: /engine, /model, /provider commands hot-swap the AI runtime from chat without restart, changes persisted to config.json - Usage tracking: per-request cost extraction from Claude Code JSON output, cumulative stats via /usage command - Auto-failover: rate limit detection on both runtimes, automatic switch to other engine on quota errors with user notification - Chat commands work without / prefix (Slack intercepts / in channels), commands: engine, model, provider, config, usage, reload, cron, subagents, status, help - /config set for editing config.json from chat with dotted key notation - Security audit saved to docs/security-audit.md - Full command reference in docs/commands.md - Future changes doc with NanoClaw agent teams analysis - Logo added to README and WebChat UI - README fully rewritten with all features documented
This commit is contained in:
267
webhooks/receiver.py
Normal file
267
webhooks/receiver.py
Normal file
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
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 <token>
|
||||
|
||||
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 <token>
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user