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
268 lines
8.6 KiB
Python
268 lines
8.6 KiB
Python
"""
|
|
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")
|