""" Discord platform adapter. Uses discord.py library for: - Receiving messages from servers and DMs - Sending responses back - Handling threads and channels """ import asyncio 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_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.""" # 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)