Files
Pterodisbot/views.py
T
k.eaven bec06bd909 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
2026-10-30 10:37:13 +00:00

345 lines
14 KiB
Python

"""
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")