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

270
adapters/discord_adapter.py Normal file
View File

@@ -0,0 +1,270 @@
"""
Aetheel Discord Adapter
========================
Connects to Discord via the Bot Gateway using discord.py.
Features:
- Receives DMs and @mentions in guild channels
- Each channel = persistent conversation context
- Sends replies back to the same channel (threaded if supported)
- Chunked replies for Discord's 2000-char limit
- Extends BaseAdapter for multi-channel support
Setup:
1. Create a bot at https://discord.com/developers/applications
2. Enable MESSAGE CONTENT intent in Bot settings
3. Set DISCORD_BOT_TOKEN in .env
4. Invite bot with: OAuth2 → URL Generator → bot scope + Send Messages + Read Message History
5. Start with: python main.py --discord
Usage:
from adapters.discord_adapter import DiscordAdapter
adapter = DiscordAdapter()
adapter.on_message(my_handler)
adapter.start()
"""
import asyncio
import logging
import os
import threading
from datetime import datetime, timezone
import discord
from adapters.base import BaseAdapter, IncomingMessage
logger = logging.getLogger("aetheel.discord")
def resolve_discord_token(explicit: str | None = None) -> str:
"""Resolve the Discord bot token."""
token = (explicit or os.environ.get("DISCORD_BOT_TOKEN", "")).strip()
if not token:
raise ValueError(
"Discord bot token is required. "
"Set DISCORD_BOT_TOKEN environment variable or pass it explicitly. "
"Get one from https://discord.com/developers/applications"
)
return token
class DiscordAdapter(BaseAdapter):
"""
Discord channel adapter using discord.py.
Handles:
- DMs (private messages)
- Guild channel messages where the bot is @mentioned
"""
def __init__(self, bot_token: str | None = None, listen_channels: list[str] | None = None):
super().__init__()
self._token = resolve_discord_token(bot_token)
self._bot_user_id: int = 0
self._bot_user_name: str = ""
self._running = False
self._thread: threading.Thread | None = None
self._loop: asyncio.AbstractEventLoop | None = None
# Channels where the bot responds to ALL messages (no @mention needed).
# Set via DISCORD_LISTEN_CHANNELS env var (comma-separated IDs) or constructor.
if listen_channels is not None:
self._listen_channels: set[str] = set(listen_channels)
else:
raw = os.environ.get("DISCORD_LISTEN_CHANNELS", "").strip()
self._listen_channels = {
ch.strip() for ch in raw.split(",") if ch.strip()
}
# Set up intents — need message content for reading messages
intents = discord.Intents.default()
intents.message_content = True
intents.dm_messages = True
self._client = discord.Client(intents=intents)
self._register_handlers()
# -------------------------------------------------------------------
# BaseAdapter implementation
# -------------------------------------------------------------------
@property
def source_name(self) -> str:
return "discord"
def start(self) -> None:
"""Start the Discord adapter (blocking)."""
logger.info("Starting Discord adapter...")
self._running = True
self._client.run(self._token, log_handler=None)
def start_async(self) -> None:
"""Start the adapter in a background thread (non-blocking)."""
self._thread = threading.Thread(
target=self._run_in_thread, daemon=True, name="discord-adapter"
)
self._thread.start()
logger.info("Discord adapter started in background thread")
def stop(self) -> None:
"""Stop the Discord adapter gracefully."""
self._running = False
if self._loop and not self._loop.is_closed():
asyncio.run_coroutine_threadsafe(self._client.close(), self._loop)
logger.info("Discord adapter stopped.")
def send_message(
self,
channel_id: str,
text: str,
thread_id: str | None = None,
) -> None:
"""Send a message to a Discord channel or DM."""
if not text.strip():
return
async def _send():
target = self._client.get_channel(int(channel_id))
if target is None:
try:
target = await self._client.fetch_channel(int(channel_id))
except discord.NotFound:
logger.error(f"Channel {channel_id} not found")
return
chunks = _chunk_text(text, 2000)
for chunk in chunks:
await target.send(chunk)
if self._loop and self._loop.is_running():
asyncio.run_coroutine_threadsafe(_send(), self._loop)
else:
asyncio.run(_send())
# -------------------------------------------------------------------
# Internal: Event handlers
# -------------------------------------------------------------------
def _register_handlers(self) -> None:
"""Register Discord event handlers."""
@self._client.event
async def on_ready():
if self._client.user:
self._bot_user_id = self._client.user.id
self._bot_user_name = self._client.user.name
self._loop = asyncio.get_running_loop()
self._running = True
logger.info("=" * 60)
logger.info(" Aetheel Discord Adapter")
logger.info("=" * 60)
logger.info(f" Bot: @{self._bot_user_name} ({self._bot_user_id})")
guilds = [g.name for g in self._client.guilds]
logger.info(f" Guilds: {', '.join(guilds) or 'none'}")
logger.info(f" Handlers: {len(self._message_handlers)} registered")
if self._listen_channels:
logger.info(f" Listen: {', '.join(self._listen_channels)} (no @mention needed)")
logger.info("=" * 60)
@self._client.event
async def on_message(message: discord.Message):
# Ignore own messages
if message.author == self._client.user:
return
# Ignore other bots
if message.author.bot:
return
is_dm = isinstance(message.channel, discord.DMChannel)
text = message.content
# In guild channels: respond to @mentions everywhere,
# and respond to ALL messages in listen channels (no @mention needed).
if not is_dm:
channel_str = str(message.channel.id)
is_listen_channel = channel_str in self._listen_channels
if self._client.user and self._client.user.mentioned_in(message):
# Strip the mention from the text
text = text.replace(f"<@{self._bot_user_id}>", "").strip()
text = text.replace(f"<@!{self._bot_user_id}>", "").strip()
elif not is_listen_channel:
return # Not mentioned and not a listen channel — ignore
if not text.strip():
return
# Build IncomingMessage
user_name = message.author.display_name or message.author.name
if is_dm:
channel_name = f"DM with {user_name}"
else:
channel_name = getattr(message.channel, "name", str(message.channel.id))
msg = IncomingMessage(
text=text,
user_id=str(message.author.id),
user_name=user_name,
channel_id=str(message.channel.id),
channel_name=channel_name,
conversation_id=str(message.channel.id),
source="discord",
is_dm=is_dm,
timestamp=message.created_at.replace(tzinfo=timezone.utc)
if message.created_at.tzinfo is None
else message.created_at,
raw_event={
"thread_id": None,
"message_id": message.id,
"guild_id": message.guild.id if message.guild else None,
},
)
logger.info(
f"📨 [Discord] Message from {user_name} in {channel_name}: "
f"{text[:100]}"
)
# Dispatch synchronously in a thread to avoid blocking the event loop
# (handlers call subprocess-based AI runtimes which are blocking)
await asyncio.to_thread(self._dispatch, msg)
def _run_in_thread(self) -> None:
"""Run the Discord client in a dedicated thread with its own event loop."""
self._running = True
self._client.run(self._token, log_handler=None)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _chunk_text(text: str, limit: int = 2000) -> list[str]:
"""Split text into chunks respecting Discord's character limit."""
if len(text) <= limit:
return [text]
chunks = []
remaining = text
while remaining:
if len(remaining) <= limit:
chunks.append(remaining)
break
cut = limit
newline_pos = remaining.rfind("\n", 0, limit)
if newline_pos > limit // 2:
cut = newline_pos + 1
else:
space_pos = remaining.rfind(" ", 0, limit)
if space_pos > limit // 2:
cut = space_pos + 1
chunks.append(remaining[:cut])
remaining = remaining[cut:]
return chunks