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:
2026-02-18 01:07:12 -05:00
parent 41b2f9a593
commit 6d73f74e0b
41 changed files with 11363 additions and 437 deletions

267
webhooks/receiver.py Normal file
View 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")