From bec3efcdd0ca7b255e921481854bcdc79d2bd4dc Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Mon, 27 Oct 2025 09:43:24 +0000 Subject: [PATCH] Update: Code formatting --- pterodisbot.py | 599 ++++++++++----------------------------- server_metrics_graphs.py | 87 ++---- 2 files changed, 180 insertions(+), 506 deletions(-) diff --git a/pterodisbot.py b/pterodisbot.py index 16d2ac3..fe00b25 100644 --- a/pterodisbot.py +++ b/pterodisbot.py @@ -13,23 +13,25 @@ Features: - Extensive logging for all operations """ -import discord -from discord.ext import commands, tasks -from server_metrics_graphs import ServerMetricsManager -import os -import sys -import signal -import types -import aiohttp import asyncio +import configparser import json import logging -from logging.handlers import RotatingFileHandler -import configparser +import os +import signal +import sys +import types from datetime import datetime -from typing import Dict, List, Optional, Tuple +from logging.handlers import RotatingFileHandler from pathlib import Path +from typing import Dict, List, Optional, Tuple + +import aiohttp +import discord import matplotlib +from discord.ext import commands, tasks + +from server_metrics_graphs import ServerMetricsManager matplotlib.use("Agg") # Use non-interactive backend for server environments @@ -56,9 +58,7 @@ logger.addHandler(handler) # Console handler for real-time output console_handler = logging.StreamHandler() console_handler.setLevel(logging.INFO) -console_handler.setFormatter( - logging.Formatter("%(asctime)s - %(levelname)s - %(message)s") -) +console_handler.setFormatter(logging.Formatter("%(asctime)s - %(levelname)s - %(message)s")) logger.addHandler(console_handler) logger.info("Initialized logging system with file and console output") @@ -128,9 +128,7 @@ def validate_config(): # Validate PanelURL is a valid URL panel_url = config.get("Pterodactyl", "PanelURL", fallback="") - if panel_url and not ( - panel_url.startswith("http://") or panel_url.startswith("https://") - ): + if panel_url and not (panel_url.startswith("http://") or panel_url.startswith("https://")): errors.append("PanelURL must start with http:// or https://") if errors: @@ -230,14 +228,10 @@ class PterodactylAPI: """ url = f"{self.panel_url}/api/{endpoint}" api_key_type = "Application" if use_application_key else "Client" - logger.debug( - f"Preparing {method} request to {endpoint} using {api_key_type} API key" - ) + logger.debug(f"Preparing {method} request to {endpoint} using {api_key_type} API key") # Choose the appropriate API key - api_key = ( - self.application_api_key if use_application_key else self.client_api_key - ) + api_key = self.application_api_key if use_application_key else self.client_api_key headers = { "Authorization": f"Bearer {api_key}", "Accept": "application/json", @@ -247,27 +241,17 @@ class PterodactylAPI: try: async with self.lock: logger.debug(f"Acquired lock for API request to {endpoint}") - async with self.session.request( - method, url, headers=headers, json=data - ) as response: + async with self.session.request(method, url, headers=headers, json=data) as response: if response.status == 204: # No content - logger.debug( - f"Received 204 No Content response from {endpoint}" - ) + logger.debug(f"Received 204 No Content response from {endpoint}") return {"status": "success"} response_data = await response.json() - logger.debug( - f"Received response from {endpoint} with status {response.status}" - ) + logger.debug(f"Received response from {endpoint} with status {response.status}") if response.status >= 400: - error_msg = response_data.get("errors", [{}])[0].get( - "detail", "Unknown error" - ) - logger.error( - f"API request to {endpoint} failed with status {response.status}: {error_msg}" - ) + error_msg = response_data.get("errors", [{}])[0].get("detail", "Unknown error") + logger.error(f"API request to {endpoint} failed with status {response.status}: {error_msg}") return {"status": "error", "message": error_msg} return response_data @@ -284,9 +268,7 @@ class PterodactylAPI: List of server dictionaries containing all server attributes """ logger.info("Fetching list of all servers from Pterodactyl panel") - response = await self._request( - "GET", "application/servers", use_application_key=True - ) + response = await self._request("GET", "application/servers", use_application_key=True) servers = response.get("data", []) logger.info(f"Retrieved {len(servers)} servers from Pterodactyl panel") return servers @@ -304,23 +286,17 @@ class PterodactylAPI: """ logger.debug(f"Fetching resource usage for server {server_id}") try: - response = await self._request( - "GET", f"client/servers/{server_id}/resources" - ) + response = await self._request("GET", f"client/servers/{server_id}/resources") if response.get("status") == "error": error_msg = response.get("message", "Unknown error") - logger.error( - f"Failed to get resources for server {server_id}: {error_msg}" - ) + logger.error(f"Failed to get resources for server {server_id}: {error_msg}") return {"attributes": {"current_state": "offline"}} state = response.get("attributes", {}).get("current_state", "unknown") logger.debug(f"Server {server_id} current state: {state}") return response except Exception as e: - logger.error( - f"Exception getting resources for server {server_id}: {str(e)}" - ) + logger.error(f"Exception getting resources for server {server_id}: {str(e)}") return {"attributes": {"current_state": "offline"}} async def send_power_action(self, server_id: str, action: str) -> dict: @@ -344,16 +320,12 @@ class PterodactylAPI: } logger.info(f"Sending {action} command to server {server_id}") - result = await self._request( - "POST", f"client/servers/{server_id}/power", {"signal": action} - ) + result = await self._request("POST", f"client/servers/{server_id}/power", {"signal": action}) if result.get("status") == "success": logger.info(f"Successfully executed {action} on server {server_id}") else: - logger.error( - f"Failed to execute {action} on server {server_id}: {result.get('message', 'Unknown error')}" - ) + logger.error(f"Failed to execute {action} on server {server_id}: {result.get('message', 'Unknown error')}") return result @@ -369,9 +341,7 @@ class PterodactylAPI: Dictionary containing detailed server information """ logger.debug(f"Fetching detailed information for server {server_id}") - return await self._request( - "GET", f"application/servers/{server_id}", use_application_key=True - ) + return await self._request("GET", f"application/servers/{server_id}", use_application_key=True) async def get_server_allocations(self, server_id: str) -> dict: """ @@ -438,23 +408,15 @@ class ServerStatusView(discord.ui.View): """ # First check if interaction is from the allowed guild if interaction.guild_id != ALLOWED_GUILD_ID: - logger.warning( - f"Unauthorized interaction attempt from guild {interaction.guild_id}" - ) - await interaction.response.send_message( - "This bot is only available in a specific server.", ephemeral=True - ) + logger.warning(f"Unauthorized interaction attempt from guild {interaction.guild_id}") + await interaction.response.send_message("This bot is only available in a specific server.", ephemeral=True) return False # Then check for required role - logger.debug( - f"Checking permissions for {interaction.user.name} on server {self.server_name}" - ) + logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name}") has_role = any(role.name == REQUIRED_ROLE for role in interaction.user.roles) if not has_role: - logger.warning( - f"Permission denied for {interaction.user.name} - missing '{REQUIRED_ROLE}' role" - ) + logger.warning(f"Permission denied for {interaction.user.name} - missing '{REQUIRED_ROLE}' role") await interaction.response.send_message( f"You don't have permission to control servers. You need the '{REQUIRED_ROLE}' role.", ephemeral=True, @@ -464,9 +426,7 @@ class ServerStatusView(discord.ui.View): logger.debug(f"Permission granted for {interaction.user.name}") return True - async def on_error( - self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item - ): + async def on_error(self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item): """ Handle errors in button interactions. @@ -475,23 +435,13 @@ class ServerStatusView(discord.ui.View): error: Exception that occurred item: The UI item that triggered the error """ - logger.error( - f"View error in {self.server_name} by {interaction.user.name}: {str(error)}" - ) - await interaction.response.send_message( - "An error occurred while processing your request.", ephemeral=True - ) + logger.error(f"View error in {self.server_name} by {interaction.user.name}: {str(error)}") + await interaction.response.send_message("An error occurred while processing your request.", ephemeral=True) - @discord.ui.button( - label="Start", style=discord.ButtonStyle.green, custom_id="start_button" - ) - async def start_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + @discord.ui.button(label="Start", style=discord.ButtonStyle.green, custom_id="start_button") + async def start_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Send a start command to the server.""" - logger.info( - f"Start button pressed for {self.server_name} by {interaction.user.name}" - ) + logger.info(f"Start button pressed for {self.server_name} by {interaction.user.name}") await interaction.response.defer(ephemeral=True) result = await self.api.send_power_action(self.server_id, "start") @@ -499,23 +449,15 @@ class ServerStatusView(discord.ui.View): message = f"Server '{self.server_name}' is starting..." logger.info(f"Successfully started server {self.server_name}") else: - message = ( - f"Failed to start server: {result.get('message', 'Unknown error')}" - ) + message = f"Failed to start server: {result.get('message', 'Unknown error')}" logger.error(f"Failed to start server {self.server_name}: {message}") await interaction.followup.send(message, ephemeral=True) - @discord.ui.button( - label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button" - ) - async def stop_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + @discord.ui.button(label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button") + async def stop_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Send a stop command to the server.""" - logger.info( - f"Stop button pressed for {self.server_name} by {interaction.user.name}" - ) + logger.info(f"Stop button pressed for {self.server_name} by {interaction.user.name}") await interaction.response.defer(ephemeral=True) result = await self.api.send_power_action(self.server_id, "stop") @@ -528,16 +470,10 @@ class ServerStatusView(discord.ui.View): await interaction.followup.send(message, ephemeral=True) - @discord.ui.button( - label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button" - ) - async def restart_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + @discord.ui.button(label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button") + async def restart_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Send a restart command to the server.""" - logger.info( - f"Restart button pressed for {self.server_name} by {interaction.user.name}" - ) + logger.info(f"Restart button pressed for {self.server_name} by {interaction.user.name}") await interaction.response.defer(ephemeral=True) result = await self.api.send_power_action(self.server_id, "restart") @@ -545,9 +481,7 @@ class ServerStatusView(discord.ui.View): message = f"Server '{self.server_name}' is restarting..." logger.info(f"Successfully restarted server {self.server_name}") else: - message = ( - f"Failed to restart server: {result.get('message', 'Unknown error')}" - ) + message = f"Failed to restart server: {result.get('message', 'Unknown error')}" logger.error(f"Failed to restart server {self.server_name}: {message}") await interaction.followup.send(message, ephemeral=True) @@ -557,27 +491,19 @@ class ServerStatusView(discord.ui.View): style=discord.ButtonStyle.grey, custom_id="show_address_button", ) - async def show_address_button( - self, interaction: discord.Interaction, button: discord.ui.Button - ): + async def show_address_button(self, interaction: discord.Interaction, button: discord.ui.Button): """Show server's default allocation IP and port using client API.""" - logger.info( - f"Show Address button pressed for {self.server_name} by {interaction.user.name}" - ) + logger.info(f"Show Address button pressed for {self.server_name} by {interaction.user.name}") try: await interaction.response.defer(ephemeral=True) logger.debug(f"Fetching server details for {self.server_id}") # Get server details using client API - server_details = await self.api._request( - "GET", f"client/servers/{self.server_id}", use_application_key=False - ) + server_details = await self.api._request("GET", f"client/servers/{self.server_id}", use_application_key=False) if server_details.get("status") == "error": error_msg = server_details.get("message", "Unknown error") - logger.error( - f"Failed to get server details for {self.server_id}: {error_msg}" - ) + logger.error(f"Failed to get server details for {self.server_id}: {error_msg}") raise ValueError(error_msg) attributes = server_details.get("attributes", {}) @@ -590,11 +516,7 @@ class ServerStatusView(discord.ui.View): # Find the default allocation (is_default=True) default_allocation = next( - ( - alloc - for alloc in allocations - if alloc.get("attributes", {}).get("is_default", False) - ), + (alloc for alloc in allocations if alloc.get("attributes", {}).get("is_default", False)), allocations[0], # Fallback to first allocation if no default found ) @@ -602,9 +524,7 @@ class ServerStatusView(discord.ui.View): ip_alias = allocation_attrs.get("ip_alias", "Unknown") port = str(allocation_attrs.get("port", "Unknown")) - logger.debug( - f"Retrieved connection info for {self.server_id}: {ip_alias}:{port}" - ) + logger.debug(f"Retrieved connection info for {self.server_id}: {ip_alias}:{port}") # Create and send embed embed = discord.Embed( @@ -646,14 +566,10 @@ class PterodactylBot(commands.Bot): super().__init__(*args, **kwargs) self.pterodactyl_api = None # Pterodactyl API client self.server_cache: Dict[str, dict] = {} # Cache of server data from Pterodactyl - self.embed_locations: Dict[str, Dict[str, int]] = ( - {} - ) # Tracks where embeds are posted + self.embed_locations: Dict[str, Dict[str, int]] = {} # Tracks where embeds are posted self.update_lock = asyncio.Lock() # Prevents concurrent updates self.embed_storage_path = Path(EMBED_LOCATIONS_FILE) # File to store embed - self.metrics_manager = ( - ServerMetricsManager() - ) # Data manager for metrics graphing system + self.metrics_manager = ServerMetricsManager() # Data manager for metrics graphing system # Track previous server states and CPU usage to detect changes # Format: {server_id: (state, cpu_usage, last_force_update)} self.previous_states: Dict[str, Tuple[str, float, Optional[float]]] = {} @@ -667,9 +583,7 @@ class PterodactylBot(commands.Bot): logger.info("Running bot setup hook") # Initialize API client - self.pterodactyl_api = PterodactylAPI( - PTERODACTYL_URL, PTERODACTYL_CLIENT_API_KEY, PTERODACTYL_APPLICATION_API_KEY - ) + self.pterodactyl_api = PterodactylAPI(PTERODACTYL_URL, PTERODACTYL_CLIENT_API_KEY, PTERODACTYL_APPLICATION_API_KEY) await self.pterodactyl_api.initialize() logger.info("Initialized Pterodactyl API client") @@ -690,9 +604,7 @@ class PterodactylBot(commands.Bot): try: with open(self.embed_storage_path, "r") as f: self.embed_locations = json.load(f) - logger.info( - f"Loaded {len(self.embed_locations)} embed locations from storage" - ) + logger.info(f"Loaded {len(self.embed_locations)} embed locations from storage") except Exception as e: logger.error(f"Failed to load embed locations: {str(e)}") @@ -727,9 +639,7 @@ class PterodactylBot(commands.Bot): logger.warning("No servers found in Pterodactyl panel") return 0, 0 - self.server_cache = { - server["attributes"]["identifier"]: server for server in servers - } + self.server_cache = {server["attributes"]["identifier"]: server for server in servers} logger.info(f"Populated server cache with {len(servers)} servers") # Create new embeds in temporary storage @@ -746,22 +656,16 @@ class PterodactylBot(commands.Bot): channel_id = self.embed_locations[server_id]["channel_id"] channel = self.get_channel(int(channel_id)) if not channel: - logger.warning( - f"Channel {channel_id} not found for server {server_id}" - ) + logger.warning(f"Channel {channel_id} not found for server {server_id}") continue try: logger.debug(f"Creating new embed for server {server_id}") # Get current server status - resources = await self.pterodactyl_api.get_server_resources( - server_id - ) + resources = await self.pterodactyl_api.get_server_resources(server_id) # Create new embed - embed, view = await self.get_server_status_embed( - server_data, resources - ) + embed, view = await self.get_server_status_embed(server_data, resources) message = await channel.send(embed=embed, view=view) # Store in temporary location @@ -770,19 +674,13 @@ class PterodactylBot(commands.Bot): "message_id": str(message.id), } created_count += 1 - logger.info( - f"Created new embed for server {server_data['attributes']['name']}" - ) + logger.info(f"Created new embed for server {server_data['attributes']['name']}") await asyncio.sleep(1) # Rate limit protection except Exception as e: - logger.error( - f"Failed to create new embed for server {server_id}: {str(e)}" - ) + logger.error(f"Failed to create new embed for server {server_id}: {str(e)}") - logger.info( - f"Created {created_count} new embeds, skipped {skipped_count} servers" - ) + logger.info(f"Created {created_count} new embeds, skipped {skipped_count} servers") # Only proceed if we created at least one new embed if not new_embeds: @@ -798,32 +696,20 @@ class PterodactylBot(commands.Bot): channel = self.get_channel(int(location["channel_id"])) if channel: try: - message = await channel.fetch_message( - int(location["message_id"]) - ) + message = await channel.fetch_message(int(location["message_id"])) await message.delete() deleted_count += 1 - logger.debug( - f"Deleted old embed for server {server_id}" - ) + logger.debug(f"Deleted old embed for server {server_id}") await asyncio.sleep(0.5) # Rate limit protection except discord.NotFound: not_found_count += 1 - logger.debug( - f"Old embed for server {server_id} already deleted" - ) + logger.debug(f"Old embed for server {server_id} already deleted") except Exception as e: - logger.error( - f"Failed to delete old embed for server {server_id}: {str(e)}" - ) + logger.error(f"Failed to delete old embed for server {server_id}: {str(e)}") except Exception as e: - logger.error( - f"Error processing old embed for server {server_id}: {str(e)}" - ) + logger.error(f"Error processing old embed for server {server_id}: {str(e)}") - logger.info( - f"Deleted {deleted_count} old embeds, {not_found_count} already missing" - ) + logger.info(f"Deleted {deleted_count} old embeds, {not_found_count} already missing") # Update storage with new embed locations self.embed_locations = new_embeds @@ -843,18 +729,14 @@ class PterodactylBot(commands.Bot): server_id: The server's Pterodactyl identifier message: Discord message containing the embed """ - logger.debug( - f"Tracking new embed for server {server_id} in channel {message.channel.id}" - ) + logger.debug(f"Tracking new embed for server {server_id} in channel {message.channel.id}") self.embed_locations[server_id] = { "channel_id": str(message.channel.id), "message_id": str(message.id), } await self.save_embed_locations() - async def get_server_status_embed( - self, server_data: dict, resources: dict - ) -> Tuple[discord.Embed, ServerStatusView]: + async def get_server_status_embed(self, server_data: dict, resources: dict) -> Tuple[discord.Embed, ServerStatusView]: """ Create a status embed and view for a server. @@ -880,11 +762,7 @@ class PterodactylBot(commands.Bot): embed = discord.Embed( title=f"{name} - {current_state}", description=description, - color=( - discord.Color.blue() - if current_state.lower() == "running" - else discord.Color.red() - ), + color=(discord.Color.blue() if current_state.lower() == "running" else discord.Color.red()), timestamp=datetime.now(), ) @@ -898,27 +776,21 @@ class PterodactylBot(commands.Bot): # Add resource usage if server is running if current_state.lower() != "offline": # Current usage - cpu_usage = round( - resource_attributes.get("resources", {}).get("cpu_absolute", 0), 2 - ) + cpu_usage = round(resource_attributes.get("resources", {}).get("cpu_absolute", 0), 2) memory_usage = round( - resource_attributes.get("resources", {}).get("memory_bytes", 0) - / (1024**2), + resource_attributes.get("resources", {}).get("memory_bytes", 0) / (1024**2), 2, ) disk_usage = round( - resource_attributes.get("resources", {}).get("disk_bytes", 0) - / (1024**2), + resource_attributes.get("resources", {}).get("disk_bytes", 0) / (1024**2), 2, ) network_rx = round( - resource_attributes.get("resources", {}).get("network_rx_bytes", 0) - / (1024**2), + resource_attributes.get("resources", {}).get("network_rx_bytes", 0) / (1024**2), 2, ) network_tx = round( - resource_attributes.get("resources", {}).get("network_tx_bytes", 0) - / (1024**2), + resource_attributes.get("resources", {}).get("network_tx_bytes", 0) / (1024**2), 2, ) @@ -969,13 +841,9 @@ class PterodactylBot(commands.Bot): embed.add_field(name="📊 Resource Usage", value=usage_text, inline=False) - embed.add_field( - name="Network In", value=f"📥 `{network_rx} MiB`", inline=True - ) + embed.add_field(name="Network In", value=f"📥 `{network_rx} MiB`", inline=True) - embed.add_field( - name="Network Out", value=f"📤 `{network_tx} MiB`", inline=True - ) + embed.add_field(name="Network Out", value=f"📤 `{network_tx} MiB`", inline=True) # Add graph images if available server_graphs = self.metrics_manager.get_server_graphs(identifier) @@ -1028,15 +896,11 @@ class PterodactylBot(commands.Bot): # Fetch current server list from Pterodactyl servers = await self.pterodactyl_api.get_servers() if not servers: - logger.warning( - "No servers found in Pterodactyl panel during update" - ) + logger.warning("No servers found in Pterodactyl panel during update") return # Update our local cache with fresh server data - self.server_cache = { - server["attributes"]["identifier"]: server for server in servers - } + self.server_cache = {server["attributes"]["identifier"]: server for server in servers} logger.debug(f"Updated server cache with {len(servers)} servers") # Clean up metrics for servers that no longer exist @@ -1054,89 +918,58 @@ class PterodactylBot(commands.Bot): for server_id, location in list(self.embed_locations.items()): # Skip if server no longer exists in Pterodactyl if server_id not in self.server_cache: - logger.warning( - f"Server {server_id} not found in cache, skipping update" - ) + logger.warning(f"Server {server_id} not found in cache, skipping update") 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})" - ) + logger.debug(f"Checking status for server {server_name} ({server_id})") # Get current server resource usage - resources = await self.pterodactyl_api.get_server_resources( - server_id - ) - current_state = resources.get("attributes", {}).get( - "current_state", "offline" - ) + resources = await self.pterodactyl_api.get_server_resources(server_id) + current_state = resources.get("attributes", {}).get("current_state", "offline") cpu_usage = round( - resources.get("attributes", {}) - .get("resources", {}) - .get("cpu_absolute", 0), + resources.get("attributes", {}).get("resources", {}).get("cpu_absolute", 0), 2, ) # Collect metrics data for running servers if current_state == "running": memory_usage = round( - resources.get("attributes", {}) - .get("resources", {}) - .get("memory_bytes", 0) - / (1024**2), + resources.get("attributes", {}).get("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}: CPU={cpu_usage}%, Memory={memory_usage}MB" - ) + self.metrics_manager.add_server_data(server_id, server_name, cpu_usage, memory_usage) + logger.debug(f"Added metrics data for {server_name}: CPU={cpu_usage}%, Memory={memory_usage}MB") # Retrieve previous recorded state, CPU usage, and last force update time - prev_state, prev_cpu, last_force_update = ( - self.previous_states.get(server_id, (None, 0, None)) - ) + prev_state, prev_cpu, last_force_update = self.previous_states.get(server_id, (None, 0, None)) # DECISION LOGIC: Should we update the embed? needs_update = False # 1. Check if power state changed (most important) if current_state != prev_state: - logger.debug( - f"Power state changed for {server_name}: {prev_state} -> {current_state}" - ) + logger.debug(f"Power state changed for {server_name}: {prev_state} -> {current_state}") needs_update = True # 2. Check for significant CPU change (only if server is running) - elif ( - current_state == "running" - and abs(cpu_usage - prev_cpu) > 50 - ): - logger.debug( - f"Significant CPU change for {server_name}: {prev_cpu}% -> {cpu_usage}%" - ) + elif current_state == "running" and abs(cpu_usage - prev_cpu) > 50: + logger.debug(f"Significant CPU change for {server_name}: {prev_cpu}% -> {cpu_usage}%") needs_update = True # 3. First time we're seeing this server (initial update) elif prev_state is None: - logger.debug( - f"First check for {server_name}, performing initial update" - ) + logger.debug(f"First check for {server_name}, performing initial update") needs_update = True # 4. Force update every 10 minutes for running servers (for uptime counter) elif current_state == "running" and ( - last_force_update is None - or current_time - last_force_update >= 600 + last_force_update is None or current_time - last_force_update >= 600 ): # 10 minutes = 600 seconds - logger.debug( - f"Executing 10-minute force update for running server {server_name}" - ) + logger.debug(f"Executing 10-minute force update for running server {server_name}") needs_update = True # Update the last force update time last_force_update = current_time @@ -1144,36 +977,24 @@ class PterodactylBot(commands.Bot): # PERFORM UPDATE IF NEEDED if needs_update: # Generate fresh embed and view components - embed, view = await self.get_server_status_embed( - server_data, resources - ) + embed, view = await self.get_server_status_embed(server_data, resources) # Get the channel where this server's embed lives channel = self.get_channel(int(location["channel_id"])) if not channel: - logger.warning( - f"Channel {location['channel_id']} not found for server {server_id}" - ) + logger.warning(f"Channel {location['channel_id']} not found for server {server_id}") continue # Fetch and update the existing message - message = await channel.fetch_message( - int(location["message_id"]) - ) + message = await channel.fetch_message(int(location["message_id"])) # Check if server is transitioning to offline/stopping state # and remove image attachment if present files = [] - server_graphs = self.metrics_manager.get_server_graphs( - server_id - ) + server_graphs = self.metrics_manager.get_server_graphs(server_id) # Only include graph images if server is running AND has sufficient data - if ( - current_state == "running" - and server_graphs - and server_graphs.has_sufficient_data - ): + if current_state == "running" and server_graphs and server_graphs.has_sufficient_data: # Generate metrics graph combined_graph = server_graphs.generate_combined_graph() if combined_graph: @@ -1183,20 +1004,14 @@ class PterodactylBot(commands.Bot): filename=f"metrics_graph_{server_id}.png", ) ) - logger.debug( - f"Including metrics graph for running server {server_name}" - ) + logger.debug(f"Including metrics graph for running server {server_name}") else: # Server is offline/stopping - ensure no image is attached - logger.debug( - f"Server {server_name} is {current_state}, removing image attachment if present" - ) + logger.debug(f"Server {server_name} is {current_state}, removing image attachment if present") # We'll update without files to remove any existing attachments # Update message with embed, view, and files (empty files list removes attachments) - await message.edit( - embed=embed, view=view, attachments=files - ) + await message.edit(embed=embed, view=view, attachments=files) update_count += 1 logger.debug(f"Updated status for {server_name}") @@ -1204,14 +1019,8 @@ class PterodactylBot(commands.Bot): # Only update last_force_update if this was a force update new_last_force_update = ( last_force_update - if needs_update - and current_state == "running" - and current_time - (last_force_update or 0) >= 600 - else ( - last_force_update - if last_force_update is not None - else None - ) + if needs_update and current_state == "running" and current_time - (last_force_update or 0) >= 600 + else (last_force_update if last_force_update is not None else None) ) self.previous_states[server_id] = ( current_state, @@ -1226,25 +1035,17 @@ class PterodactylBot(commands.Bot): last_force_update, ) skipped_count += 1 - logger.debug( - f"No changes detected for {server_name}, skipping update" - ) + logger.debug(f"No changes detected for {server_name}, skipping update") except discord.NotFound: # Embed message was deleted - clean up our tracking - logger.warning( - f"Embed for server {server_id} not found, removing from tracking" - ) + logger.warning(f"Embed for server {server_id} not found, removing from tracking") self.embed_locations.pop(server_id, None) - self.previous_states.pop( - server_id, None - ) # Also clean up state tracking + self.previous_states.pop(server_id, None) # Also clean up state tracking missing_count += 1 await self.save_embed_locations() except Exception as e: - logger.error( - f"Failed to update status for server {server_id}: {str(e)}" - ) + logger.error(f"Failed to update status for server {server_id}: {str(e)}") error_count += 1 # Small delay between servers to avoid rate limits @@ -1309,19 +1110,13 @@ async def check_allowed_guild(interaction: discord.Interaction) -> bool: bool: True if interaction is allowed, False otherwise """ if interaction.guild_id != ALLOWED_GUILD_ID: - logger.warning( - f"Command attempted from unauthorized guild {interaction.guild_id} by {interaction.user.name}" - ) - await interaction.response.send_message( - "This bot is only available in a specific server.", ephemeral=True - ) + logger.warning(f"Command attempted from unauthorized guild {interaction.guild_id} by {interaction.user.name}") + await interaction.response.send_message("This bot is only available in a specific server.", ephemeral=True) return False return True -@bot.tree.command( - name="server_status", description="Get a list of available game servers to control" -) +@bot.tree.command(name="server_status", description="Get a list of available game servers to control") async def server_status(interaction: discord.Interaction): """ Slash command to display server status dashboard with interactive dropdown selection. @@ -1373,14 +1168,10 @@ async def server_status(interaction: discord.Interaction): servers = await bot.pterodactyl_api.get_servers() if not servers: logger.warning("No servers found in Pterodactyl panel") - await interaction.followup.send( - "No servers found in the Pterodactyl panel.", ephemeral=True - ) + await interaction.followup.send("No servers found in the Pterodactyl panel.", ephemeral=True) return - bot.server_cache = { - server["attributes"]["identifier"]: server for server in servers - } + bot.server_cache = {server["attributes"]["identifier"]: server for server in servers} logger.debug(f"Refreshed server cache with {len(servers)} servers") # Count online/offline servers by checking each server's current state @@ -1390,9 +1181,7 @@ async def server_status(interaction: discord.Interaction): # Check status for each server to generate accurate statistics for server_id, server_data in bot.server_cache.items(): resources = await bot.pterodactyl_api.get_server_resources(server_id) - current_state = resources.get("attributes", {}).get( - "current_state", "offline" - ) + current_state = resources.get("attributes", {}).get("current_state", "offline") if current_state == "running": online_count += 1 @@ -1409,9 +1198,7 @@ async def server_status(interaction: discord.Interaction): stats_embed.add_field( name="📊 Server Statistics", - value=f"**Total Servers:** {len(servers)}\n" - f"✅ **Online:** {online_count}\n" - f"❌ **Offline:** {offline_count}", + value=f"**Total Servers:** {len(servers)}\n" f"✅ **Online:** {online_count}\n" f"❌ **Offline:** {offline_count}", inline=False, ) @@ -1427,34 +1214,24 @@ async def server_status(interaction: discord.Interaction): 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" - ) + 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 - ) - ) + server_options.append(discord.SelectOption(label=server_name, value=server_id, description=server_description)) # Create dropdown view with timeout for automatic cleanup class ServerDropdownView(discord.ui.View): - def __init__( - self, server_options, timeout=180 - ): # 3 minute timeout for ephemeral cleanup + def __init__(self, server_options, timeout=180): # 3 minute timeout for ephemeral cleanup super().__init__(timeout=timeout) self.server_options = server_options self.add_item(ServerDropdown(server_options)) async def on_timeout(self): # Clean up when dropdown times out (ephemeral auto-removal) - logger.debug( - "Server dropdown timed out and was automatically cleaned up" - ) + logger.debug("Server dropdown timed out and was automatically cleaned up") # Dropdown selection handler for server choice class ServerDropdown(discord.ui.Select): @@ -1484,46 +1261,30 @@ async def server_status(interaction: discord.Interaction): return server_name = server_data["attributes"]["name"] - logger.info( - f"User {interaction.user.name} selected server: {server_name}" - ) + logger.info(f"User {interaction.user.name} selected server: {server_name}") try: # Get current server status for embed creation - resources = await bot.pterodactyl_api.get_server_resources( - selected_server_id - ) + resources = await bot.pterodactyl_api.get_server_resources(selected_server_id) # Delete old embed if it exists to prevent duplication if selected_server_id in bot.embed_locations: - logger.debug( - f"Found existing embed for {selected_server_id}, attempting to delete" - ) + logger.debug(f"Found existing embed for {selected_server_id}, attempting to delete") try: old_location = bot.embed_locations[selected_server_id] - old_channel = bot.get_channel( - int(old_location["channel_id"]) - ) + old_channel = bot.get_channel(int(old_location["channel_id"])) if old_channel: try: - old_message = await old_channel.fetch_message( - int(old_location["message_id"]) - ) + old_message = await old_channel.fetch_message(int(old_location["message_id"])) await old_message.delete() - logger.debug( - f"Deleted old embed for {selected_server_id}" - ) + logger.debug(f"Deleted old embed for {selected_server_id}") except discord.NotFound: - logger.debug( - f"Old embed for {selected_server_id} already deleted" - ) + logger.debug(f"Old embed for {selected_server_id} already deleted") except Exception as e: logger.error(f"Failed to delete old embed: {str(e)}") # Create and send new permanent status embed in channel - embed, view = await bot.get_server_status_embed( - server_data, resources - ) + embed, view = await bot.get_server_status_embed(server_data, resources) message = await interaction.channel.send(embed=embed, view=view) await bot.track_new_embed(selected_server_id, message) @@ -1534,32 +1295,22 @@ async def server_status(interaction: discord.Interaction): logger.info(f"Successfully posted status for {server_name}") except Exception as e: - logger.error( - f"Failed to create status embed for {server_name}: {str(e)}" - ) + logger.error(f"Failed to create status embed for {server_name}: {str(e)}") await interaction.followup.send( f"❌ Failed to create status embed for **{server_name}**: {str(e)}", ephemeral=True, ) # Send the initial dashboard embed with dropdown (ephemeral - auto-cleaned) - await interaction.followup.send( - embed=stats_embed, view=ServerDropdownView(server_options), ephemeral=True - ) - logger.info( - f"Sent server status dashboard to {interaction.user.name} with {len(server_options)} servers" - ) + await interaction.followup.send(embed=stats_embed, view=ServerDropdownView(server_options), ephemeral=True) + logger.info(f"Sent server status dashboard to {interaction.user.name} 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 - ) + 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 (admin only)" -) +@bot.tree.command(name="refresh_embeds", description="Refresh all server status embeds (admin only)") async def refresh_embeds(interaction: discord.Interaction): """Slash command to refresh all server embeds.""" if not await check_allowed_guild(interaction): @@ -1571,9 +1322,7 @@ async def refresh_embeds(interaction: discord.Interaction): # Require administrator permissions if not interaction.user.guild_permissions.administrator: logger.warning(f"Unauthorized refresh attempt by {interaction.user.name}") - await interaction.followup.send( - "You need administrator permissions to refresh all embeds.", ephemeral=True - ) + await interaction.followup.send("You need administrator permissions to refresh all embeds.", ephemeral=True) return try: @@ -1586,9 +1335,7 @@ async def refresh_embeds(interaction: discord.Interaction): logger.info(f"Embed refresh completed: {deleted} deleted, {created} created") except Exception as e: logger.error(f"Embed refresh failed: {str(e)}") - await interaction.followup.send( - f"Failed to refresh embeds: {str(e)}", ephemeral=True - ) + await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True) @bot.tree.command( @@ -1635,9 +1382,7 @@ async def purge_embeds(interaction: discord.Interaction): # Require administrator permissions if not interaction.user.guild_permissions.administrator: logger.warning(f"Unauthorized purge attempt by {interaction.user.name}") - await interaction.followup.send( - "You need administrator permissions to purge all embeds.", ephemeral=True - ) + await interaction.followup.send("You need administrator permissions to purge all embeds.", ephemeral=True) return try: @@ -1667,9 +1412,7 @@ async def purge_embeds(interaction: discord.Interaction): 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 - ) + progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True) # Process each tracked embed for server_id, location in list(bot.embed_locations.items()): @@ -1677,27 +1420,19 @@ async def purge_embeds(interaction: discord.Interaction): channel = bot.get_channel(int(location["channel_id"])) if channel: try: - message = await channel.fetch_message( - int(location["message_id"]) - ) + 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}" - ) + logger.debug(f"Successfully purged embed for server {server_id}") except discord.NotFound: not_found_count += 1 logger.debug(f"Embed for server {server_id} already deleted") except discord.Forbidden: error_count += 1 - logger.error( - f"Permission denied when deleting embed for server {server_id}" - ) + logger.error(f"Permission denied when deleting embed for server {server_id}") except Exception as e: error_count += 1 - logger.error( - f"Error deleting embed for server {server_id}: {str(e)}" - ) + logger.error(f"Error deleting embed for server {server_id}: {str(e)}") else: not_found_count += 1 logger.warning(f"Channel not found for server {server_id}") @@ -1712,15 +1447,9 @@ async def purge_embeds(interaction: discord.Interaction): ) == total_embeds: progress_embed.description = f"Processed {deleted_count + not_found_count + error_count}/{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 - ) + 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) @@ -1729,9 +1458,7 @@ async def purge_embeds(interaction: discord.Interaction): except Exception as e: error_count += 1 - logger.error( - f"Unexpected error processing server {server_id}: {str(e)}" - ) + logger.error(f"Unexpected error processing server {server_id}: {str(e)}") # Save the cleared embed locations await bot.save_embed_locations() @@ -1742,15 +1469,9 @@ async def purge_embeds(interaction: discord.Interaction): 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="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", @@ -1760,15 +1481,11 @@ async def purge_embeds(interaction: discord.Interaction): result_embed.set_footer(text="Embed tracking file has been cleared") await progress_message.edit(embed=result_embed) - logger.info( - f"Embed purge completed: {deleted_count} deleted, {not_found_count} not found, {error_count} errors" - ) + logger.info(f"Embed purge completed: {deleted_count} deleted, {not_found_count} not found, {error_count} errors") except Exception as e: logger.error(f"Embed purge failed: {str(e)}") - await interaction.followup.send( - f"❌ Failed to purge embeds: {str(e)}", ephemeral=True - ) + await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True) # ============================================== @@ -1780,12 +1497,8 @@ async def purge_embeds(interaction: discord.Interaction): async def on_interaction(interaction: discord.Interaction): """Global interaction handler to check guild before processing any interaction.""" if interaction.guild_id != ALLOWED_GUILD_ID: - logger.debug( - f"Ignoring interaction from unauthorized guild {interaction.guild_id}" - ) - await interaction.response.send_message( - "This bot is only available in a specific server.", ephemeral=True - ) + logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}") + await interaction.response.send_message("This bot is only available in a specific server.", ephemeral=True) return @@ -1799,9 +1512,7 @@ async def on_ready(): guild = discord.Object(id=ALLOWED_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 {ALLOWED_GUILD_ID}: {[cmd.name for cmd in synced]}" - ) + logger.info(f"Successfully synced {len(synced)} command(s) to guild {ALLOWED_GUILD_ID}: {[cmd.name for cmd in synced]}") except Exception as e: logger.error(f"Command sync failed: {str(e)}") diff --git a/server_metrics_graphs.py b/server_metrics_graphs.py index 532f87b..8d6e89b 100644 --- a/server_metrics_graphs.py +++ b/server_metrics_graphs.py @@ -5,15 +5,16 @@ This module provides graphing capabilities for server CPU and memory usage. Generates line graphs as PNG images for embedding in Discord messages. """ -import matplotlib -import matplotlib.pyplot as plt -import matplotlib.dates as mdates -from collections import deque -from datetime import datetime -from typing import Dict, Optional import io import logging import math +from collections import deque +from datetime import datetime +from typing import Dict, Optional + +import matplotlib +import matplotlib.dates as mdates +import matplotlib.pyplot as plt matplotlib.use("Agg") # Use non-interactive backend for server environments @@ -52,13 +53,9 @@ class ServerMetricsGraphs: # Track if we have enough data for meaningful graphs (at least 2 points) self.has_sufficient_data = False - logger.debug( - f"Initialized metrics tracking for server {server_name} ({server_id})" - ) + logger.debug(f"Initialized metrics tracking for server {server_name} ({server_id})") - def add_data_point( - self, cpu_percent: float, memory_mb: float, timestamp: Optional[datetime] = None - ): + def add_data_point(self, cpu_percent: float, memory_mb: float, timestamp: Optional[datetime] = None): """ Add a new data point to the metrics history. @@ -76,9 +73,7 @@ class ServerMetricsGraphs: # Update sufficient data flag self.has_sufficient_data = len(self.data_points) >= 2 - logger.debug( - f"Added metrics data point for {self.server_name}: CPU={cpu_percent}%, Memory={memory_mb}MB" - ) + logger.debug(f"Added metrics data point for {self.server_name}: CPU={cpu_percent}%, Memory={memory_mb}MB") def _calculate_cpu_scale_limit(self, max_cpu_value: float) -> int: """ @@ -105,9 +100,7 @@ class ServerMetricsGraphs: BytesIO object containing PNG image data, or None if insufficient data """ if not self.has_sufficient_data: - logger.debug( - f"Insufficient data for CPU graph generation: {self.server_name}" - ) + logger.debug(f"Insufficient data for CPU graph generation: {self.server_name}") return None try: @@ -134,9 +127,7 @@ class ServerMetricsGraphs: # Add horizontal grid lines at 100% increments for better readability for i in range(100, cpu_scale_limit + 1, 100): - ax.axhline( - y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8 - ) + ax.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8) # Format time axis ax.xaxis.set_major_formatter(mdates.DateFormatter("%H:%M:%S")) @@ -182,15 +173,11 @@ class ServerMetricsGraphs: # Clean up matplotlib resources plt.close(fig) - logger.debug( - f"Generated CPU graph for {self.server_name} (scale: 0-{cpu_scale_limit}%)" - ) + logger.debug(f"Generated CPU graph for {self.server_name} (scale: 0-{cpu_scale_limit}%)") return img_buffer except Exception as e: - logger.error( - f"Failed to generate CPU graph for {self.server_name}: {str(e)}" - ) + logger.error(f"Failed to generate CPU graph for {self.server_name}: {str(e)}") plt.close("all") # Clean up any remaining figures return None @@ -202,9 +189,7 @@ class ServerMetricsGraphs: BytesIO object containing PNG image data, or None if insufficient data """ if not self.has_sufficient_data: - logger.debug( - f"Insufficient data for memory graph generation: {self.server_name}" - ) + logger.debug(f"Insufficient data for memory graph generation: {self.server_name}") return None try: @@ -274,9 +259,7 @@ class ServerMetricsGraphs: return img_buffer except Exception as e: - logger.error( - f"Failed to generate memory graph for {self.server_name}: {str(e)}" - ) + logger.error(f"Failed to generate memory graph for {self.server_name}: {str(e)}") plt.close("all") # Clean up any remaining figures return None @@ -288,9 +271,7 @@ class ServerMetricsGraphs: BytesIO object containing PNG image data, or None if insufficient data """ if not self.has_sufficient_data: - logger.debug( - f"Insufficient data for combined graph generation: {self.server_name}" - ) + logger.debug(f"Insufficient data for combined graph generation: {self.server_name}") return None try: @@ -326,9 +307,7 @@ class ServerMetricsGraphs: # Add horizontal grid lines at 100% increments for CPU subplot for i in range(100, cpu_scale_limit + 1, 100): - ax1.axhline( - y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8 - ) + ax1.axhline(y=i, color="#ffffff", alpha=0.2, linestyle="--", linewidth=0.8) # Title with vCPU info if applicable title = f"{self.server_name} - Resource Usage" @@ -387,15 +366,11 @@ class ServerMetricsGraphs: plt.close(fig) - logger.debug( - f"Generated combined graph for {self.server_name} (CPU scale: 0-{cpu_scale_limit}%)" - ) + logger.debug(f"Generated combined graph for {self.server_name} (CPU scale: 0-{cpu_scale_limit}%)") return img_buffer except Exception as e: - logger.error( - f"Failed to generate combined graph for {self.server_name}: {str(e)}" - ) + logger.error(f"Failed to generate combined graph for {self.server_name}: {str(e)}") plt.close("all") return None @@ -468,9 +443,7 @@ class ServerMetricsManager: self.server_graphs: Dict[str, ServerMetricsGraphs] = {} logger.info("Initialized ServerMetricsManager") - def get_or_create_server_graphs( - self, server_id: str, server_name: str - ) -> ServerMetricsGraphs: + def get_or_create_server_graphs(self, server_id: str, server_name: str) -> ServerMetricsGraphs: """ Get existing ServerMetricsGraphs instance or create a new one. @@ -487,9 +460,7 @@ class ServerMetricsManager: return self.server_graphs[server_id] - def add_server_data( - self, server_id: str, server_name: str, cpu_percent: float, memory_mb: float - ): + def add_server_data(self, server_id: str, server_name: str, cpu_percent: float, memory_mb: float): """ Add data point to a server's metrics tracking. @@ -541,9 +512,7 @@ class ServerMetricsManager: self.remove_server(server_id) if servers_to_remove: - logger.info( - f"Cleaned up metrics for {len(servers_to_remove)} inactive servers" - ) + logger.info(f"Cleaned up metrics for {len(servers_to_remove)} inactive servers") def get_summary(self) -> Dict[str, any]: """ @@ -554,12 +523,6 @@ class ServerMetricsManager: """ return { "total_servers": len(self.server_graphs), - "servers_with_data": sum( - 1 - for graphs in self.server_graphs.values() - if graphs.has_sufficient_data - ), - "total_data_points": sum( - len(graphs.data_points) for graphs in self.server_graphs.values() - ), + "servers_with_data": sum(1 for graphs in self.server_graphs.values() if graphs.has_sufficient_data), + "total_data_points": sum(len(graphs.data_points) for graphs in self.server_graphs.values()), }