- 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
345 lines
14 KiB
Python
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") |