""" 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 from typing import TYPE_CHECKING import discord from discord.ext import commands from config import Config from exceptions import PermissionError if TYPE_CHECKING: from api import PanelAPI logger = logging.getLogger("pterodisbot") class ServerStatusView(discord.ui.View): """ Interactive view containing server control buttons. Provides persistent controls for server management with role-based access. Buttons: Start (green), Stop (red), Restart (blurple), Show Address (grey). Features: - 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__( self, server_id: str, server_name: str, api: "PanelAPI", server_data: dict, guild_id: int, ) -> None: """ Initialize the server status view with control buttons. Args: server_id: The server's panel identifier 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 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 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 Returns: bool: True if authorized, False otherwise """ config = Config.get_instance() # 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 control panel belongs to a different server.", ephemeral=True ) return False # 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} 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 in this server.", ephemeral=True, ) return False logger.debug(f"Permission granted for {interaction.user.name} in guild {interaction.guild_id}") return True async def on_error( self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item ) -> None: """ Handle errors in button interactions. Args: interaction: Discord interaction object error: Exception that occurred item: The UI item that triggered the 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 ) else: await interaction.followup.send( "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 ) -> None: """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" ) async def stop_button( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: """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" ) async def restart_button( self, interaction: discord.Interaction, button: discord.ui.Button ) -> None: """Send a restart command to the server with cross-guild embed update.""" await self._handle_power_action(interaction, "restart") 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. 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", style=discord.ButtonStyle.grey, custom_id="show_address_button", ) async def show_address_button( 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} in guild {interaction.guild_id}") try: await interaction.response.defer(ephemeral=True) logger.debug(f"Fetching server details for {self.server_id}") ip_alias, port = await self.api.get_server_default_allocation(self.server_id) # Create and send embed embed = discord.Embed( title=f"{self.server_name} Connection Info", color=discord.Color.blue(), description=f"Server ID: `{self.server_id}`", ) embed.add_field(name="Address", value=f"`{ip_alias}`", inline=True) embed.add_field(name="Port", value=f"`{port}`", inline=True) await interaction.followup.send(embed=embed, ephemeral=True) logger.info(f"Displayed connection info for {self.server_name}") except Exception as e: logger.error(f"Failed to show address for {self.server_name}: {str(e)}") await interaction.followup.send( "⚠️ Failed to get connection info. The server may not have any ports allocated.", ephemeral=True, ) class ServerDropdown(discord.ui.Select): """Dropdown menu for server selection in status dashboard.""" def __init__(self, server_options: list) -> None: """ Initialize the server dropdown. Args: server_options: List of discord.SelectOption objects """ super().__init__( placeholder="Select a server to display...", options=server_options, min_values=1, max_values=1, ) async def callback(self, interaction: discord.Interaction) -> None: """ Handle server selection from dropdown menu. Creates permanent status embed in the channel for the selected server in the CURRENT guild (where the interaction occurred). """ from bot import PterodactylBot await interaction.response.defer(ephemeral=True) bot = PterodactylBot.get_instance() selected_server_id = self.values[0] server_data = bot.server_cache.get(selected_server_id) if not server_data: await interaction.followup.send( "❌ Selected server no longer available. Please try again.", ephemeral=True ) return server_name = server_data["attributes"]["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 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 = guild_locations[selected_server_id] old_channel = bot.get_channel(int(old_location["channel_id"])) if old_channel: # 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)}") # Create and send new permanent status embed in channel from models import ServerData, ServerResources 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, guild_id) message = await interaction.channel.send(embed=embed, view=view) 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} in guild {guild_id}") except Exception as 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 ) class ServerDropdownView(discord.ui.View): """View containing the server dropdown with timeout for auto-cleanup.""" def __init__(self, server_options: list, timeout: int = 180) -> None: """ Initialize dropdown view with timeout. Args: server_options: List of discord.SelectOption objects timeout: Timeout in seconds (default 3 minutes for ephemeral cleanup) """ super().__init__(timeout=timeout) self.server_options = server_options self.add_item(ServerDropdown(server_options)) async def on_timeout(self) -> None: """Clean up when dropdown times out (ephemeral auto-removal).""" logger.debug("Server dropdown timed out and was automatically cleaned up")