import discord from discord.ext import commands, tasks import asyncio from typing import Optional, Dict, Any import logging from datetime import datetime, timedelta, timezone from utils.config import get_settings from utils.logging import log_error_with_context, log_system_health from database.connection import get_db_session from database.models import Message, Conversation, Character from sqlalchemy import select, and_ logger = logging.getLogger(__name__) class FishbowlBot(commands.Bot): def __init__(self, conversation_engine): settings = get_settings() intents = discord.Intents.default() intents.message_content = True intents.guilds = True intents.members = True super().__init__( command_prefix='!', intents=intents, help_command=None ) self.settings = settings self.conversation_engine = conversation_engine self.guild_id = int(settings.discord.guild_id) self.channel_id = int(settings.discord.channel_id) self.target_guild = None self.target_channel = None # Health monitoring self.health_check_task = None self.last_heartbeat = datetime.now(timezone.utc) async def setup_hook(self): """Called when the bot is starting up""" logger.info("Bot setup hook called") # Start health monitoring self.health_check_task = self.health_check_loop.start() # Sync commands (if any) try: synced = await self.tree.sync() logger.info(f"Synced {len(synced)} command(s)") except Exception as e: logger.error(f"Failed to sync commands: {e}") async def on_ready(self): """Called when the bot is ready""" logger.info(f'Bot logged in as {self.user} (ID: {self.user.id})') # Get target guild and channel self.target_guild = self.get_guild(self.guild_id) if not self.target_guild: logger.error(f"Could not find guild with ID {self.guild_id}") return self.target_channel = self.target_guild.get_channel(self.channel_id) if not self.target_channel: logger.error(f"Could not find channel with ID {self.channel_id}") return logger.info(f"Connected to guild: {self.target_guild.name}") logger.info(f"Target channel: {self.target_channel.name}") # Initialize conversation engine await self.conversation_engine.initialize(self) # Update heartbeat self.last_heartbeat = datetime.now(timezone.utc) log_system_health("discord_bot", "connected", { "guild": self.target_guild.name, "channel": self.target_channel.name, "latency": round(self.latency * 1000, 2) }) async def on_message(self, message: discord.Message): """Handle incoming messages""" # Ignore messages from the bot itself if message.author == self.user: return # Only process messages from the target channel if message.channel.id != self.channel_id: return # Log the message for analytics await self._log_discord_message(message) # Process commands await self.process_commands(message) async def on_message_edit(self, before: discord.Message, after: discord.Message): """Handle message edits""" if after.author == self.user or after.channel.id != self.channel_id: return logger.info(f"Message edited by {after.author}: {before.content} -> {after.content}") async def on_message_delete(self, message: discord.Message): """Handle message deletions""" if message.author == self.user or message.channel.id != self.channel_id: return logger.info(f"Message deleted by {message.author}: {message.content}") async def on_error(self, event: str, *args, **kwargs): """Handle bot errors""" logger.error(f"Bot error in event {event}: {args}") log_error_with_context( Exception(f"Bot error in {event}"), {"event": event, "args": str(args)} ) async def on_disconnect(self): """Handle bot disconnect""" logger.warning("Bot disconnected from Discord") log_system_health("discord_bot", "disconnected") async def on_resumed(self): """Handle bot reconnection""" logger.info("Bot reconnected to Discord") self.last_heartbeat = datetime.now(timezone.utc) log_system_health("discord_bot", "reconnected") async def send_character_message(self, character_name: str, content: str, conversation_id: Optional[int] = None, reply_to_message_id: Optional[int] = None) -> Optional[discord.Message]: """Send a message as a character""" if not self.target_channel: logger.error("No target channel available") return None try: # Get the character's webhook or create one webhook = await self._get_character_webhook(character_name) if not webhook: logger.error(f"Could not get webhook for character {character_name}") return None # Send the message via webhook discord_message = await webhook.send( content=content, username=character_name, wait=True ) # Store message in database await self._store_character_message( character_name=character_name, content=content, discord_message_id=str(discord_message.id), conversation_id=conversation_id, reply_to_message_id=reply_to_message_id ) logger.info(f"Character {character_name} sent message: {content[:50]}...") return discord_message except Exception as e: log_error_with_context(e, { "character_name": character_name, "content_length": len(content), "conversation_id": conversation_id }) return None async def _get_character_webhook(self, character_name: str) -> Optional[discord.Webhook]: """Get or create a webhook for a character""" try: # Check if webhook already exists webhooks = await self.target_channel.webhooks() for webhook in webhooks: if webhook.name == f"fishbowl-{character_name.lower()}": return webhook # Create new webhook webhook = await self.target_channel.create_webhook( name=f"fishbowl-{character_name.lower()}", reason=f"Webhook for character {character_name}" ) logger.info(f"Created webhook for character {character_name}") return webhook except Exception as e: log_error_with_context(e, {"character_name": character_name}) return None async def _store_character_message(self, character_name: str, content: str, discord_message_id: str, conversation_id: Optional[int] = None, reply_to_message_id: Optional[int] = None): """Store a character message in the database""" try: async with get_db_session() as session: # Get character character_query = select(Character).where(Character.name == character_name) character = await session.scalar(character_query) if not character: logger.error(f"Character {character_name} not found in database") return # Create message record message = Message( character_id=character.id, conversation_id=conversation_id, content=content, discord_message_id=discord_message_id, response_to_message_id=reply_to_message_id, timestamp=datetime.now(timezone.utc) ) session.add(message) await session.commit() # Update character's last activity character.last_active = datetime.now(timezone.utc) character.last_message_id = message.id await session.commit() except Exception as e: log_error_with_context(e, { "character_name": character_name, "discord_message_id": discord_message_id }) async def _log_discord_message(self, message: discord.Message): """Log external Discord messages for analytics""" try: # Store external message for context logger.info(f"External message from {message.author}: {message.content[:100]}...") # You could store external messages in a separate table if needed # This helps with conversation context and analytics except Exception as e: log_error_with_context(e, {"message_id": str(message.id)}) @tasks.loop(minutes=5) async def health_check_loop(self): """Periodic health check""" try: # Check bot connectivity if self.is_closed() or not self.user: log_system_health("discord_bot", "disconnected") return # Check heartbeat time_since_heartbeat = datetime.now(timezone.utc) - self.last_heartbeat if time_since_heartbeat > timedelta(minutes=10): log_system_health("discord_bot", "heartbeat_stale", { "minutes_since_heartbeat": time_since_heartbeat.total_seconds() / 60 }) # Update heartbeat self.last_heartbeat = datetime.now(timezone.utc) # Log health metrics uptime_minutes = 0 if self.user and hasattr(self.user, 'created_at') and self.user.created_at: uptime_minutes = (datetime.now(timezone.utc) - self.user.created_at.replace(tzinfo=timezone.utc)).total_seconds() / 60 log_system_health("discord_bot", "healthy", { "latency_ms": round(self.latency * 1000, 2), "guild_count": len(self.guilds), "uptime_minutes": uptime_minutes }) except Exception as e: log_error_with_context(e, {"component": "health_check"}) async def close(self): """Clean shutdown""" logger.info("Shutting down Discord bot") if self.health_check_task: self.health_check_task.cancel() # Stop conversation engine if self.conversation_engine: await self.conversation_engine.stop() await super().close() logger.info("Discord bot shut down complete")