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:
1 parent
e68b248033
commit
bec06bd909
10 files changed
+802
-408
No files matched your search
@@ -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
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user