From bec06bd9093153403dab4b15b85dfac9240eed99 Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Fri, 30 Oct 2026 10:37:13 +0000 Subject: [PATCH 1/4] feat: Multi-guild support implementation - Added multi-guild support with comma-separated AllowedGuildIDs config - Per-guild embed tracking with isolated state - Per-server action locking to prevent race conditions across guilds - Cross-guild embed updates after power actions - Debug logging for embed update triggers - Fixed resource usage field formatting (brackets now properly closed) - Updated config.ini.sample, generate_config.py for backward compatibility - Updated README.md with multi-guild documentation - Updated tests for multi-guild support --- 8} | 0 README.md | 51 ++-- bot.py | 602 ++++++++++++++++++++++++++++---------------- config.ini.sample | 2 +- config.py | 63 ++++- embeds.py | 211 +++++++++++----- generate_config.py | 52 +++- models.py | 8 +- test_pterodisbot.py | 51 ++-- views.py | 170 +++++++------ 10 files changed, 802 insertions(+), 408 deletions(-) create mode 100644 8} diff --git a/8} b/8} new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index d4c601b..480b1d6 100644 --- a/README.md +++ b/README.md @@ -61,10 +61,11 @@ A feature-rich Discord bot for monitoring and managing Pterodactyl game servers - **Background Task Management**: Asynchronous update loop runs every 10 seconds ### 🔒 Security & Access Control -- **Single-Guild Restriction**: Bot operates exclusively in your designated Discord server -- **Role-based Permissions**: "Game Server User" role required for all server interactions -- **Administrator Commands**: Special management commands restricted to server administrators -- **Interaction Validation**: Every button press and command verified for proper authorization +- **Multi-Guild Support**: Bot operates across multiple designated Discord servers simultaneously +- **Per-Guild Server Tracking**: Each guild tracks servers independently with isolated embeds +- **Role-based Permissions**: "Game Server User" role required for all server interactions (per guild) +- **Administrator Commands**: Special management commands restricted to server administrators (per guild) +- **Interaction Validation**: Every button press and command verified for proper authorization within its guild ### 💾 Data Persistence - **Embed Location Storage**: Automatically remembers where embeds are posted (survives bot restarts) @@ -438,11 +439,13 @@ Before installing the bot software, you need to create a Discord application and When using Docker or generating the config automatically, these environment variables are required: - `DISCORD_TOKEN`: Your Discord bot token (from Discord Developer Portal) -- `ALLOWED_GUILD_ID`: The Discord server ID where the bot should operate +- `ALLOWED_GUILD_IDS`: Comma-separated list of Discord server IDs where the bot should operate (e.g., `123456789,987654321`) - `PANEL_URL`: Your Pterodactyl panel URL (must include http:// or https://) - `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`) - `APPLICATION_API_KEY`: Pterodactyl application API key (starts with `ptla_`) +> **Note**: The legacy `ALLOWED_GUILD_ID` (singular) environment variable is still supported for backward compatibility. + ### Configuration File The bot uses a `config.ini` file with this structure: @@ -455,7 +458,7 @@ ApplicationAPIKey = ptla_yourapplicationkey [Discord] Token = your.discord.bot.token -AllowedGuildID = 123456789012345678 +AllowedGuildIDs = 123456789012345678,987654321098765432 ``` To generate the config file from environment variables: @@ -479,6 +482,8 @@ python generate_config.py ### Bot Commands +All commands are guild-scoped - they only affect the guild (server) where they are invoked. + #### `/server_status` Display an interactive dropdown menu containing all available servers from your Pterodactyl panel. Select a server to create a permanent status embed in the current channel with real-time monitoring and control buttons. @@ -486,10 +491,10 @@ Display an interactive dropdown menu containing all available servers from your - Shows total server count and online/offline statistics - Lists all servers with descriptions - Creates persistent, auto-updating embeds -- Automatically removes old embeds when recreating for the same server +- Automatically removes old embeds when recreating for the same server **in this guild** #### `/refresh_embeds` (Admin only) -Force a complete refresh of all server status embeds. This command: +Force a complete refresh of all server status embeds **in the current guild**. This command: - Recreates all tracked embeds with fresh data - Deletes old embed messages to prevent duplication - Updates embed tracking storage @@ -501,9 +506,9 @@ Force a complete refresh of all server status embeds. This command: - Following Discord API issues #### `/purge_embeds` (Admin only) -Permanently delete all server status embeds from the Discord server. This command: +Permanently delete all server status embeds from **the current Discord server**. This command: - Removes all tracked embed messages -- Clears the embed tracking file +- Clears the embed tracking file for this guild - Provides real-time progress updates - Shows detailed statistics (deleted, missing, errors) @@ -554,24 +559,26 @@ Embed locations are stored in `embed/embed_locations.json` and persist between b ## Permissions -The bot implements two levels of access control: +The bot implements multi-guild access control with per-guild isolation: -### 1. Guild Restriction -The bot only responds to commands and interactions from the Discord server specified in `AllowedGuildID`. This prevents unauthorized use if the bot is accidentally added to other servers. +### 1. Guild Allowlist +The bot only responds to commands and interactions from Discord servers whose IDs are listed in `AllowedGuildIDs`. This prevents unauthorized use if the bot is accidentally added to other servers. -### 2. Role-Based Access -Users must have the **"Game Server User"** role (exact name match) to: +### 2. Per-Guild Role-Based Access +Users must have the **"Game Server User"** role (exact name match) **in the specific guild** where they are interacting to: - Use server control buttons (Start/Stop/Restart) - View server addresses - Interact with any bot features -### 3. Administrator Access -Server administrators (users with Discord's Administrator permission) can additionally: -- Use `/refresh_embeds` command -- Use `/purge_embeds` command -- No special role required - based on Discord permissions +Each guild maintains its own "Game Server User" role independently. -**Note:** The `/server_status` command can be used by anyone, but only users with the "Game Server User" role can interact with the resulting embeds. +### 3. Per-Guild Administrator Access +Server administrators (users with Discord's Administrator permission) **in a specific guild** can additionally: +- Use `/refresh_embeds` command (affects only that guild) +- Use `/purge_embeds` command (affects only that guild) +- No special role required - based on Discord permissions in that guild + +**Note:** The `/server_status` command can be used by anyone in an allowed guild, but only users with the "Game Server User" role in that guild can interact with the resulting embeds. ## Logging System @@ -719,4 +726,4 @@ This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) --- -**Note**: This bot is designed for single Pterodactyl domain and single Discord guild use. For multi-instance support, significant modifications would be required. \ No newline at end of file +**Note**: This bot supports multiple Pterodactyl domains and multiple Discord guilds simultaneously. Each guild tracks servers independently while sharing the same Pterodactyl infrastructure. \ No newline at end of file diff --git a/bot.py b/bot.py index 95324ed..d4f44b8 100644 --- a/bot.py +++ b/bot.py @@ -3,6 +3,7 @@ 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 @@ -10,8 +11,9 @@ import logging import signal import sys import types +from collections import defaultdict from datetime import datetime -from typing import TYPE_CHECKING, Optional, Tuple +from typing import TYPE_CHECKING, Dict, List, Optional, Tuple import discord from discord.ext import commands, tasks @@ -43,14 +45,15 @@ 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. + Manages server status embeds and user commands across multiple guilds. Features: - - Background status update task with smart update detection - - Persistent embed tracking with JSON storage + - 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 - - Single-guild restriction + - Role-based access control per guild + - Multi-guild support with independent server tracking + - Per-server action locking to prevent race conditions - Graceful shutdown handling """ @@ -73,23 +76,121 @@ class PterodactylBot(commands.Bot): self.metrics_manager = ServerMetricsManager() self.embed_manager = EmbedManager(config, self.api, self.metrics_manager) - # Server cache from panel - self.server_cache: dict = {} + # Server cache from panel (shared across guilds, read-mostly) + self.server_cache: Dict[str, dict] = {} + self._server_cache_lock = asyncio.Lock() - # Concurrency control + # Concurrency control for background update task self.update_lock = asyncio.Lock() - # State tracking for smart updates - # Format: {server_id: (state, cpu_usage, last_force_update)} - self.previous_states: dict = {} + # 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) - logger.info("Initialized PterodactylBot instance with state tracking") + # 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. @@ -112,184 +213,241 @@ class PterodactylBot(commands.Bot): @tasks.loop(seconds=10) # UPDATE_INTERVAL = 10 async def update_status(self) -> None: """ - Background task to update server status embeds when: + 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 + 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 and updates while maintaining + This minimizes API calls to Discord while maintaining real-time awareness of important server changes. """ - logger.info("Starting optimized server status update cycle") + logger.info("Starting multi-guild server status update cycle") async with self.update_lock: try: - # Fetch current server list from panel + # 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 - self.server_cache = {s["attributes"]["identifier"]: s for s in servers} + 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) - # Update statistics - update_count = 0 - error_count = 0 - missing_count = 0 - skipped_count = 0 current_time = datetime.now().timestamp() - # Process each server we're tracking embeds for - for server_id, location in list(self.embed_manager.embed_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") - continue + # Process each guild's embeds + total_updated = 0 + total_skipped = 0 + total_missing = 0 + total_errors = 0 - 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})") - - # 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 - 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 - 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}: " - f"{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}: " - f"{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") - 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 - ): # 10 minutes = 600 seconds - logger.debug(f"Executing 10-minute force update for running server {server_name}") - needs_update = True - last_force_update = current_time - - # PERFORM UPDATE IF NEEDED - if needs_update: - # 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( - 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 - 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 - ) - self.previous_states[server_id] = ( - current_state, - cpu_usage, - new_last_force_update, - ) - else: - # No significant changes, but update tracking with current state - self.previous_states[server_id] = ( - current_state, - cpu_usage, - last_force_update, - ) - skipped_count += 1 - logger.debug(f"No changes detected for {server_name}, skipping update") - - except discord.NotFound: - # Embed message was deleted - clean up tracking - logger.warning(f"Embed for server {server_id} not found, removing from tracking") - await self.embed_manager.untrack_embed(server_id) - missing_count += 1 - except Exception as 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 - await asyncio.sleep(0.5) + 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"Update cycle complete: " - f"{update_count} updated, " - f"{skipped_count} skipped, " - f"{missing_count} missing, " - f"{error_count} errors" + 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.""" + """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() - await self.embed_manager.refresh_all_embeds() + # 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: @@ -309,7 +467,7 @@ class PterodactylBot(commands.Bot): async def check_allowed_guild(interaction: discord.Interaction, config: Config) -> bool: """ - Verify that an interaction is coming from the allowed guild. + Verify that an interaction is coming from an allowed guild. Args: interaction: Discord interaction object @@ -318,13 +476,13 @@ async def check_allowed_guild(interaction: discord.Interaction, config: Config) Returns: bool: True if interaction is allowed, False otherwise """ - if interaction.guild_id != config.allowed_guild_id: + 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 only available in a specific server.", ephemeral=True + "This bot is not available in this server.", ephemeral=True ) return False return True @@ -346,10 +504,10 @@ async def setup_commands(bot: PterodactylBot) -> None: @bot.event async def on_interaction(interaction: discord.Interaction) -> None: """Global interaction handler to check guild before processing any interaction.""" - if interaction.guild_id != config.allowed_guild_id: + 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 only available in a specific server.", ephemeral=True + "This bot is not available in this server.", ephemeral=True ) return @@ -359,14 +517,15 @@ async def setup_commands(bot: PterodactylBot) -> None: logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})") try: - # Sync commands only to the allowed guild - guild = discord.Object(id=bot.config.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 {bot.config.allowed_guild_id}: " - f"{[cmd.name for cmd in synced]}" - ) + # 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)}") @@ -389,12 +548,13 @@ async def setup_commands(bot: PterodactylBot) -> None: 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 + 8. Manages embed tracking and cleanup of previous embeds (per guild) """ if not await check_allowed_guild(interaction, config): return - logger.info(f"Server status command invoked by {interaction.user.name}") + 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: @@ -407,7 +567,8 @@ async def setup_commands(bot: PterodactylBot) -> None: ) return - bot.server_cache = {s["attributes"]["identifier"]: s for s in servers} + 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 @@ -472,7 +633,7 @@ async def setup_commands(bot: PterodactylBot) -> None: view=ServerDropdownView(server_options), ephemeral=True, ) - logger.info(f"Sent server status dashboard to {interaction.user.name} with {len(server_options)} servers") + 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)}") @@ -481,47 +642,48 @@ async def setup_commands(bot: PterodactylBot) -> None: ) @bot.tree.command( - name="refresh_embeds", description="Refresh all server status embeds (admin only)" + 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.""" + """Slash command to refresh all server embeds in the current guild.""" if not await check_allowed_guild(interaction, config): return - logger.info(f"Refresh embeds command invoked by {interaction.user.name}") + 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}") + 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("Starting full embed refresh per admin request") - deleted, created = await bot.embed_manager.refresh_all_embeds() + 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. Deleted {deleted} old embeds, created {created} new ones.", + f"Refreshed all embeds in this guild. Deleted {deleted} old embeds, created {created} new ones.", ephemeral=True, ) - logger.info(f"Embed refresh completed: {deleted} deleted, {created} created") + logger.info(f"Embed refresh completed for guild {guild_id}: {deleted} deleted, {created} created") except Exception as e: - logger.error(f"Embed refresh failed: {str(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 (admin only)", + 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 Discord channels. + 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 + 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 @@ -531,35 +693,38 @@ async def setup_commands(bot: PterodactylBot) -> None: if not await check_allowed_guild(interaction, config): return - logger.info(f"Purge embeds command invoked by {interaction.user.name}") + 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}") + 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("Starting embed purge per admin request") + 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 - total_embeds = len(bot.embed_manager.embed_locations) - - if total_embeds == 0: - await interaction.followup.send( - "No embeds are currently being tracked. Nothing to purge.", ephemeral=True - ) - return # Create progress embed progress_embed = discord.Embed( title="🔄 Purging Server Embeds", - description=f"Processing {total_embeds} embeds...", + description=f"Processing {total_embeds} embeds in this guild...", color=discord.Color.orange(), ) progress_embed.add_field(name="Deleted", value="0", inline=True) @@ -569,31 +734,36 @@ async def setup_commands(bot: PterodactylBot) -> None: progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True) - # Process each tracked embed - for server_id, location in list(bot.embed_manager.embed_locations.items()): + # 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: - 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}") - except discord.NotFound: + # 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 - 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}") - except Exception as e: - error_count += 1 - logger.error(f"Error deleting embed for server {server_id}: {str(e)}") + 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}") + logger.warning(f"Channel not found for server {server_id} in guild {guild_id}") # Remove from tracking immediately - await bot.embed_manager.untrack_embed(server_id) + 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 @@ -610,7 +780,7 @@ async def setup_commands(bot: PterodactylBot) -> None: 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} in guild {guild_id}: {str(e)}") # Create results embed result_embed = discord.Embed( @@ -627,13 +797,13 @@ async def setup_commands(bot: PterodactylBot) -> None: value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%", inline=True, ) - result_embed.set_footer(text="Embed tracking file has been cleared") + 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: {deleted_count} deleted, {not_found_count} not found, {error_count} errors") + 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: {str(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) diff --git a/config.ini.sample b/config.ini.sample index 16de4f9..9f54c90 100644 --- a/config.ini.sample +++ b/config.ini.sample @@ -5,4 +5,4 @@ ApplicationAPIKey = ptla_yourpterodactylapplicationapikey [Discord] Token = yourdiscordbottoken -AllowedGuildID = yourdiscordguildid \ No newline at end of file +AllowedGuildIDs = yourdiscordguildid1,yourdiscordguildid2 \ No newline at end of file diff --git a/config.py b/config.py index 5df907c..29633c4 100644 --- a/config.py +++ b/config.py @@ -9,7 +9,7 @@ import configparser import logging import os from pathlib import Path -from typing import Optional +from typing import List, Optional from exceptions import ConfigValidationError @@ -30,7 +30,8 @@ class Config: client_api_key: Client API key for server operations application_api_key: Application API key for admin operations discord_token: Discord bot token - allowed_guild_id: Discord server ID where bot operates + allowed_guild_ids: List of Discord server IDs where bot operates + allowed_guild_id: First guild ID (backward compatibility) required_role: Role name required for server control permissions update_interval: Background task update interval in seconds embed_storage_path: Path to embed locations JSON file @@ -60,7 +61,9 @@ class Config: self.client_api_key: str = self._config.get("Pterodactyl", "ClientAPIKey") self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey") self.discord_token: str = self._config.get("Discord", "Token") - self.allowed_guild_id: int = self._config.getint("Discord", "AllowedGuildID") + self.allowed_guild_ids: List[int] = self._parse_allowed_guild_ids() + # Backward compatibility: first guild ID + self.allowed_guild_id: int = self.allowed_guild_ids[0] if self.allowed_guild_ids else 0 self.required_role: str = "Game Server User" self.update_interval: int = 10 self.embed_storage_path: Path = Path("./embed/embed_locations.json") @@ -96,18 +99,29 @@ class Config: if not self._config.has_section("Discord"): errors.append("Missing [Discord] section in config.ini") else: - required_discord = ["Token", "AllowedGuildID"] + required_discord = ["Token"] # Guild ID validated separately below for key in required_discord: if not self._config.get("Discord", key, fallback=None): errors.append(f"Missing required Discord config value: {key}") - # Validate AllowedGuildID is a valid integer - try: - guild_id = self._config.getint("Discord", "AllowedGuildID", fallback=0) - if guild_id <= 0: - errors.append("AllowedGuildID must be a positive integer") - except ValueError: - errors.append("AllowedGuildID must be a valid integer") + # Validate AllowedGuildIDs (comma-separated list) + allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="") + # Backward compatibility: also check for singular AllowedGuildID + if not allowed_guild_ids_str: + allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="") + + if not allowed_guild_ids_str: + errors.append("Missing required Discord config value: AllowedGuildIDs (or AllowedGuildID)") + else: + try: + guild_ids = [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()] + if not guild_ids: + errors.append("AllowedGuildIDs must contain at least one guild ID") + for gid in guild_ids: + if gid <= 0: + errors.append(f"Invalid guild ID: {gid} (must be positive integer)") + except ValueError: + errors.append("AllowedGuildIDs must be comma-separated integers") # Validate API keys have correct prefixes (Pterodactyl and Pelican Panel) client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="") @@ -138,6 +152,24 @@ class Config: or self.application_api_key.startswith("papp_") ) + def _parse_allowed_guild_ids(self) -> List[int]: + """Parse comma-separated guild IDs from config. + + Supports both new 'AllowedGuildIDs' and legacy 'AllowedGuildID' keys. + + Returns: + List of guild IDs as integers + """ + allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="") + if not allowed_guild_ids_str: + # Backward compatibility + allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="") + + if not allowed_guild_ids_str: + return [] + + return [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()] + @property def panel_type(self) -> str: """Return the detected panel type.""" @@ -182,7 +214,7 @@ def generate_config_from_env(output_path: str = "config.ini") -> None: Useful for Docker/container deployments where config comes from env vars. Required environment variables: - DISCORD_TOKEN, ALLOWED_GUILD_ID, PANEL_URL, + DISCORD_TOKEN, ALLOWED_GUILD_IDS (or ALLOWED_GUILD_ID), PANEL_URL, CLIENT_API_KEY, APPLICATION_API_KEY Args: @@ -196,9 +228,14 @@ def generate_config_from_env(output_path: str = "config.ini") -> None: "ApplicationAPIKey": os.environ.get("APPLICATION_API_KEY", ""), } + # Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID + allowed_guild_ids = os.environ.get("ALLOWED_GUILD_IDS", "") + if not allowed_guild_ids: + allowed_guild_ids = os.environ.get("ALLOWED_GUILD_ID", "") + config["Discord"] = { "Token": os.environ.get("DISCORD_TOKEN", ""), - "AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""), + "AllowedGuildIDs": allowed_guild_ids, } with open(output_path, "w") as f: diff --git a/embeds.py b/embeds.py index 2b8285b..4e9ba46 100644 --- a/embeds.py +++ b/embeds.py @@ -3,10 +3,12 @@ Embed Management Module Handles creation, tracking, and persistence of Discord server status embeds. Manages embed locations storage and provides utilities for embed lifecycle. +Supports multi-guild deployments where each guild tracks servers independently. """ import json import logging +from collections import defaultdict from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Optional, Tuple @@ -24,14 +26,15 @@ logger = logging.getLogger("pterodisbot") class EmbedManager: """ - Manages server status embeds lifecycle. + Manages server status embeds lifecycle across multiple guilds. Handles: - Embed creation with real-time server data - - Persistent storage of embed locations (channel_id, message_id) - - Embed refresh and recreation + - Persistent storage of embed locations per guild (guild_id -> server_id -> location) + - Embed refresh and recreation per guild - Cleanup of deleted/missing embeds - Rate limit protection during bulk operations + - Migration from legacy single-guild storage format Attributes: config: Application configuration @@ -58,15 +61,16 @@ class EmbedManager: self.api = api self.metrics_manager = metrics_manager self.storage_path = config.embed_storage_path - self.embed_locations: Dict[str, Dict[str, int]] = {} - self.previous_states: Dict[str, Tuple[str, float, Optional[float]]] = {} + # Structure: {guild_id: {server_id: {"channel_id": str, "message_id": str}}} + self.embed_locations: Dict[int, Dict[str, Dict[str, str]]] = defaultdict(dict) + # Previous states tracking moved to bot.py for multi-guild support # Ensure storage directory exists self.storage_path.parent.mkdir(parents=True, exist_ok=True) - logger.info("Initialized EmbedManager") + logger.info("Initialized EmbedManager with multi-guild support") async def load_locations(self) -> None: - """Load saved embed locations from JSON storage file.""" + """Load saved embed locations from JSON storage file with migration support.""" logger.debug("Loading embed locations from storage") if not self.storage_path.exists(): logger.info("No existing embed locations file found") @@ -74,8 +78,47 @@ class EmbedManager: try: with open(self.storage_path, "r") as f: - self.embed_locations = json.load(f) - logger.info(f"Loaded {len(self.embed_locations)} embed locations from storage") + data = json.load(f) + + # Detect format: + # New format: {guild_id: {server_id: location}} where values are dicts with channel_id/message_id + # Old format: {server_id: location} where location has channel_id/message_id directly + is_new_format = False + if data: + first_key = next(iter(data.keys())) + first_value = data[first_key] + # New format: value is a dict of server locations + # Old format: value is a location dict with channel_id/message_id + if isinstance(first_value, dict) and "channel_id" not in first_value: + # Value is {server_id: {channel_id, message_id}} - new format + is_new_format = True + elif isinstance(first_value, dict) and "channel_id" in first_value: + # Value is {channel_id, message_id} - old format + is_new_format = False + # If first_key is numeric and >= 17 digits, also treat as new format + elif first_key.isdigit() and len(first_key) >= 17: + is_new_format = True + + if is_new_format: + # New format: {guild_id: {server_id: location}} + self.embed_locations = defaultdict(dict) + for guild_id_str, servers in data.items(): + guild_id = int(guild_id_str) + self.embed_locations[guild_id] = servers + total_embeds = sum(len(s) for s in self.embed_locations.values()) + logger.info(f"Loaded embed locations for {len(self.embed_locations)} guilds ({total_embeds} total embeds)") + else: + # Legacy format: {server_id: location} - migrate to first allowed guild + legacy_guild_id = self.config.allowed_guild_ids[0] if self.config.allowed_guild_ids else 0 + if legacy_guild_id: + self.embed_locations = defaultdict(dict) + self.embed_locations[legacy_guild_id] = data + logger.info(f"Migrated legacy embed locations to guild {legacy_guild_id}") + # Save in new format immediately + await self.save_locations() + else: + logger.warning("No allowed guilds configured, cannot migrate legacy embed locations") + except Exception as e: logger.error(f"Failed to load embed locations: {str(e)}") @@ -83,40 +126,72 @@ class EmbedManager: """Save current embed locations to JSON storage file.""" logger.debug("Saving embed locations to storage") try: + # Convert defaultdict to regular dict for JSON serialization + # Guild IDs as strings for JSON compatibility + data = {str(gid): servers for gid, servers in self.embed_locations.items()} with open(self.storage_path, "w") as f: - json.dump(self.embed_locations, f, indent=2) + json.dump(data, f, indent=2) logger.debug("Successfully saved embed locations") except Exception as e: logger.error(f"Failed to save embed locations: {str(e)}") - async def track_embed(self, server_id: str, message: discord.Message) -> None: + def get_guild_locations(self, guild_id: int) -> Dict[str, Dict[str, str]]: """ - Track a newly created embed in storage. + Get all tracked embeds for a specific guild. Args: + guild_id: Discord guild ID + + Returns: + Dictionary mapping server_id to location info + """ + return self.embed_locations.get(guild_id, {}) + + def get_all_tracked_servers(self) -> List[Tuple[int, str]]: + """ + Get all (guild_id, server_id) pairs currently being tracked. + + Returns: + List of (guild_id, server_id) tuples + """ + result = [] + for guild_id, servers in self.embed_locations.items(): + for server_id in servers: + result.append((guild_id, server_id)) + return result + + async def track_embed(self, guild_id: int, server_id: str, message: discord.Message) -> None: + """ + Track a newly created embed in storage for a specific guild. + + Args: + guild_id: Discord guild ID server_id: The server's panel identifier message: Discord message containing the embed """ - logger.debug(f"Tracking new embed for server {server_id} in channel {message.channel.id}") - self.embed_locations[server_id] = { + logger.debug(f"Tracking new embed for server {server_id} in guild {guild_id}, channel {message.channel.id}") + self.embed_locations[guild_id][server_id] = { "channel_id": str(message.channel.id), "message_id": str(message.id), } await self.save_locations() - async def untrack_embed(self, server_id: str) -> None: + async def untrack_embed(self, guild_id: int, server_id: str) -> None: """ - Remove a server's embed from tracking. + Remove a server's embed from tracking for a specific guild. Args: + guild_id: Discord guild ID server_id: The server's panel identifier """ - self.embed_locations.pop(server_id, None) - self.previous_states.pop(server_id, None) + self.embed_locations[guild_id].pop(server_id, None) + # Clean up empty guild entries + if not self.embed_locations[guild_id]: + del self.embed_locations[guild_id] await self.save_locations() async def create_server_embed( - self, server_data: ServerData, resources: ServerResources + self, server_data: ServerData, resources: ServerResources, guild_id: int ) -> Tuple[discord.Embed, discord.ui.View]: """ Create a status embed and interactive view for a server. @@ -124,6 +199,7 @@ class EmbedManager: Args: server_data: Server information from panel resources: Current resource usage data + guild_id: Discord guild ID this embed belongs to Returns: Tuple of (embed, view) ready for display @@ -133,7 +209,7 @@ class EmbedManager: name = attributes.name description = attributes.description or "No description available" - logger.debug(f"Building status embed for server {name} ({identifier})") + logger.debug(f"Building status embed for server {name} ({identifier}) in guild {guild_id}") # Parse resource data current_state = resources.attributes.current_state.title() @@ -165,7 +241,7 @@ class EmbedManager: embed.set_footer(text="Last updated") - # Create interactive view with control buttons + # Create interactive view with control buttons (pass guild_id for validation) from views import ServerStatusView view = ServerStatusView( @@ -173,9 +249,10 @@ class EmbedManager: server_name=name, api=self.api, server_data=server_data.model_dump(), + guild_id=guild_id, ) - logger.debug(f"Successfully built status components for {name}") + logger.debug(f"Successfully built status components for {name} in guild {guild_id}") return embed, view def _add_resource_fields( @@ -198,10 +275,10 @@ class EmbedManager: disk_limit = limits.disk # Format limit values - display ∞ for unlimited (0 limit) - def format_limit(value: int, unit: str = "") -> str: + def format_limit(value: int) -> str: if value == 0: - return f"{'∞':>8} {unit}" - return f"{value:>8} {unit}" + return f"{'∞':>8}" + return f"{value:>8}" # Format uptime uptime_ms = resource_attrs.uptime @@ -227,9 +304,9 @@ class EmbedManager: # Resource usage text box usage_text = ( f"```properties\n" - f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit, '%')}\n" - f"Memory : [{memory_usage:>8} / {format_limit(memory_limit, 'MiB')}\n" - f"Disk : [{disk_usage:>8} / {format_limit(disk_limit, 'MiB')}\n" + f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit)}] %\n" + f"Memory : [{memory_usage:>8} / {format_limit(memory_limit)}] MiB\n" + f"Disk : [{disk_usage:>8} / {format_limit(disk_limit)}] MiB\n" f"```" ) @@ -261,16 +338,19 @@ class EmbedManager: return embed - async def refresh_all_embeds(self) -> Tuple[int, int]: + async def refresh_all_embeds(self, guild_id: int) -> Tuple[int, int]: """ - Perform a complete refresh of all server status embeds. + Perform a complete refresh of all server status embeds for a specific guild. Creates new embeds and deletes old ones to prevent duplication. + Args: + guild_id: Discord guild ID to refresh embeds for + Returns: Tuple of (deleted_count, created_count) """ - logger.info("Starting full refresh of all server embeds") + logger.info(f"Starting full refresh of all server embeds for guild {guild_id}") from bot import PterodactylBot # Avoid circular import bot = PterodactylBot.get_instance() @@ -278,6 +358,11 @@ class EmbedManager: created_count = 0 skipped_count = 0 + guild_locations = self.get_guild_locations(guild_id) + if not guild_locations: + logger.info(f"No embeds tracked for guild {guild_id}, nothing to refresh") + return 0, 0 + try: # Get current server list servers = await self.api.get_servers() @@ -291,14 +376,19 @@ class EmbedManager: new_embeds = {} for server_id, server_data in server_cache.items(): - if server_id not in self.embed_locations: + if server_id not in guild_locations: skipped_count += 1 continue - channel_id = self.embed_locations[server_id]["channel_id"] + channel_id = guild_locations[server_id]["channel_id"] channel = bot.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} in guild {guild_id}") + continue + + # Verify channel belongs to the correct guild + if channel.guild.id != guild_id: + logger.warning(f"Channel {channel_id} not in guild {guild_id}, skipping") continue try: @@ -306,7 +396,7 @@ class EmbedManager: server_obj = ServerData.model_validate(server_data) resources_obj = ServerResources.model_validate(resources) - embed, view = await self.create_server_embed(server_obj, resources_obj) + embed, view = await self.create_server_embed(server_obj, resources_obj, guild_id) message = await channel.send(embed=embed, view=view) new_embeds[server_id] = { @@ -314,48 +404,53 @@ class EmbedManager: "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']} in guild {guild_id}") 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} in guild {guild_id}: {str(e)}") if not new_embeds: - logger.warning("No new embeds created during refresh") + logger.warning(f"No new embeds created during refresh for guild {guild_id}") return 0, 0 # Delete old embeds - for server_id, location in list(self.embed_locations.items()): + 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 delete") + continue try: 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} in guild {guild_id}") await asyncio.sleep(0.5) except discord.NotFound: - logger.debug(f"Old embed for server {server_id} already deleted") + logger.debug(f"Old embed for server {server_id} in guild {guild_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} in guild {guild_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} in guild {guild_id}: {str(e)}") - logger.info(f"Deleted {deleted_count} old embeds, created {created_count} new embeds") + logger.info(f"Guild {guild_id}: Deleted {deleted_count} old embeds, created {created_count} new embeds") - # Update storage - self.embed_locations = new_embeds + # Update storage for this guild only + self.embed_locations[guild_id] = new_embeds await self.save_locations() return deleted_count, created_count except Exception as e: - logger.error(f"Critical error during embed refresh: {str(e)}") + logger.error(f"Critical error during embed refresh for guild {guild_id}: {str(e)}") raise async def update_single_embed( self, + guild_id: int, server_id: str, location: Dict[str, str], server_data: ServerData, @@ -364,9 +459,10 @@ class EmbedManager: cpu_usage: float, ) -> bool: """ - Update a single server's status embed. + Update a single server's status embed for a specific guild. Args: + guild_id: Discord guild ID server_id: Server identifier location: Embed location dict with channel_id and message_id server_data: Current server data @@ -381,11 +477,16 @@ class EmbedManager: bot = PterodactylBot.get_instance() try: - embed, view = await self.create_server_embed(server_data, resources) + embed, view = await self.create_server_embed(server_data, resources, guild_id) channel = bot.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} in guild {guild_id}") + return False + + # 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}") return False message = await channel.fetch_message(int(location["message_id"])) @@ -406,17 +507,15 @@ class EmbedManager: # Empty files list removes existing attachments await message.edit(embed=embed, view=view, attachments=files) - logger.debug(f"Updated status for {server_data.attributes.name}") + logger.debug(f"Updated status for {server_data.attributes.name} in guild {guild_id}") return True except discord.NotFound: - 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) - await self.save_locations() + logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking") + await self.untrack_embed(guild_id, server_id) return False 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} in guild {guild_id}: {str(e)}") return False diff --git a/generate_config.py b/generate_config.py index b6fbba7..9afa2ce 100644 --- a/generate_config.py +++ b/generate_config.py @@ -4,27 +4,47 @@ from pathlib import Path from dotenv import load_dotenv +REQUIRED_VARS = [ + 'DISCORD_TOKEN', + 'ALLOWED_GUILD_ID', # Legacy + 'ALLOWED_GUILD_IDS', # New + 'PANEL_URL', + 'CLIENT_API_KEY', + 'APPLICATION_API_KEY' +] + + def validate_env_vars(): """Validate that required environment variables are set and non-empty.""" - required_vars = [ - 'DISCORD_TOKEN', - 'ALLOWED_GUILD_ID', - 'PANEL_URL', - 'CLIENT_API_KEY', - 'APPLICATION_API_KEY' - ] + # Check for either legacy or new guild ID variable + has_guild_id = bool(os.getenv('ALLOWED_GUILD_ID')) + has_guild_ids = bool(os.getenv('ALLOWED_GUILD_IDS')) - missing_vars = [var for var in required_vars if not os.getenv(var)] + if not has_guild_id and not has_guild_ids: + raise ValueError( + "Missing required environment variable: ALLOWED_GUILD_IDS (or legacy ALLOWED_GUILD_ID)" + ) + + # Check other required vars + missing_vars = [ + var for var in ['DISCORD_TOKEN', 'PANEL_URL', 'CLIENT_API_KEY', 'APPLICATION_API_KEY'] + if not os.getenv(var) + ] if missing_vars: raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}") def get_env_config(): """Extract configuration from environment variables.""" + # Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID + allowed_guild_ids = os.getenv('ALLOWED_GUILD_IDS', '') + if not allowed_guild_ids: + allowed_guild_ids = os.getenv('ALLOWED_GUILD_ID', '') + return { 'Discord': { 'Token': os.getenv('DISCORD_TOKEN'), - 'AllowedGuildID': os.getenv('ALLOWED_GUILD_ID') + 'AllowedGuildIDs': allowed_guild_ids }, 'Pterodactyl': { 'PanelURL': os.getenv('PANEL_URL'), @@ -43,8 +63,18 @@ def configs_match(existing_config, env_config): if env_value is None: # Environment variable not set, don't require match continue - existing_value = existing_config.get(section, key, fallback=None) - if existing_value != env_value: + # For AllowedGuildIDs, also check legacy AllowedGuildID key + check_keys = [key] + if key == 'AllowedGuildIDs': + check_keys.append('AllowedGuildID') + + matched = False + for check_key in check_keys: + existing_value = existing_config.get(section, check_key, fallback=None) + if existing_value == env_value: + matched = True + break + if not matched: return False return True diff --git a/models.py b/models.py index f16fd62..1f8723b 100644 --- a/models.py +++ b/models.py @@ -101,11 +101,17 @@ class ServerListResponse(BaseModel): class EmbedLocation(BaseModel): - """Embed location tracking data.""" + """Embed location tracking data with guild context.""" + guild_id: int channel_id: str message_id: str + # Composite key for storage + @property + def storage_key(self) -> str: + return f"{self.guild_id}:{self.channel_id}:{self.message_id}" + # Rebuild models to resolve forward references ResourceAttributes.model_rebuild() diff --git a/test_pterodisbot.py b/test_pterodisbot.py index 02d66fd..5143e94 100644 --- a/test_pterodisbot.py +++ b/test_pterodisbot.py @@ -14,6 +14,7 @@ import pytest import asyncio import json import os +from collections import defaultdict from unittest.mock import Mock, AsyncMock, patch, MagicMock from datetime import datetime import configparser @@ -52,7 +53,8 @@ def mock_config(): config.client_api_key = 'ptlc_test_client_key_123' config.application_api_key = 'ptla_test_app_key_456' config.discord_token = 'test_discord_token' - config.allowed_guild_id = 123456789 + config.allowed_guild_ids = [123456789, 987654321] + config.allowed_guild_id = 123456789 # Backward compat config.required_role = "Game Server User" config.update_interval = 10 config.embed_storage_path = Path("./embed/embed_locations.json") @@ -177,8 +179,8 @@ def mock_bot(mock_config, mock_panel_api): bot = PterodactylBot(mock_config, command_prefix="!", intents=intents) bot.api = mock_panel_api bot.server_cache = {} - bot.embed_manager.embed_locations = {} - bot.previous_states = {} + # Don't override embed_locations - let it use the defaultdict from EmbedManager + bot.previous_states = defaultdict(dict) return bot @@ -561,13 +563,15 @@ class TestServerStatusView: server_id='abc123', server_name='Test Server', api=mock_panel_api, - server_data=sample_server_data + server_data=sample_server_data, + guild_id=123456789 ) assert view.server_id == 'abc123' assert view.server_name == 'Test Server' assert view.api is mock_panel_api assert view.timeout is None # Persistent view + assert view.guild_id == 123456789 @pytest.mark.asyncio async def test_interaction_check_authorized(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction): @@ -576,7 +580,8 @@ class TestServerStatusView: server_id='abc123', server_name='Test Server', api=mock_panel_api, - server_data=sample_server_data + server_data=sample_server_data, + guild_id=123456789 ) result = await view.interaction_check(mock_discord_interaction) @@ -590,7 +595,8 @@ class TestServerStatusView: server_id='abc123', server_name='Test Server', api=mock_panel_api, - server_data=sample_server_data + server_data=sample_server_data, + guild_id=123456789 ) mock_discord_interaction.guild_id = 999999999 @@ -607,7 +613,8 @@ class TestServerStatusView: server_id='abc123', server_name='Test Server', api=mock_panel_api, - server_data=sample_server_data + server_data=sample_server_data, + guild_id=123456789 ) # Remove required role @@ -638,7 +645,8 @@ class TestPterodactylBot: assert bot.metrics_manager is not None assert bot.embed_manager is not None assert bot.server_cache == {} - assert bot.previous_states == {} + # previous_states is now a defaultdict + assert isinstance(bot.previous_states, dict) @pytest.mark.asyncio async def test_track_new_embed(self, mock_bot, mock_discord_interaction): @@ -647,25 +655,29 @@ class TestPterodactylBot: mock_message.channel.id = 987654321 mock_message.id = 123456789 - await mock_bot.embed_manager.track_embed('abc123', mock_message) + await mock_bot.embed_manager.track_embed(123456789, 'abc123', mock_message) - assert 'abc123' in mock_bot.embed_manager.embed_locations - assert mock_bot.embed_manager.embed_locations['abc123']['channel_id'] == '987654321' - assert mock_bot.embed_manager.embed_locations['abc123']['message_id'] == '123456789' + assert 123456789 in mock_bot.embed_manager.embed_locations + assert 'abc123' in mock_bot.embed_manager.embed_locations[123456789] + assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['channel_id'] == '987654321' + assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['message_id'] == '123456789' @pytest.mark.asyncio async def test_load_embed_locations(self, mock_bot, tmp_path): """Test loading embed locations from file.""" - # Create a temporary embed locations file + # Create a temporary embed locations file with new multi-guild format embed_file = tmp_path / "embed_locations.json" embed_file.write_text(json.dumps({ - "server1": {"channel_id": "123", "message_id": "456"} + "123456789": { + "server1": {"channel_id": "123", "message_id": "456"} + } })) mock_bot.embed_manager.storage_path = embed_file await mock_bot.embed_manager.load_locations() - assert "server1" in mock_bot.embed_manager.embed_locations + assert 123456789 in mock_bot.embed_manager.embed_locations + assert "server1" in mock_bot.embed_manager.embed_locations[123456789] @pytest.mark.asyncio async def test_save_embed_locations(self, mock_bot, tmp_path): @@ -674,13 +686,16 @@ class TestPterodactylBot: mock_bot.embed_manager.storage_path = embed_file mock_bot.embed_manager.embed_locations = { - "server1": {"channel_id": "123", "message_id": "456"} + 123456789: { + "server1": {"channel_id": "123", "message_id": "456"} + } } await mock_bot.embed_manager.save_locations() saved_data = json.loads(embed_file.read_text()) - assert "server1" in saved_data + assert "123456789" in saved_data + assert "server1" in saved_data["123456789"] # ========================================== @@ -715,7 +730,7 @@ class TestIntegration: server_obj = ServerData.model_validate(sample_server_data) resources_obj = ServerResources.model_validate(sample_resources_data) - embed, view = await mock_bot.embed_manager.create_server_embed(server_obj, resources_obj) + embed, view = await mock_bot.embed_manager.create_server_embed(server_obj, resources_obj, 123456789) assert embed is not None assert view is not None diff --git a/views.py b/views.py index 5faf1e8..cc3698c 100644 --- a/views.py +++ b/views.py @@ -3,6 +3,7 @@ Discord Views Module Contains all Discord UI views and interactive components. Handles server control buttons, dropdowns, and permission checks. +Supports multi-guild deployments with per-guild validation. """ import logging @@ -28,10 +29,11 @@ class ServerStatusView(discord.ui.View): Buttons: Start (green), Stop (red), Restart (blurple), Show Address (grey). Features: - - Guild restriction (only works in configured server) - - Role-based access control (requires "Game Server User" role) + - Guild restriction (only works in the guild the embed was created in) + - Role-based access control (requires "Game Server User" role in that guild) - Persistent views (timeout=None for infinite persistence) - Comprehensive error handling with user feedback + - Per-server action locking to prevent race conditions across guilds """ def __init__( @@ -40,6 +42,7 @@ class ServerStatusView(discord.ui.View): server_name: str, api: "PanelAPI", server_data: dict, + guild_id: int, ) -> None: """ Initialize the server status view with control buttons. @@ -49,17 +52,24 @@ class ServerStatusView(discord.ui.View): server_name: Human-readable server name api: Panel API client instance server_data: Full server data from panel + guild_id: Discord guild ID this view belongs to """ super().__init__(timeout=None) # Persistent view self.server_id = server_id self.server_name = server_name self.api = api self.server_data = server_data - logger.debug(f"Created ServerStatusView for {server_name} ({server_id})") + self.guild_id = guild_id + logger.debug(f"Created ServerStatusView for {server_name} ({server_id}) in guild {guild_id}") async def interaction_check(self, interaction: discord.Interaction) -> bool: """ - Verify the interacting user has the required role and is in the allowed guild. + Verify the interacting user has the required role and is in the correct guild. + + This check ensures: + 1. The interaction is from the same guild the embed was created in + 2. The guild is in the allowed guilds list + 3. The user has the required role in that guild Args: interaction: Discord interaction object @@ -69,28 +79,40 @@ class ServerStatusView(discord.ui.View): """ config = Config.get_instance() - # First check if interaction is from the allowed guild - if interaction.guild_id != config.allowed_guild_id: - logger.warning(f"Unauthorized interaction attempt from guild {interaction.guild_id}") + # First check if interaction is from the SAME guild as this view + if interaction.guild_id != self.guild_id: + logger.warning( + f"Interaction from wrong guild: expected {self.guild_id}, got {interaction.guild_id} " + f"for server {self.server_name} by {interaction.user.name}" + ) await interaction.response.send_message( - "This bot is only available in a specific server.", ephemeral=True + "This control panel belongs to a different server.", ephemeral=True ) return False - # Then check for required role - logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name}") + # Then check if the guild is in the allowed list + if interaction.guild_id not in config.allowed_guild_ids: + logger.warning(f"Unauthorized interaction attempt from guild {interaction.guild_id}") + await interaction.response.send_message( + "This bot is not available in this server.", ephemeral=True + ) + return False + + # Then check for required role in THIS guild + logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name} in guild {interaction.guild_id}") has_role = any(role.name == config.required_role for role in interaction.user.roles) if not has_role: logger.warning( - f"Permission denied for {interaction.user.name} - missing '{config.required_role}' role" + f"Permission denied for {interaction.user.name} in guild {interaction.guild_id} - " + f"missing '{config.required_role}' role" ) await interaction.response.send_message( - f"You don't have permission to control servers. You need the '{config.required_role}' role.", + f"You don't have permission to control servers. You need the '{config.required_role}' role in this server.", ephemeral=True, ) return False - logger.debug(f"Permission granted for {interaction.user.name}") + logger.debug(f"Permission granted for {interaction.user.name} in guild {interaction.guild_id}") return True async def on_error( @@ -104,7 +126,7 @@ 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)}") + logger.error(f"View error in {self.server_name} (guild {self.guild_id}) by {interaction.user.name}: {str(error)}") if not interaction.response.is_done(): await interaction.response.send_message( "An error occurred while processing your request.", ephemeral=True @@ -120,19 +142,8 @@ class ServerStatusView(discord.ui.View): async def start_button( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - """Send a start command to the server.""" - 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") - - if result.get("status") == "success": - 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')}" - logger.error(f"Failed to start server {self.server_name}: {message}") - - await interaction.followup.send(message, ephemeral=True) + """Send a start command to the server with cross-guild embed update.""" + await self._handle_power_action(interaction, "start") @discord.ui.button( label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button" @@ -140,19 +151,8 @@ class ServerStatusView(discord.ui.View): async def stop_button( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - """Send a stop command to the server.""" - 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") - - if result.get("status") == "success": - message = f"Server '{self.server_name}' is stopping..." - logger.info(f"Successfully stopped server {self.server_name}") - else: - message = f"Failed to stop server: {result.get('message', 'Unknown error')}" - logger.error(f"Failed to stop server {self.server_name}: {message}") - - await interaction.followup.send(message, ephemeral=True) + """Send a stop command to the server with cross-guild embed update.""" + await self._handle_power_action(interaction, "stop") @discord.ui.button( label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button" @@ -160,19 +160,42 @@ class ServerStatusView(discord.ui.View): async def restart_button( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: - """Send a restart command to the server.""" - 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") + """Send a restart command to the server with cross-guild embed update.""" + await self._handle_power_action(interaction, "restart") - if result.get("status") == "success": - 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')}" - logger.error(f"Failed to restart server {self.server_name}: {message}") + async def _handle_power_action(self, interaction: discord.Interaction, action: str) -> None: + """ + Handle a power action (start/stop/restart) with proper locking and cross-guild updates. - await interaction.followup.send(message, ephemeral=True) + Uses a per-server lock to serialize concurrent power actions from multiple guilds, + then triggers an immediate embed update across all guilds tracking this server. + + Args: + interaction: Discord interaction object + action: Power action to perform (start, stop, restart) + """ + from bot import PterodactylBot + bot = PterodactylBot.get_instance() + + logger.info(f"{action.capitalize()} button pressed for {self.server_name} by {interaction.user.name} in guild {interaction.guild_id}") + + # Acquire per-server lock to serialize concurrent actions across guilds + async with bot.get_server_action_lock(self.server_id): + await interaction.response.defer(ephemeral=True) + result = await self.api.send_power_action(self.server_id, action) + + if result.get("status") == "success": + message = f"Server '{self.server_name}' is {action}ing..." + logger.info(f"Successfully executed {action} on server {self.server_name}") + else: + message = f"Failed to {action} server: {result.get('message', 'Unknown error')}" + logger.error(f"Failed to {action} server {self.server_name}: {message}") + + await interaction.followup.send(message, ephemeral=True) + + # Trigger immediate embed update for ALL guilds tracking this server + # This ensures all guilds see the state change promptly + await bot.trigger_embed_update_for_server(self.server_id) @discord.ui.button( label="Show Address", @@ -183,7 +206,7 @@ class ServerStatusView(discord.ui.View): self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: """Show server's default allocation IP and port.""" - 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} in guild {interaction.guild_id}") try: await interaction.response.defer(ephemeral=True) logger.debug(f"Fetching server details for {self.server_id}") @@ -231,7 +254,8 @@ class ServerDropdown(discord.ui.Select): """ Handle server selection from dropdown menu. - Creates permanent status embed in the channel for the selected server. + Creates permanent status embed in the channel for the selected server + in the CURRENT guild (where the interaction occurred). """ from bot import PterodactylBot @@ -248,27 +272,33 @@ class ServerDropdown(discord.ui.Select): return server_name = server_data["attributes"]["name"] - logger.info(f"User {interaction.user.name} selected server: {server_name}") + guild_id = interaction.guild_id + logger.info(f"User {interaction.user.name} selected server: {server_name} in guild {guild_id}") try: # Get current server status for embed creation resources = await bot.api.get_server_resources(selected_server_id) - # Delete old embed if it exists to prevent duplication - if selected_server_id in bot.embed_manager.embed_locations: - logger.debug(f"Found existing embed for {selected_server_id}, attempting to delete") + # Delete old embed if it exists in THIS guild to prevent duplication + guild_locations = bot.embed_manager.get_guild_locations(guild_id) + if selected_server_id in guild_locations: + logger.debug(f"Found existing embed for {selected_server_id} in guild {guild_id}, attempting to delete") try: - old_location = bot.embed_manager.embed_locations[selected_server_id] + old_location = guild_locations[selected_server_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"]) - ) - await old_message.delete() - logger.debug(f"Deleted old embed for {selected_server_id}") - except discord.NotFound: - logger.debug(f"Old embed for {selected_server_id} already deleted") + # Verify channel belongs to the correct guild + if old_channel.guild.id != guild_id: + logger.warning(f"Old embed channel not in guild {guild_id}, skipping delete") + else: + try: + 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} in guild {guild_id}") + except discord.NotFound: + logger.debug(f"Old embed for {selected_server_id} in guild {guild_id} already deleted") except Exception as e: logger.error(f"Failed to delete old embed: {str(e)}") @@ -278,18 +308,18 @@ class ServerDropdown(discord.ui.Select): server_obj = ServerData.model_validate(server_data) resources_obj = ServerResources.model_validate(resources) - embed, view = await bot.embed_manager.create_server_embed(server_obj, resources_obj) + embed, view = await bot.embed_manager.create_server_embed(server_obj, resources_obj, guild_id) message = await interaction.channel.send(embed=embed, view=view) - await bot.embed_manager.track_embed(selected_server_id, message) + await bot.embed_manager.track_embed(guild_id, selected_server_id, message) await interaction.followup.send( f"✅ **{server_name}** status has been posted in {interaction.channel.mention}", ephemeral=True, ) - logger.info(f"Successfully posted status for {server_name}") + logger.info(f"Successfully posted status for {server_name} in guild {guild_id}") 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} in guild {guild_id}: {str(e)}") await interaction.followup.send( f"❌ Failed to create status embed for **{server_name}**: {str(e)}", ephemeral=True ) -- 2.54.0 From 9a3122a2596f4d7608f8c65618a04337d89053ec Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Fri, 30 Oct 2026 10:37:26 +0000 Subject: [PATCH 2/4] chore: Remove stray file --- 8} | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 8} diff --git a/8} b/8} deleted file mode 100644 index e69de29..0000000 -- 2.54.0 From 204cc6704cb5f36f20fb8d5b8d409e11fdf4a460 Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Fri, 30 Oct 2026 11:06:14 +0000 Subject: [PATCH 3/4] Chore: correct errors in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 480b1d6..296558a 100644 --- a/README.md +++ b/README.md @@ -716,7 +716,7 @@ tail -n 100 logs/pterodisbot.log If issues persist after trying these solutions: 1. Enable DEBUG logging by reviewing recent log files -2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot/issues) for similar problems +2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodisbot/issues) for similar problems 3. Verify your setup matches all requirements in [Prerequisites](#prerequisites) 4. Test each component individually (Discord bot, Pterodactyl API, permissions) @@ -726,4 +726,4 @@ This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE) --- -**Note**: This bot supports multiple Pterodactyl domains and multiple Discord guilds simultaneously. Each guild tracks servers independently while sharing the same Pterodactyl infrastructure. \ No newline at end of file +**Note**: This bot supports a singular Pterodactyl or Pelican domain with multiple Discord guilds. Each guild tracks servers independently while sharing the same Pterodactyl/Pelican infrastructure. Users from multiple Discord guilds can view and control the same servers running on a given Pterodaactyl/Pelican domain simultaneously. \ No newline at end of file -- 2.54.0 From f3f7bb9c4a923bea6f9092453549f3f12f7f4d30 Mon Sep 17 00:00:00 2001 From: Eaven Kimura Date: Tue, 3 Nov 2026 09:16:36 +0000 Subject: [PATCH 4/4] Format resource usage denominators left-aligned in embeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changed format_limit() in _add_resource_fields() to use left-alignment (<8) instead of right-alignment (>8) - Denominators (limits/∞) now align left relative to the '/' separator - Produces cleaner visual formatting in resource usage section --- embeds.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/embeds.py b/embeds.py index 4e9ba46..0ce14f9 100644 --- a/embeds.py +++ b/embeds.py @@ -277,8 +277,8 @@ class EmbedManager: # Format limit values - display ∞ for unlimited (0 limit) def format_limit(value: int) -> str: if value == 0: - return f"{'∞':>8}" - return f"{value:>8}" + return f"{'∞':<8}" + return f"{value:<8}" # Format uptime uptime_ms = resource_attrs.uptime -- 2.54.0