""" Main Bot Module Core bot class and command definitions. Manages the bot lifecycle, background tasks, and Discord interactions. Supports multi-guild deployments with independent server tracking per guild. """ import asyncio import logging import signal import sys import types from collections import defaultdict from datetime import datetime from typing import TYPE_CHECKING, Dict, List, Optional, Tuple import discord from discord.ext import commands, tasks from api import PanelAPI from config import Config from embeds import EmbedManager from exceptions import ConfigValidationError from metrics import ServerMetricsManager from models import ServerData, ServerResources if TYPE_CHECKING: from views import ServerStatusView logger = logging.getLogger("pterodisbot") # Singleton instance holder _bot_instance: Optional["PterodactylBot"] = None def get_bot_instance() -> Optional["PterodactylBot"]: """Get the singleton bot instance.""" return _bot_instance class PterodactylBot(commands.Bot): """ Main bot class for Pterodactyl/Pelican Panel server management. Handles Discord interactions, embed management, and background tasks. Manages server status embeds and user commands across multiple guilds. Features: - Background status update task with smart update detection per guild - Persistent embed tracking with JSON storage per guild - Real-time server monitoring with metrics graphs - Role-based access control per guild - Multi-guild support with independent server tracking - Per-server action locking to prevent race conditions - Graceful shutdown handling """ def __init__(self, config: Config, *args, **kwargs) -> None: """ Initialize the Pterodactyl bot instance. Args: config: Validated application configuration *args: Additional arguments for commands.Bot **kwargs: Additional keyword arguments for commands.Bot """ global _bot_instance _bot_instance = self super().__init__(*args, **kwargs) self.config = config self.api = PanelAPI(config) self.metrics_manager = ServerMetricsManager() self.embed_manager = EmbedManager(config, self.api, self.metrics_manager) # Server cache from panel (shared across guilds, read-mostly) self.server_cache: Dict[str, dict] = {} self._server_cache_lock = asyncio.Lock() # Concurrency control for background update task self.update_lock = asyncio.Lock() # Per-server action locks to serialize concurrent power actions across guilds # Key: server_id, Value: asyncio.Lock self._server_action_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock) # State tracking for smart updates per guild # Format: {guild_id: {server_id: (state, cpu_usage, last_force_update)}} self.previous_states: Dict[int, Dict[str, Tuple[str, float, Optional[float]]]] = defaultdict(dict) logger.info(f"Initialized PterodactylBot instance with multi-guild support for {len(config.allowed_guild_ids)} guild(s)") @classmethod def get_instance(cls) -> Optional["PterodactylBot"]: """Get the singleton bot instance.""" return _bot_instance def get_server_action_lock(self, server_id: str) -> asyncio.Lock: """ Get or create a per-server lock for serializing power actions. This prevents race conditions when users from multiple guilds press buttons for the same server simultaneously. Args: server_id: Panel server identifier Returns: asyncio.Lock for the specified server """ return self._server_action_locks[server_id] async def trigger_embed_update_for_server(self, server_id: str) -> None: """ Trigger an immediate embed update for a server across all guilds tracking it. Called after a successful power action to ensure all guilds see the state change promptly. Bypasses smart update detection since we know the state changed. Args: server_id: Panel server identifier """ if server_id not in self.server_cache: logger.warning(f"Cannot trigger update for unknown server {server_id}") return server_data = self.server_cache[server_id] server_name = server_data["attributes"]["name"] try: # Fetch fresh resources resources = await self.api.get_server_resources(server_id) current_state = resources["attributes"]["current_state"] cpu_usage = round( resources["attributes"]["resources"].get("cpu_absolute", 0), 2 ) # Collect metrics for running servers if current_state == "running": memory_usage = round( resources["attributes"]["resources"].get("memory_bytes", 0) / (1024**2), 2, ) self.metrics_manager.add_server_data( server_id, server_name, cpu_usage, memory_usage ) # Convert to models for embed creation server_obj = ServerData.model_validate(server_data) resources_obj = ServerResources.model_validate(resources) current_time = datetime.now().timestamp() # Update in all guilds that track this server for guild_id in self.config.allowed_guild_ids: guild_locations = self.embed_manager.get_guild_locations(guild_id) if server_id not in guild_locations: continue location = guild_locations[server_id] # Force update (bypass smart detection) success = await self.embed_manager.update_single_embed( guild_id=guild_id, server_id=server_id, location=location, server_data=server_obj, resources=resources_obj, current_state=current_state, cpu_usage=cpu_usage, ) if success: logger.debug( f"Cross-guild embed update triggered for {server_name} ({server_id}) in guild {guild_id}: power action" ) # Update state tracking for this guild guild_states = self.previous_states[guild_id] guild_states[server_id] = (current_state, cpu_usage, current_time) logger.debug(f"Force-updated embed for {server_name} in guild {guild_id}") else: logger.warning(f"Failed to force-update embed for {server_name} in guild {guild_id}") # Small delay between guilds to respect rate limits await asyncio.sleep(0.3) except Exception as e: logger.error(f"Failed to trigger cross-guild update for server {server_id}: {str(e)}") async def setup_hook(self) -> None: """ Bot setup routine called when the bot is starting. Initializes API client, loads saved data, and starts background tasks. """ logger.info("Running bot setup hook") # Initialize API client await self.api.initialize() logger.info(f"Initialized {self.config.get_panel_display_name()} API client") # Load saved embed locations await self.embed_manager.load_locations() # Start background update task self.update_status.start() logger.info("Started background status update task") @tasks.loop(seconds=10) # UPDATE_INTERVAL = 10 async def update_status(self) -> None: """ Background task to update server status embeds across all guilds. For each guild, updates embeds when: 1. Server power state changes (started/stopped/restarted) 2. Significant CPU usage change (>50% difference) 3. First time seeing the server in that guild 4. Server has been running for 10 minutes (force update for uptime) This minimizes API calls to Discord while maintaining real-time awareness of important server changes. """ logger.info("Starting multi-guild server status update cycle") async with self.update_lock: try: # Fetch current server list from panel (once per cycle, shared across guilds) servers = await self.api.get_servers() if not servers: logger.warning("No servers found in panel during update") return # Update our local cache with fresh server data async with self._server_cache_lock: self.server_cache = {s["attributes"]["identifier"]: s for s in servers} logger.debug(f"Updated server cache with {len(servers)} servers") # Clean up metrics for servers that no longer exist active_server_ids = list(self.server_cache.keys()) self.metrics_manager.cleanup_old_servers(active_server_ids) current_time = datetime.now().timestamp() # Process each guild's embeds total_updated = 0 total_skipped = 0 total_missing = 0 total_errors = 0 for guild_id in self.config.allowed_guild_ids: u, s, m, e = await self._update_guild_embeds(guild_id, current_time) total_updated += u total_skipped += s total_missing += m total_errors += e # Log summary of this update cycle logger.info( f"Multi-guild update cycle complete: " f"{total_updated} updated, " f"{total_skipped} skipped, " f"{total_missing} missing, " f"{total_errors} errors across {len(self.config.allowed_guild_ids)} guild(s)" ) except Exception as e: logger.error(f"Error in update_status task: {str(e)}") await asyncio.sleep(5) async def _update_guild_embeds( self, guild_id: int, current_time: float ) -> Tuple[int, int, int, int]: """ Update embeds for a specific guild. Args: guild_id: Discord guild ID current_time: Current timestamp for force-update calculations Returns: Tuple of (updated, skipped, missing, errors) counts """ guild_locations = self.embed_manager.get_guild_locations(guild_id) if not guild_locations: return 0, 0, 0, 0 guild_prev_states = self.previous_states[guild_id] update_count = 0 skipped_count = 0 missing_count = 0 error_count = 0 for server_id, location in list(guild_locations.items()): # Skip if server no longer exists in panel if server_id not in self.server_cache: logger.warning(f"Server {server_id} not found in cache, skipping update for guild {guild_id}") continue server_data = self.server_cache[server_id] server_name = server_data["attributes"]["name"] try: logger.debug(f"Checking status for server {server_name} ({server_id}) in guild {guild_id}") # Get current server resource usage resources = await self.api.get_server_resources(server_id) current_state = resources["attributes"]["current_state"] cpu_usage = round( resources["attributes"]["resources"].get("cpu_absolute", 0), 2 ) # Collect metrics data for running servers (shared across guilds) if current_state == "running": memory_usage = round( resources["attributes"]["resources"].get("memory_bytes", 0) / (1024**2), 2, ) self.metrics_manager.add_server_data( server_id, server_name, cpu_usage, memory_usage ) logger.debug( f"Added metrics data for {server_name}: " f"CPU={cpu_usage}%, Memory={memory_usage}MB" ) # Retrieve previous recorded state for THIS guild prev_state, prev_cpu, last_force_update = guild_prev_states.get( server_id, (None, 0, None) ) # DECISION LOGIC: Should we update the embed? needs_update, trigger_reason = self._should_update_embed( current_state, prev_state, cpu_usage, prev_cpu, last_force_update, current_time ) # PERFORM UPDATE IF NEEDED if needs_update: logger.debug( f"Embed update triggered for {server_name} ({server_id}) in guild {guild_id}: {trigger_reason}" ) # Convert to models for embed creation server_obj = ServerData.model_validate(server_data) resources_obj = ServerResources.model_validate(resources) # Update the embed success = await self.embed_manager.update_single_embed( guild_id=guild_id, server_id=server_id, location=location, server_data=server_obj, resources=resources_obj, current_state=current_state, cpu_usage=cpu_usage, ) if success: update_count += 1 # Update state tracking for this guild new_last_force_update = current_time if ( current_state == "running" and (last_force_update is None or current_time - last_force_update >= 600) ) else last_force_update guild_prev_states[server_id] = ( current_state, cpu_usage, new_last_force_update, ) else: # No significant changes, but update tracking with current state guild_prev_states[server_id] = ( current_state, cpu_usage, last_force_update, ) skipped_count += 1 logger.debug(f"No changes detected for {server_name} in guild {guild_id}, skipping update") except discord.NotFound: # Embed message was deleted - clean up tracking for this guild logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking") await self.embed_manager.untrack_embed(guild_id, server_id) missing_count += 1 except Exception as e: logger.error(f"Failed to update status for server {server_id} in guild {guild_id}: {str(e)}") error_count += 1 # Small delay between servers to avoid rate limits await asyncio.sleep(0.5) return update_count, skipped_count, missing_count, error_count def _should_update_embed( self, current_state: str, prev_state: Optional[str], cpu_usage: float, prev_cpu: float, last_force_update: Optional[float], current_time: float, ) -> Tuple[bool, str]: """ Determine if an embed needs updating based on smart detection criteria. This logic is shared between the background task and cross-guild triggers. Args: current_state: Current server power state prev_state: Previously recorded state (or None) cpu_usage: Current CPU usage percentage prev_cpu: Previously recorded CPU usage last_force_update: Timestamp of last forced update (or None) current_time: Current timestamp Returns: Tuple of (should_update: bool, reason: str) """ # 1. Check if power state changed (most important) if current_state != prev_state: return True, f"power state changed: {prev_state} -> {current_state}" # 2. Check for significant CPU change (only if server is running) if current_state == "running" and abs(cpu_usage - prev_cpu) > 50: return True, f"significant CPU change: {prev_cpu}% -> {cpu_usage}%" # 3. First time we're seeing this server (initial update) if prev_state is None: return True, "first detection (initial update)" # 4. Force update every 10 minutes for running servers (for uptime counter) if current_state == "running" and ( last_force_update is None or current_time - last_force_update >= 600 ): # 10 minutes = 600 seconds return True, f"10-minute force update (last: {last_force_update})" return False, "no significant changes" @update_status.before_loop async def before_update_status(self) -> None: """Wait for bot to be ready before starting update task, then do initial refresh.""" logger.debug("Waiting for bot readiness before starting update task") await self.wait_until_ready() # Initial refresh for all guilds for guild_id in self.config.allowed_guild_ids: await self.embed_manager.refresh_all_embeds(guild_id) @update_status.after_loop async def after_update_status(self) -> None: """Handle update task stopping.""" if self.update_status.is_being_cancelled(): logger.info("Server status update task was cancelled") elif self.update_status.failed(): logger.error("Server status update task failed") async def close(self) -> None: """Cleanup when bot is shutting down.""" await self.embed_manager.save_locations() if self.api: await self.api.close() await super().close() async def check_allowed_guild(interaction: discord.Interaction, config: Config) -> bool: """ Verify that an interaction is coming from an allowed guild. Args: interaction: Discord interaction object config: Application configuration Returns: bool: True if interaction is allowed, False otherwise """ if interaction.guild_id not in config.allowed_guild_ids: logger.warning( f"Command attempted from unauthorized guild {interaction.guild_id} " f"by {interaction.user.name}" ) await interaction.response.send_message( "This bot is not available in this server.", ephemeral=True ) return False return True async def setup_commands(bot: PterodactylBot) -> None: """ Set up all slash commands and event handlers for the bot. Args: bot: The bot instance """ config = bot.config # ============================================== # EVENT HANDLERS # ============================================== @bot.event async def on_interaction(interaction: discord.Interaction) -> None: """Global interaction handler to check guild before processing any interaction.""" if interaction.guild_id not in config.allowed_guild_ids: logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}") await interaction.response.send_message( "This bot is not available in this server.", ephemeral=True ) return @bot.event async def on_ready() -> None: """Called when the bot successfully connects to Discord.""" logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})") try: # Sync commands to ALL allowed guilds for guild_id in bot.config.allowed_guild_ids: guild = discord.Object(id=guild_id) bot.tree.copy_global_to(guild=guild) synced = await bot.tree.sync(guild=guild) logger.info( f"Successfully synced {len(synced)} command(s) to guild {guild_id}: " f"{[cmd.name for cmd in synced]}" ) except Exception as e: logger.error(f"Command sync failed: {str(e)}") # ============================================== # SLASH COMMANDS # ============================================== @bot.tree.command( name="server_status", description="Get a list of available game servers to control" ) async def server_status(interaction: discord.Interaction) -> None: """ Slash command to display server status dashboard with interactive dropdown selection. Workflow: 1. Validates guild permissions and defers ephemeral response 2. Refreshes server cache from Panel API 3. Calculates online/offline statistics by checking each server's state 4. Creates statistics embed with visual server status breakdown 5. Generates dropdown menu with all servers (name + description) 6. Handles user selection via ephemeral dropdown interaction 7. Creates permanent status embed in channel upon selection 8. Manages embed tracking and cleanup of previous embeds (per guild) """ if not await check_allowed_guild(interaction, config): return guild_id = interaction.guild_id logger.info(f"Server status command invoked by {interaction.user.name} in guild {guild_id}") await interaction.response.defer(ephemeral=True) try: # Refresh server cache with current data from panel servers = await bot.api.get_servers() if not servers: logger.warning("No servers found in panel") await interaction.followup.send( "No servers found in the panel.", ephemeral=True ) return async with bot._server_cache_lock: bot.server_cache = {s["attributes"]["identifier"]: s for s in servers} logger.debug(f"Refreshed server cache with {len(servers)} servers") # Count online/offline servers online_count = 0 offline_count = 0 for server_id, server_data in bot.server_cache.items(): resources = await bot.api.get_server_resources(server_id) current_state = resources.get("attributes", {}).get("current_state", "offline") if current_state == "running": online_count += 1 else: offline_count += 1 # Create statistics embed stats_embed = discord.Embed( title="đŸ—ī¸ Server Status Dashboard", description="Select a server from the dropdown below to view its detailed status and controls.", color=discord.Color.blue(), timestamp=datetime.now(), ) stats_embed.add_field( name="📊 Server Statistics", value=f"**Total Servers:** {len(servers)}\n" f"✅ **Online:** {online_count}\n" f"❌ **Offline:** {offline_count}", inline=False, ) stats_embed.add_field( name="â„šī¸ How to Use", value="Use the dropdown menu below to select a server. " "The server's status embed will be posted in this channel.", inline=False, ) stats_embed.set_footer(text="Server status will update automatically") # Create dropdown menu options from views import ServerDropdown, ServerDropdownView server_options = [] for server_id, server_data in bot.server_cache.items(): server_name = server_data["attributes"]["name"] server_description = server_data["attributes"].get("description", "No description") # Truncate description if too long for dropdown constraints if len(server_description) > 50: server_description = server_description[:47] + "..." server_options.append( discord.SelectOption( label=server_name, value=server_id, description=server_description ) ) # Send the initial dashboard embed with dropdown (ephemeral) await interaction.followup.send( embed=stats_embed, view=ServerDropdownView(server_options), ephemeral=True, ) logger.info(f"Sent server status dashboard to {interaction.user.name} in guild {guild_id} with {len(server_options)} servers") except Exception as e: logger.error(f"Server status command failed: {str(e)}") await interaction.followup.send( f"❌ Failed to load server status: {str(e)}", ephemeral=True ) @bot.tree.command( name="refresh_embeds", description="Refresh all server status embeds in this guild (admin only)" ) async def refresh_embeds(interaction: discord.Interaction) -> None: """Slash command to refresh all server embeds in the current guild.""" if not await check_allowed_guild(interaction, config): return guild_id = interaction.guild_id logger.info(f"Refresh embeds command invoked by {interaction.user.name} in guild {guild_id}") await interaction.response.defer(ephemeral=True) # Require administrator permissions if not interaction.user.guild_permissions.administrator: logger.warning(f"Unauthorized refresh attempt by {interaction.user.name} in guild {guild_id}") await interaction.followup.send( "You need administrator permissions to refresh all embeds.", ephemeral=True ) return try: logger.info(f"Starting full embed refresh per admin request for guild {guild_id}") deleted, created = await bot.embed_manager.refresh_all_embeds(guild_id) await interaction.followup.send( f"Refreshed all embeds in this guild. Deleted {deleted} old embeds, created {created} new ones.", ephemeral=True, ) logger.info(f"Embed refresh completed for guild {guild_id}: {deleted} deleted, {created} created") except Exception as e: logger.error(f"Embed refresh failed for guild {guild_id}: {str(e)}") await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True) @bot.tree.command( name="purge_embeds", description="Permanently delete all server status embeds in this guild (admin only)", ) async def purge_embeds(interaction: discord.Interaction) -> None: """ Slash command to permanently purge all server status embeds from the current guild. Workflow: 1. Validates administrator permissions 2. Checks if any embeds are currently tracked in this guild 3. Sends initial progress embed 4. Processes each embed sequentially with error handling 5. Updates progress embed in real-time 6. Saves cleared tracking data to disk 7. Sends final results with comprehensive statistics """ if not await check_allowed_guild(interaction, config): return guild_id = interaction.guild_id logger.info(f"Purge embeds command invoked by {interaction.user.name} in guild {guild_id}") await interaction.response.defer(ephemeral=True) # Require administrator permissions if not interaction.user.guild_permissions.administrator: logger.warning(f"Unauthorized purge attempt by {interaction.user.name} in guild {guild_id}") await interaction.followup.send( "You need administrator permissions to purge all embeds.", ephemeral=True ) return try: logger.info(f"Starting embed purge per admin request for guild {guild_id}") guild_locations = bot.embed_manager.get_guild_locations(guild_id) total_embeds = len(guild_locations) if total_embeds == 0: await interaction.followup.send( "No embeds are currently being tracked in this guild. Nothing to purge.", ephemeral=True ) return deleted_count = 0 not_found_count = 0 error_count = 0 # Create progress embed progress_embed = discord.Embed( title="🔄 Purging Server Embeds", description=f"Processing {total_embeds} embeds in this guild...", color=discord.Color.orange(), ) progress_embed.add_field(name="Deleted", value="0", inline=True) progress_embed.add_field(name="Not Found", value="0", inline=True) progress_embed.add_field(name="Errors", value="0", inline=True) progress_embed.set_footer(text="This may take a while...") progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True) # Process each tracked embed in this guild for server_id, location in list(guild_locations.items()): try: channel = bot.get_channel(int(location["channel_id"])) if channel: # Verify channel belongs to the correct guild if channel.guild.id != guild_id: logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}, skipping") not_found_count += 1 else: try: message = await channel.fetch_message(int(location["message_id"])) await message.delete() deleted_count += 1 logger.debug(f"Successfully purged embed for server {server_id} in guild {guild_id}") except discord.NotFound: not_found_count += 1 logger.debug(f"Embed for server {server_id} in guild {guild_id} already deleted") except discord.Forbidden: error_count += 1 logger.error(f"Permission denied when deleting embed for server {server_id} in guild {guild_id}") except Exception as e: error_count += 1 logger.error(f"Error deleting embed for server {server_id} in guild {guild_id}: {str(e)}") else: not_found_count += 1 logger.warning(f"Channel not found for server {server_id} in guild {guild_id}") # Remove from tracking immediately await bot.embed_manager.untrack_embed(guild_id, server_id) # Update progress every 5 embeds or for the last one processed = deleted_count + not_found_count + error_count if processed % 5 == 0 or processed == total_embeds: progress_embed.description = f"Processed {processed}/{total_embeds} embeds" progress_embed.set_field_at(0, name="Deleted", value=str(deleted_count), inline=True) progress_embed.set_field_at(1, name="Not Found", value=str(not_found_count), inline=True) progress_embed.set_field_at(2, name="Errors", value=str(error_count), inline=True) await progress_message.edit(embed=progress_embed) # Small delay to avoid rate limits await asyncio.sleep(0.3) except Exception as e: error_count += 1 logger.error(f"Unexpected error processing server {server_id} in guild {guild_id}: {str(e)}") # Create results embed result_embed = discord.Embed( title="✅ Embed Purge Complete", color=discord.Color.green(), timestamp=datetime.now(), ) result_embed.add_field(name="Total Tracked", value=str(total_embeds), inline=True) result_embed.add_field(name="✅ Successfully Deleted", value=str(deleted_count), inline=True) result_embed.add_field(name="❌ Already Missing", value=str(not_found_count), inline=True) result_embed.add_field(name="âš ī¸ Errors", value=str(error_count), inline=True) result_embed.add_field( name="📊 Success Rate", value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%", inline=True, ) result_embed.set_footer(text="Embed tracking for this guild has been cleared") await progress_message.edit(embed=result_embed) logger.info(f"Embed purge completed for guild {guild_id}: {deleted_count} deleted, {not_found_count} not found, {error_count} errors") except Exception as e: logger.error(f"Embed purge failed for guild {guild_id}: {str(e)}") await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True) # ============================================== # SYSTEM SIGNAL HANDLERS # ============================================== def handle_sigint(signum: int, frame: types.FrameType) -> None: """ Handle SIGINT signals (Ctrl+C) by initiating graceful shutdown. Args: signum: The signal number (signal.SIGINT) frame: Current stack frame (unused but required by signal handler signature) """ logger.info("Received SIGINT (Ctrl+C), initiating graceful shutdown...") raise KeyboardInterrupt def handle_sigterm(signum: int, frame: types.FrameType) -> None: """ Handle SIGTERM signals (container stop) by initiating graceful shutdown. Args: signum: The signal number (signal.SIGTERM) frame: Current stack frame (unused but required by signal handler signature) """ logger.info("Received SIGTERM (container stop), initiating graceful shutdown...") raise KeyboardInterrupt # ============================================== # BOT STARTUP # ============================================== async def run_bot(config: Config) -> None: """ Main async entry point for the bot application. Args: config: Validated application configuration """ logger.info("Starting bot initialization") # Create bot instance intents = discord.Intents.default() intents.message_content = True bot = PterodactylBot(config, command_prefix="!", intents=intents) # Set up commands and events await setup_commands(bot) # Register signal handlers signal.signal(signal.SIGINT, handle_sigint) signal.signal(signal.SIGTERM, handle_sigterm) logger.info("System signal handlers registered") try: await bot.start(config.discord_token) except KeyboardInterrupt: logger.info("Bot shutting down") except Exception as e: logger.error(f"Bot crashed: {str(e)}") sys.exit(1) finally: logger.info("Bot shutdown initiated - performing cleanup") await bot.close() logger.info("Bot shutdown complete")