""" Discord platform adapter. Uses discord.py library for: - Receiving messages from servers and DMs - Sending responses back - Handling threads and channels """ import asyncio import os from typing import Dict, List, Optional, Any try: import discord from discord import Message as DiscordMessage, Intents from discord.ext import commands DISCORD_AVAILABLE = True except ImportError: DISCORD_AVAILABLE = False discord = None DiscordMessage = Any Intents = Any commands = None import sys sys.path.insert(0, str(__file__).rsplit("/", 3)[0]) from gateway.config import Platform, PlatformConfig from gateway.platforms.base import ( BasePlatformAdapter, MessageEvent, MessageType, SendResult, ) def check_discord_requirements() -> bool: """Check if Discord dependencies are available.""" return DISCORD_AVAILABLE class DiscordAdapter(BasePlatformAdapter): """ Discord bot adapter. Handles: - Receiving messages from servers and DMs - Sending responses with Discord markdown - Thread support - Slash commands (future) """ # Discord message limits MAX_MESSAGE_LENGTH = 2000 def __init__(self, config: PlatformConfig): super().__init__(config, Platform.DISCORD) self._client: Optional[commands.Bot] = None self._ready_event = asyncio.Event() async def connect(self) -> bool: """Connect to Discord and start receiving events.""" if not DISCORD_AVAILABLE: print(f"[{self.name}] discord.py not installed. Run: pip install discord.py") return False if not self.config.token: print(f"[{self.name}] No bot token configured") return False try: # Set up intents intents = Intents.default() intents.message_content = True intents.dm_messages = True intents.guild_messages = True # Create bot self._client = commands.Bot( command_prefix="!", # Not really used, we handle raw messages intents=intents, ) # Register event handlers @self._client.event async def on_ready(): print(f"[{self.name}] Connected as {self._client.user}") self._ready_event.set() @self._client.event async def on_message(message: DiscordMessage): # Ignore bot's own messages if message.author == self._client.user: return await self._handle_message(message) # Start the bot in background asyncio.create_task(self._client.start(self.config.token)) # Wait for ready await asyncio.wait_for(self._ready_event.wait(), timeout=30) self._running = True return True except asyncio.TimeoutError: print(f"[{self.name}] Timeout waiting for connection") return False except Exception as e: print(f"[{self.name}] Failed to connect: {e}") return False async def disconnect(self) -> None: """Disconnect from Discord.""" if self._client: try: await self._client.close() except Exception as e: print(f"[{self.name}] Error during disconnect: {e}") self._running = False self._client = None self._ready_event.clear() print(f"[{self.name}] Disconnected") async def send( self, chat_id: str, content: str, reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: """Send a message to a Discord channel.""" if not self._client: return SendResult(success=False, error="Not connected") try: # Get the channel channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") # Format and split message if needed formatted = self.format_message(content) chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) message_ids = [] reference = None if reply_to: try: ref_msg = await channel.fetch_message(int(reply_to)) reference = ref_msg except Exception: pass # Ignore if we can't find the referenced message for i, chunk in enumerate(chunks): msg = await channel.send( content=chunk, reference=reference if i == 0 else None, ) message_ids.append(str(msg.id)) return SendResult( success=True, message_id=message_ids[0] if message_ids else None, raw_response={"message_ids": message_ids} ) except Exception as e: return SendResult(success=False, error=str(e)) async def send_voice( self, chat_id: str, audio_path: str, caption: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: """Send audio as a Discord file attachment.""" if not self._client: return SendResult(success=False, error="Not connected") try: import io channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") if not os.path.exists(audio_path): return SendResult(success=False, error=f"Audio file not found: {audio_path}") # Determine filename from path filename = os.path.basename(audio_path) with open(audio_path, "rb") as f: file = discord.File(io.BytesIO(f.read()), filename=filename) msg = await channel.send( content=caption if caption else None, file=file, ) return SendResult(success=True, message_id=str(msg.id)) except Exception as e: print(f"[{self.name}] Failed to send audio: {e}") return await super().send_voice(chat_id, audio_path, caption, reply_to) async def send_image( self, chat_id: str, image_url: str, caption: Optional[str] = None, reply_to: Optional[str] = None, ) -> SendResult: """Send an image natively as a Discord file attachment.""" if not self._client: return SendResult(success=False, error="Not connected") try: import aiohttp channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) if not channel: return SendResult(success=False, error=f"Channel {chat_id} not found") # Download the image and send as a Discord file attachment # (Discord renders attachments inline, unlike plain URLs) async with aiohttp.ClientSession() as session: async with session.get(image_url, timeout=aiohttp.ClientTimeout(total=30)) as resp: if resp.status != 200: raise Exception(f"Failed to download image: HTTP {resp.status}") image_data = await resp.read() # Determine filename from URL or content type content_type = resp.headers.get("content-type", "image/png") ext = "png" if "jpeg" in content_type or "jpg" in content_type: ext = "jpg" elif "gif" in content_type: ext = "gif" elif "webp" in content_type: ext = "webp" import io file = discord.File(io.BytesIO(image_data), filename=f"image.{ext}") msg = await channel.send( content=caption if caption else None, file=file, ) return SendResult(success=True, message_id=str(msg.id)) except ImportError: print(f"[{self.name}] aiohttp not installed, falling back to URL. Run: pip install aiohttp") return await super().send_image(chat_id, image_url, caption, reply_to) except Exception as e: print(f"[{self.name}] Failed to send image attachment, falling back to URL: {e}") return await super().send_image(chat_id, image_url, caption, reply_to) async def send_typing(self, chat_id: str) -> None: """Send typing indicator.""" if self._client: try: channel = self._client.get_channel(int(chat_id)) if channel: await channel.typing() except Exception: pass # Ignore typing indicator failures async def get_chat_info(self, chat_id: str) -> Dict[str, Any]: """Get information about a Discord channel.""" if not self._client: return {"name": "Unknown", "type": "dm"} try: channel = self._client.get_channel(int(chat_id)) if not channel: channel = await self._client.fetch_channel(int(chat_id)) if not channel: return {"name": str(chat_id), "type": "dm"} # Determine channel type if isinstance(channel, discord.DMChannel): chat_type = "dm" name = channel.recipient.name if channel.recipient else str(chat_id) elif isinstance(channel, discord.Thread): chat_type = "thread" name = channel.name elif isinstance(channel, discord.TextChannel): chat_type = "channel" name = f"#{channel.name}" if channel.guild: name = f"{channel.guild.name} / {name}" else: chat_type = "channel" name = getattr(channel, "name", str(chat_id)) return { "name": name, "type": chat_type, "guild_id": str(channel.guild.id) if hasattr(channel, "guild") and channel.guild else None, "guild_name": channel.guild.name if hasattr(channel, "guild") and channel.guild else None, } except Exception as e: return {"name": str(chat_id), "type": "dm", "error": str(e)} def format_message(self, content: str) -> str: """ Format message for Discord. Discord uses its own markdown variant. """ # Discord markdown is fairly standard, no special escaping needed return content async def _handle_message(self, message: DiscordMessage) -> None: """Handle incoming Discord messages.""" # In server channels (not DMs), require the bot to be @mentioned # UNLESS the channel is in the free-response list. # # Config: # DISCORD_FREE_RESPONSE_CHANNELS: Comma-separated channel IDs where the # bot responds to every message without needing a mention. # DISCORD_REQUIRE_MENTION: Set to "false" to disable mention requirement # globally (all channels become free-response). Default: "true". if not isinstance(message.channel, discord.DMChannel): # Check if this channel is in the free-response list free_channels_raw = os.getenv("DISCORD_FREE_RESPONSE_CHANNELS", "") free_channels = {ch.strip() for ch in free_channels_raw.split(",") if ch.strip()} channel_id = str(message.channel.id) # Global override: if DISCORD_REQUIRE_MENTION=false, all channels are free require_mention = os.getenv("DISCORD_REQUIRE_MENTION", "true").lower() not in ("false", "0", "no") is_free_channel = channel_id in free_channels if require_mention and not is_free_channel: # Must be @mentioned to respond if self._client.user not in message.mentions: return # Silently ignore messages that don't mention the bot # Strip the bot mention from the message text so the agent sees clean input if self._client.user and self._client.user in message.mentions: message.content = message.content.replace(f"<@{self._client.user.id}>", "").strip() message.content = message.content.replace(f"<@!{self._client.user.id}>", "").strip() # Determine message type msg_type = MessageType.TEXT if message.content.startswith("/"): msg_type = MessageType.COMMAND elif message.attachments: # Check attachment types for att in message.attachments: if att.content_type: if att.content_type.startswith("image/"): msg_type = MessageType.PHOTO elif att.content_type.startswith("video/"): msg_type = MessageType.VIDEO elif att.content_type.startswith("audio/"): msg_type = MessageType.AUDIO else: msg_type = MessageType.DOCUMENT break # Determine chat type if isinstance(message.channel, discord.DMChannel): chat_type = "dm" chat_name = message.author.name elif isinstance(message.channel, discord.Thread): chat_type = "thread" chat_name = message.channel.name else: chat_type = "group" # Treat server channels as groups chat_name = getattr(message.channel, "name", str(message.channel.id)) if hasattr(message.channel, "guild") and message.channel.guild: chat_name = f"{message.channel.guild.name} / #{chat_name}" # Get thread ID if in a thread thread_id = None if isinstance(message.channel, discord.Thread): thread_id = str(message.channel.id) # Build source source = self.build_source( chat_id=str(message.channel.id), chat_name=chat_name, chat_type=chat_type, user_id=str(message.author.id), user_name=message.author.display_name, thread_id=thread_id, ) # Build media URLs media_urls = [att.url for att in message.attachments] media_types = [att.content_type or "unknown" for att in message.attachments] event = MessageEvent( text=message.content, message_type=msg_type, source=source, raw_message=message, message_id=str(message.id), media_urls=media_urls, media_types=media_types, reply_to_message_id=str(message.reference.message_id) if message.reference else None, timestamp=message.created_at, ) await self.handle_message(event)