""" 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