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
271 lines
9.4 KiB
Python
271 lines
9.4 KiB
Python
"""
|
|
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
|