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

171
tests/test_webhooks.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Tests for the Webhook receiver.
"""
import json
import pytest
from aiohttp.test_utils import TestClient, TestServer
from webhooks.receiver import WebhookReceiver, WebhookConfig
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_handler(response_text="AI response"):
def handler(msg):
return response_text
return handler
def _make_send_fn():
calls = []
def send(channel_id, text, thread_id, channel_type):
calls.append({"channel_id": channel_id, "text": text, "channel_type": channel_type})
send.calls = calls
return send
def _make_receiver(token="", handler=None, send_fn=None):
config = WebhookConfig(enabled=True, port=0, host="127.0.0.1", token=token)
return WebhookReceiver(
ai_handler_fn=handler or _make_handler(),
send_fn=send_fn or _make_send_fn(),
config=config,
)
# ---------------------------------------------------------------------------
# Tests: Health endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_health_endpoint():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.get("/hooks/health")
assert resp.status == 200
data = await resp.json()
assert data["status"] == "ok"
# ---------------------------------------------------------------------------
# Tests: Wake endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_wake_no_auth():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={"text": "hello"})
assert resp.status == 200
data = await resp.json()
assert data["response"] == "AI response"
@pytest.mark.asyncio
async def test_wake_auth_required():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
# No auth → 401
resp = await client.post("/hooks/wake", json={"text": "hello"})
assert resp.status == 401
# With auth → 200
resp = await client.post(
"/hooks/wake",
json={"text": "hello"},
headers={"Authorization": "Bearer secret"},
)
assert resp.status == 200
@pytest.mark.asyncio
async def test_wake_empty_text():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={"text": ""})
assert resp.status == 400
@pytest.mark.asyncio
async def test_wake_invalid_json():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/wake",
data="not json",
headers={"Content-Type": "application/json"},
)
assert resp.status == 400
@pytest.mark.asyncio
async def test_wake_delivers_to_channel():
send_fn = _make_send_fn()
receiver = _make_receiver(handler=_make_handler("response"), send_fn=send_fn)
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/wake", json={
"text": "hello",
"channel": "slack",
"channel_id": "C123",
})
assert resp.status == 200
assert len(send_fn.calls) == 1
assert send_fn.calls[0]["channel_id"] == "C123"
# ---------------------------------------------------------------------------
# Tests: Agent endpoint
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_agent_endpoint():
send_fn = _make_send_fn()
receiver = _make_receiver(send_fn=send_fn)
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/agent", json={
"message": "research something",
"channel": "slack",
"channel_id": "C456",
})
assert resp.status == 200
assert len(send_fn.calls) == 1
@pytest.mark.asyncio
async def test_agent_empty_message():
receiver = _make_receiver()
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post("/hooks/agent", json={"message": ""})
assert resp.status == 400
@pytest.mark.asyncio
async def test_agent_auth_bearer():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/agent",
json={"message": "test"},
headers={"Authorization": "Bearer secret"},
)
assert resp.status == 200
@pytest.mark.asyncio
async def test_agent_auth_query_param():
receiver = _make_receiver(token="secret")
async with TestClient(TestServer(receiver._app)) as client:
resp = await client.post(
"/hooks/agent?token=secret",
json={"message": "test"},
)
assert resp.status == 200