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
This commit is contained in:
k.eaven committed 2026-10-30 10:37:13 +00:00
1 parent e68b248033
commit bec06bd909
10 files changed
+802 -408

No files matched your search

+100 -70
View File
@@ -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
)