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:
270
adapters/discord_adapter.py
Normal file
270
adapters/discord_adapter.py
Normal 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
|
||||
Reference in New Issue
Block a user