8 Commits
Author SHA1 Message Date
k.eaven da01f64e5c Version increment
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 36s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 35s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 35s
CI/CD Pipeline / Security Scanning (push) Successful in 31s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Integration Tests (push) Successful in 18s
CI/CD Pipeline / Generate Test Report (push) Successful in 11s
CI/CD Pipeline / Build Docker Image (push) Successful in 36s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
2026-11-03 10:38:23 +00:00
k.eaven 3743d19d9d Merge pull request 'Merge development into main: Multi-guild support + fixes' (#5) from development into main
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 1m1s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 40s
CI/CD Pipeline / Integration Tests (push) Successful in 20s
CI/CD Pipeline / Security Scanning (push) Successful in 28s
CI/CD Pipeline / Build Docker Image (push) Successful in 37s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 43s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 1m0s
Reviewed-on: k.eaven/pterodisbot#5
2026-11-03 10:25:34 +00:00
k.eaven f3f7bb9c4a Format resource usage denominators left-aligned in embeds
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 2m48s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 2m46s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 2m46s
CI/CD Pipeline / Security Scanning (push) Successful in 32s
CI/CD Pipeline / Integration Tests (push) Successful in 24s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 24s
CI/CD Pipeline / Build Docker Image (push) Successful in 12m21s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 30s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 32s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 26s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 23s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 30s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 18s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 3s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
- Changed format_limit() in _add_resource_fields() to use left-alignment (<8) instead of right-alignment (>8)
- Denominators (limits/∞) now align left relative to the '/' separator
- Produces cleaner visual formatting in resource usage section
2026-11-03 09:16:36 +00:00
k.eaven 204cc6704c Chore: correct errors in README
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Failing after 7s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 32s
CI/CD Pipeline / Security Scanning (push) Successful in 27s
CI/CD Pipeline / Build Docker Image (push) Skipped
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 34s
CI/CD Pipeline / Integration Tests (push) Skipped
CI/CD Pipeline / Code Quality & Linting (push) Successful in 28s
CI/CD Pipeline / Generate Test Report (push) Successful in 3s
2026-10-30 11:06:14 +00:00
k.eaven 9a3122a259 chore: Remove stray file
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 39s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 35s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 39s
CI/CD Pipeline / Security Scanning (push) Successful in 29s
CI/CD Pipeline / Integration Tests (push) Successful in 23s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 21s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Build Docker Image (push) Successful in 33s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
2026-10-30 10:37:26 +00:00
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
k.eaven e68b248033 Merge pull request 'Remove redundant original code (pterodisbot.py)' (#4) from development into main
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 30s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 39s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 45s
CI/CD Pipeline / Security Scanning (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 30s
CI/CD Pipeline / Integration Tests (push) Successful in 21s
CI/CD Pipeline / Generate Test Report (push) Successful in 7s
CI/CD Pipeline / Build Docker Image (push) Successful in 44s
2026-10-26 11:43:40 +00:00
k.eaven 25cf7fed34 Remove redundant original code
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 33s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 35s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 30s
CI/CD Pipeline / Security Scanning (push) Successful in 29s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 21s
CI/CD Pipeline / Integration Tests (push) Successful in 25s
CI/CD Pipeline / Generate Test Report (push) Successful in 5s
CI/CD Pipeline / Build Docker Image (push) Successful in 55s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 1s
CI/CD Pipeline / Unit Tests (Python 3.13) (pull_request) Successful in 1m17s
CI/CD Pipeline / Unit Tests (Python 3.12) (pull_request) Successful in 1m17s
CI/CD Pipeline / Unit Tests (Python 3.11) (pull_request) Successful in 1m17s
CI/CD Pipeline / Code Quality & Linting (pull_request) Successful in 29s
CI/CD Pipeline / Integration Tests (pull_request) Successful in 32s
CI/CD Pipeline / Security Scanning (pull_request) Successful in 38s
CI/CD Pipeline / Build Docker Image (pull_request) Skipped
CI/CD Pipeline / Generate Test Report (pull_request) Successful in 4s
CI/CD Pipeline / CI/CD Pipeline Status (pull_request) Successful in 2s
2026-10-26 11:32:51 +00:00
11 changed files with 706 additions and 1897 deletions

No files matched your search

+30 -23
View File
@@ -61,10 +61,11 @@ A feature-rich Discord bot for monitoring and managing Pterodactyl game servers
- **Background Task Management**: Asynchronous update loop runs every 10 seconds
### 🔒 Security & Access Control
- **Single-Guild Restriction**: Bot operates exclusively in your designated Discord server
- **Role-based Permissions**: "Game Server User" role required for all server interactions
- **Administrator Commands**: Special management commands restricted to server administrators
- **Interaction Validation**: Every button press and command verified for proper authorization
- **Multi-Guild Support**: Bot operates across multiple designated Discord servers simultaneously
- **Per-Guild Server Tracking**: Each guild tracks servers independently with isolated embeds
- **Role-based Permissions**: "Game Server User" role required for all server interactions (per guild)
- **Administrator Commands**: Special management commands restricted to server administrators (per guild)
- **Interaction Validation**: Every button press and command verified for proper authorization within its guild
### 💾 Data Persistence
- **Embed Location Storage**: Automatically remembers where embeds are posted (survives bot restarts)
@@ -438,11 +439,13 @@ Before installing the bot software, you need to create a Discord application and
When using Docker or generating the config automatically, these environment variables are required:
- `DISCORD_TOKEN`: Your Discord bot token (from Discord Developer Portal)
- `ALLOWED_GUILD_ID`: The Discord server ID where the bot should operate
- `ALLOWED_GUILD_IDS`: Comma-separated list of Discord server IDs where the bot should operate (e.g., `123456789,987654321`)
- `PANEL_URL`: Your Pterodactyl panel URL (must include http:// or https://)
- `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`)
- `APPLICATION_API_KEY`: Pterodactyl application API key (starts with `ptla_`)
> **Note**: The legacy `ALLOWED_GUILD_ID` (singular) environment variable is still supported for backward compatibility.
### Configuration File
The bot uses a `config.ini` file with this structure:
@@ -455,7 +458,7 @@ ApplicationAPIKey = ptla_yourapplicationkey
[Discord]
Token = your.discord.bot.token
AllowedGuildID = 123456789012345678
AllowedGuildIDs = 123456789012345678,987654321098765432
```
To generate the config file from environment variables:
@@ -479,6 +482,8 @@ python generate_config.py
### Bot Commands
All commands are guild-scoped - they only affect the guild (server) where they are invoked.
#### `/server_status`
Display an interactive dropdown menu containing all available servers from your Pterodactyl panel. Select a server to create a permanent status embed in the current channel with real-time monitoring and control buttons.
@@ -486,10 +491,10 @@ Display an interactive dropdown menu containing all available servers from your
- Shows total server count and online/offline statistics
- Lists all servers with descriptions
- Creates persistent, auto-updating embeds
- Automatically removes old embeds when recreating for the same server
- Automatically removes old embeds when recreating for the same server **in this guild**
#### `/refresh_embeds` (Admin only)
Force a complete refresh of all server status embeds. This command:
Force a complete refresh of all server status embeds **in the current guild**. This command:
- Recreates all tracked embeds with fresh data
- Deletes old embed messages to prevent duplication
- Updates embed tracking storage
@@ -501,9 +506,9 @@ Force a complete refresh of all server status embeds. This command:
- Following Discord API issues
#### `/purge_embeds` (Admin only)
Permanently delete all server status embeds from the Discord server. This command:
Permanently delete all server status embeds from **the current Discord server**. This command:
- Removes all tracked embed messages
- Clears the embed tracking file
- Clears the embed tracking file for this guild
- Provides real-time progress updates
- Shows detailed statistics (deleted, missing, errors)
@@ -554,24 +559,26 @@ Embed locations are stored in `embed/embed_locations.json` and persist between b
## Permissions
The bot implements two levels of access control:
The bot implements multi-guild access control with per-guild isolation:
### 1. Guild Restriction
The bot only responds to commands and interactions from the Discord server specified in `AllowedGuildID`. This prevents unauthorized use if the bot is accidentally added to other servers.
### 1. Guild Allowlist
The bot only responds to commands and interactions from Discord servers whose IDs are listed in `AllowedGuildIDs`. This prevents unauthorized use if the bot is accidentally added to other servers.
### 2. Role-Based Access
Users must have the **"Game Server User"** role (exact name match) to:
### 2. Per-Guild Role-Based Access
Users must have the **"Game Server User"** role (exact name match) **in the specific guild** where they are interacting to:
- Use server control buttons (Start/Stop/Restart)
- View server addresses
- Interact with any bot features
### 3. Administrator Access
Server administrators (users with Discord's Administrator permission) can additionally:
- Use `/refresh_embeds` command
- Use `/purge_embeds` command
- No special role required - based on Discord permissions
Each guild maintains its own "Game Server User" role independently.
**Note:** The `/server_status` command can be used by anyone, but only users with the "Game Server User" role can interact with the resulting embeds.
### 3. Per-Guild Administrator Access
Server administrators (users with Discord's Administrator permission) **in a specific guild** can additionally:
- Use `/refresh_embeds` command (affects only that guild)
- Use `/purge_embeds` command (affects only that guild)
- No special role required - based on Discord permissions in that guild
**Note:** The `/server_status` command can be used by anyone in an allowed guild, but only users with the "Game Server User" role in that guild can interact with the resulting embeds.
## Logging System
@@ -709,7 +716,7 @@ tail -n 100 logs/pterodisbot.log
If issues persist after trying these solutions:
1. Enable DEBUG logging by reviewing recent log files
2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodactyl-discord-bot/issues) for similar problems
2. Check the [Git Issues](https://git.serendipity.systems/k.eaven/pterodisbot/issues) for similar problems
3. Verify your setup matches all requirements in [Prerequisites](#prerequisites)
4. Test each component individually (Discord bot, Pterodactyl API, permissions)
@@ -719,4 +726,4 @@ This project is licensed under the GPL-3.0 License - see the [LICENSE](LICENSE)
---
**Note**: This bot is designed for single Pterodactyl domain and single Discord guild use. For multi-instance support, significant modifications would be required.
**Note**: This bot supports a singular Pterodactyl or Pelican domain with multiple Discord guilds. Each guild tracks servers independently while sharing the same Pterodactyl/Pelican infrastructure. Users from multiple Discord guilds can view and control the same servers running on a given Pterodaactyl/Pelican domain simultaneously.
+1 -1
View File
@@ -16,7 +16,7 @@ Modules:
- exceptions: Custom exception classes
"""
__version__ = "1.2.0"
__version__ = "1.3.0"
__author__ = "Eaven Kimura"
from config import Config, load_config, generate_config_from_env
+304 -134
View File
@@ -3,6 +3,7 @@ Main Bot Module
Core bot class and command definitions.
Manages the bot lifecycle, background tasks, and Discord interactions.
Supports multi-guild deployments with independent server tracking per guild.
"""
import asyncio
@@ -10,8 +11,9 @@ import logging
import signal
import sys
import types
from collections import defaultdict
from datetime import datetime
from typing import TYPE_CHECKING, Optional, Tuple
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
import discord
from discord.ext import commands, tasks
@@ -43,14 +45,15 @@ class PterodactylBot(commands.Bot):
Main bot class for Pterodactyl/Pelican Panel server management.
Handles Discord interactions, embed management, and background tasks.
Manages server status embeds and user commands.
Manages server status embeds and user commands across multiple guilds.
Features:
- Background status update task with smart update detection
- Persistent embed tracking with JSON storage
- Background status update task with smart update detection per guild
- Persistent embed tracking with JSON storage per guild
- Real-time server monitoring with metrics graphs
- Role-based access control
- Single-guild restriction
- Role-based access control per guild
- Multi-guild support with independent server tracking
- Per-server action locking to prevent race conditions
- Graceful shutdown handling
"""
@@ -73,23 +76,121 @@ class PterodactylBot(commands.Bot):
self.metrics_manager = ServerMetricsManager()
self.embed_manager = EmbedManager(config, self.api, self.metrics_manager)
# Server cache from panel
self.server_cache: dict = {}
# Server cache from panel (shared across guilds, read-mostly)
self.server_cache: Dict[str, dict] = {}
self._server_cache_lock = asyncio.Lock()
# Concurrency control
# Concurrency control for background update task
self.update_lock = asyncio.Lock()
# State tracking for smart updates
# Format: {server_id: (state, cpu_usage, last_force_update)}
self.previous_states: dict = {}
# Per-server action locks to serialize concurrent power actions across guilds
# Key: server_id, Value: asyncio.Lock
self._server_action_locks: Dict[str, asyncio.Lock] = defaultdict(asyncio.Lock)
logger.info("Initialized PterodactylBot instance with state tracking")
# State tracking for smart updates per guild
# Format: {guild_id: {server_id: (state, cpu_usage, last_force_update)}}
self.previous_states: Dict[int, Dict[str, Tuple[str, float, Optional[float]]]] = defaultdict(dict)
logger.info(f"Initialized PterodactylBot instance with multi-guild support for {len(config.allowed_guild_ids)} guild(s)")
@classmethod
def get_instance(cls) -> Optional["PterodactylBot"]:
"""Get the singleton bot instance."""
return _bot_instance
def get_server_action_lock(self, server_id: str) -> asyncio.Lock:
"""
Get or create a per-server lock for serializing power actions.
This prevents race conditions when users from multiple guilds
press buttons for the same server simultaneously.
Args:
server_id: Panel server identifier
Returns:
asyncio.Lock for the specified server
"""
return self._server_action_locks[server_id]
async def trigger_embed_update_for_server(self, server_id: str) -> None:
"""
Trigger an immediate embed update for a server across all guilds tracking it.
Called after a successful power action to ensure all guilds see the state change promptly.
Bypasses smart update detection since we know the state changed.
Args:
server_id: Panel server identifier
"""
if server_id not in self.server_cache:
logger.warning(f"Cannot trigger update for unknown server {server_id}")
return
server_data = self.server_cache[server_id]
server_name = server_data["attributes"]["name"]
try:
# Fetch fresh resources
resources = await self.api.get_server_resources(server_id)
current_state = resources["attributes"]["current_state"]
cpu_usage = round(
resources["attributes"]["resources"].get("cpu_absolute", 0), 2
)
# Collect metrics for running servers
if current_state == "running":
memory_usage = round(
resources["attributes"]["resources"].get("memory_bytes", 0)
/ (1024**2),
2,
)
self.metrics_manager.add_server_data(
server_id, server_name, cpu_usage, memory_usage
)
# Convert to models for embed creation
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
current_time = datetime.now().timestamp()
# Update in all guilds that track this server
for guild_id in self.config.allowed_guild_ids:
guild_locations = self.embed_manager.get_guild_locations(guild_id)
if server_id not in guild_locations:
continue
location = guild_locations[server_id]
# Force update (bypass smart detection)
success = await self.embed_manager.update_single_embed(
guild_id=guild_id,
server_id=server_id,
location=location,
server_data=server_obj,
resources=resources_obj,
current_state=current_state,
cpu_usage=cpu_usage,
)
if success:
logger.debug(
f"Cross-guild embed update triggered for {server_name} ({server_id}) in guild {guild_id}: power action"
)
# Update state tracking for this guild
guild_states = self.previous_states[guild_id]
guild_states[server_id] = (current_state, cpu_usage, current_time)
logger.debug(f"Force-updated embed for {server_name} in guild {guild_id}")
else:
logger.warning(f"Failed to force-update embed for {server_name} in guild {guild_id}")
# Small delay between guilds to respect rate limits
await asyncio.sleep(0.3)
except Exception as e:
logger.error(f"Failed to trigger cross-guild update for server {server_id}: {str(e)}")
async def setup_hook(self) -> None:
"""
Bot setup routine called when the bot is starting.
@@ -112,25 +213,28 @@ class PterodactylBot(commands.Bot):
@tasks.loop(seconds=10) # UPDATE_INTERVAL = 10
async def update_status(self) -> None:
"""
Background task to update server status embeds when:
Background task to update server status embeds across all guilds.
For each guild, updates embeds when:
1. Server power state changes (started/stopped/restarted)
2. Significant CPU usage change (>50% difference)
3. First time seeing the server
3. First time seeing the server in that guild
4. Server has been running for 10 minutes (force update for uptime)
This minimizes API calls to Discord and updates while maintaining
This minimizes API calls to Discord while maintaining
real-time awareness of important server changes.
"""
logger.info("Starting optimized server status update cycle")
logger.info("Starting multi-guild server status update cycle")
async with self.update_lock:
try:
# Fetch current server list from panel
# Fetch current server list from panel (once per cycle, shared across guilds)
servers = await self.api.get_servers()
if not servers:
logger.warning("No servers found in panel during update")
return
# Update our local cache with fresh server data
async with self._server_cache_lock:
self.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Updated server cache with {len(servers)} servers")
@@ -138,25 +242,68 @@ class PterodactylBot(commands.Bot):
active_server_ids = list(self.server_cache.keys())
self.metrics_manager.cleanup_old_servers(active_server_ids)
# Update statistics
update_count = 0
error_count = 0
missing_count = 0
skipped_count = 0
current_time = datetime.now().timestamp()
# Process each server we're tracking embeds for
for server_id, location in list(self.embed_manager.embed_locations.items()):
# Process each guild's embeds
total_updated = 0
total_skipped = 0
total_missing = 0
total_errors = 0
for guild_id in self.config.allowed_guild_ids:
u, s, m, e = await self._update_guild_embeds(guild_id, current_time)
total_updated += u
total_skipped += s
total_missing += m
total_errors += e
# Log summary of this update cycle
logger.info(
f"Multi-guild update cycle complete: "
f"{total_updated} updated, "
f"{total_skipped} skipped, "
f"{total_missing} missing, "
f"{total_errors} errors across {len(self.config.allowed_guild_ids)} guild(s)"
)
except Exception as e:
logger.error(f"Error in update_status task: {str(e)}")
await asyncio.sleep(5)
async def _update_guild_embeds(
self, guild_id: int, current_time: float
) -> Tuple[int, int, int, int]:
"""
Update embeds for a specific guild.
Args:
guild_id: Discord guild ID
current_time: Current timestamp for force-update calculations
Returns:
Tuple of (updated, skipped, missing, errors) counts
"""
guild_locations = self.embed_manager.get_guild_locations(guild_id)
if not guild_locations:
return 0, 0, 0, 0
guild_prev_states = self.previous_states[guild_id]
update_count = 0
skipped_count = 0
missing_count = 0
error_count = 0
for server_id, location in list(guild_locations.items()):
# Skip if server no longer exists in panel
if server_id not in self.server_cache:
logger.warning(f"Server {server_id} not found in cache, skipping update")
logger.warning(f"Server {server_id} not found in cache, skipping update for guild {guild_id}")
continue
server_data = self.server_cache[server_id]
server_name = server_data["attributes"]["name"]
try:
logger.debug(f"Checking status for server {server_name} ({server_id})")
logger.debug(f"Checking status for server {server_name} ({server_id}) in guild {guild_id}")
# Get current server resource usage
resources = await self.api.get_server_resources(server_id)
@@ -165,7 +312,7 @@ class PterodactylBot(commands.Bot):
resources["attributes"]["resources"].get("cpu_absolute", 0), 2
)
# Collect metrics data for running servers
# Collect metrics data for running servers (shared across guilds)
if current_state == "running":
memory_usage = round(
resources["attributes"]["resources"].get("memory_bytes", 0)
@@ -180,52 +327,29 @@ class PterodactylBot(commands.Bot):
f"CPU={cpu_usage}%, Memory={memory_usage}MB"
)
# Retrieve previous recorded state
prev_state, prev_cpu, last_force_update = self.previous_states.get(
# Retrieve previous recorded state for THIS guild
prev_state, prev_cpu, last_force_update = guild_prev_states.get(
server_id, (None, 0, None)
)
# DECISION LOGIC: Should we update the embed?
needs_update = False
# 1. Check if power state changed (most important)
if current_state != prev_state:
logger.debug(
f"Power state changed for {server_name}: "
f"{prev_state} -> {current_state}"
needs_update, trigger_reason = self._should_update_embed(
current_state, prev_state, cpu_usage, prev_cpu,
last_force_update, current_time
)
needs_update = True
# 2. Check for significant CPU change (only if server is running)
elif current_state == "running" and abs(cpu_usage - prev_cpu) > 50:
logger.debug(
f"Significant CPU change for {server_name}: "
f"{prev_cpu}% -> {cpu_usage}%"
)
needs_update = True
# 3. First time we're seeing this server (initial update)
elif prev_state is None:
logger.debug(f"First check for {server_name}, performing initial update")
needs_update = True
# 4. Force update every 10 minutes for running servers (for uptime counter)
elif current_state == "running" and (
last_force_update is None
or current_time - last_force_update >= 600
): # 10 minutes = 600 seconds
logger.debug(f"Executing 10-minute force update for running server {server_name}")
needs_update = True
last_force_update = current_time
# PERFORM UPDATE IF NEEDED
if needs_update:
logger.debug(
f"Embed update triggered for {server_name} ({server_id}) in guild {guild_id}: {trigger_reason}"
)
# Convert to models for embed creation
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
# Update the embed
success = await self.embed_manager.update_single_embed(
guild_id=guild_id,
server_id=server_id,
location=location,
server_data=server_obj,
@@ -236,60 +360,94 @@ class PterodactylBot(commands.Bot):
if success:
update_count += 1
# Update state tracking
new_last_force_update = (
last_force_update
if needs_update
and current_state == "running"
and current_time - (last_force_update or 0) >= 600
else last_force_update
)
self.previous_states[server_id] = (
# Update state tracking for this guild
new_last_force_update = current_time if (
current_state == "running" and
(last_force_update is None or current_time - last_force_update >= 600)
) else last_force_update
guild_prev_states[server_id] = (
current_state,
cpu_usage,
new_last_force_update,
)
else:
# No significant changes, but update tracking with current state
self.previous_states[server_id] = (
guild_prev_states[server_id] = (
current_state,
cpu_usage,
last_force_update,
)
skipped_count += 1
logger.debug(f"No changes detected for {server_name}, skipping update")
logger.debug(f"No changes detected for {server_name} in guild {guild_id}, skipping update")
except discord.NotFound:
# Embed message was deleted - clean up tracking
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
await self.embed_manager.untrack_embed(server_id)
# Embed message was deleted - clean up tracking for this guild
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
await self.embed_manager.untrack_embed(guild_id, server_id)
missing_count += 1
except Exception as e:
logger.error(f"Failed to update status for server {server_id}: {str(e)}")
logger.error(f"Failed to update status for server {server_id} in guild {guild_id}: {str(e)}")
error_count += 1
# Small delay between servers to avoid rate limits
await asyncio.sleep(0.5)
# Log summary of this update cycle
logger.info(
f"Update cycle complete: "
f"{update_count} updated, "
f"{skipped_count} skipped, "
f"{missing_count} missing, "
f"{error_count} errors"
)
return update_count, skipped_count, missing_count, error_count
except Exception as e:
logger.error(f"Error in update_status task: {str(e)}")
await asyncio.sleep(5)
def _should_update_embed(
self,
current_state: str,
prev_state: Optional[str],
cpu_usage: float,
prev_cpu: float,
last_force_update: Optional[float],
current_time: float,
) -> Tuple[bool, str]:
"""
Determine if an embed needs updating based on smart detection criteria.
This logic is shared between the background task and cross-guild triggers.
Args:
current_state: Current server power state
prev_state: Previously recorded state (or None)
cpu_usage: Current CPU usage percentage
prev_cpu: Previously recorded CPU usage
last_force_update: Timestamp of last forced update (or None)
current_time: Current timestamp
Returns:
Tuple of (should_update: bool, reason: str)
"""
# 1. Check if power state changed (most important)
if current_state != prev_state:
return True, f"power state changed: {prev_state} -> {current_state}"
# 2. Check for significant CPU change (only if server is running)
if current_state == "running" and abs(cpu_usage - prev_cpu) > 50:
return True, f"significant CPU change: {prev_cpu}% -> {cpu_usage}%"
# 3. First time we're seeing this server (initial update)
if prev_state is None:
return True, "first detection (initial update)"
# 4. Force update every 10 minutes for running servers (for uptime counter)
if current_state == "running" and (
last_force_update is None
or current_time - last_force_update >= 600
): # 10 minutes = 600 seconds
return True, f"10-minute force update (last: {last_force_update})"
return False, "no significant changes"
@update_status.before_loop
async def before_update_status(self) -> None:
"""Wait for bot to be ready before starting update task."""
"""Wait for bot to be ready before starting update task, then do initial refresh."""
logger.debug("Waiting for bot readiness before starting update task")
await self.wait_until_ready()
await self.embed_manager.refresh_all_embeds()
# Initial refresh for all guilds
for guild_id in self.config.allowed_guild_ids:
await self.embed_manager.refresh_all_embeds(guild_id)
@update_status.after_loop
async def after_update_status(self) -> None:
@@ -309,7 +467,7 @@ class PterodactylBot(commands.Bot):
async def check_allowed_guild(interaction: discord.Interaction, config: Config) -> bool:
"""
Verify that an interaction is coming from the allowed guild.
Verify that an interaction is coming from an allowed guild.
Args:
interaction: Discord interaction object
@@ -318,13 +476,13 @@ async def check_allowed_guild(interaction: discord.Interaction, config: Config)
Returns:
bool: True if interaction is allowed, False otherwise
"""
if interaction.guild_id != config.allowed_guild_id:
if interaction.guild_id not in config.allowed_guild_ids:
logger.warning(
f"Command attempted from unauthorized guild {interaction.guild_id} "
f"by {interaction.user.name}"
)
await interaction.response.send_message(
"This bot is only available in a specific server.", ephemeral=True
"This bot is not available in this server.", ephemeral=True
)
return False
return True
@@ -346,10 +504,10 @@ async def setup_commands(bot: PterodactylBot) -> None:
@bot.event
async def on_interaction(interaction: discord.Interaction) -> None:
"""Global interaction handler to check guild before processing any interaction."""
if interaction.guild_id != config.allowed_guild_id:
if interaction.guild_id not in config.allowed_guild_ids:
logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}")
await interaction.response.send_message(
"This bot is only available in a specific server.", ephemeral=True
"This bot is not available in this server.", ephemeral=True
)
return
@@ -359,12 +517,13 @@ async def setup_commands(bot: PterodactylBot) -> None:
logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})")
try:
# Sync commands only to the allowed guild
guild = discord.Object(id=bot.config.allowed_guild_id)
# Sync commands to ALL allowed guilds
for guild_id in bot.config.allowed_guild_ids:
guild = discord.Object(id=guild_id)
bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild)
logger.info(
f"Successfully synced {len(synced)} command(s) to guild {bot.config.allowed_guild_id}: "
f"Successfully synced {len(synced)} command(s) to guild {guild_id}: "
f"{[cmd.name for cmd in synced]}"
)
except Exception as e:
@@ -389,12 +548,13 @@ async def setup_commands(bot: PterodactylBot) -> None:
5. Generates dropdown menu with all servers (name + description)
6. Handles user selection via ephemeral dropdown interaction
7. Creates permanent status embed in channel upon selection
8. Manages embed tracking and cleanup of previous embeds
8. Manages embed tracking and cleanup of previous embeds (per guild)
"""
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Server status command invoked by {interaction.user.name}")
guild_id = interaction.guild_id
logger.info(f"Server status command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
try:
@@ -407,6 +567,7 @@ async def setup_commands(bot: PterodactylBot) -> None:
)
return
async with bot._server_cache_lock:
bot.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Refreshed server cache with {len(servers)} servers")
@@ -472,7 +633,7 @@ async def setup_commands(bot: PterodactylBot) -> None:
view=ServerDropdownView(server_options),
ephemeral=True,
)
logger.info(f"Sent server status dashboard to {interaction.user.name} with {len(server_options)} servers")
logger.info(f"Sent server status dashboard to {interaction.user.name} in guild {guild_id} with {len(server_options)} servers")
except Exception as e:
logger.error(f"Server status command failed: {str(e)}")
@@ -481,47 +642,48 @@ async def setup_commands(bot: PterodactylBot) -> None:
)
@bot.tree.command(
name="refresh_embeds", description="Refresh all server status embeds (admin only)"
name="refresh_embeds", description="Refresh all server status embeds in this guild (admin only)"
)
async def refresh_embeds(interaction: discord.Interaction) -> None:
"""Slash command to refresh all server embeds."""
"""Slash command to refresh all server embeds in the current guild."""
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Refresh embeds command invoked by {interaction.user.name}")
guild_id = interaction.guild_id
logger.info(f"Refresh embeds command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized refresh attempt by {interaction.user.name}")
logger.warning(f"Unauthorized refresh attempt by {interaction.user.name} in guild {guild_id}")
await interaction.followup.send(
"You need administrator permissions to refresh all embeds.", ephemeral=True
)
return
try:
logger.info("Starting full embed refresh per admin request")
deleted, created = await bot.embed_manager.refresh_all_embeds()
logger.info(f"Starting full embed refresh per admin request for guild {guild_id}")
deleted, created = await bot.embed_manager.refresh_all_embeds(guild_id)
await interaction.followup.send(
f"Refreshed all embeds. Deleted {deleted} old embeds, created {created} new ones.",
f"Refreshed all embeds in this guild. Deleted {deleted} old embeds, created {created} new ones.",
ephemeral=True,
)
logger.info(f"Embed refresh completed: {deleted} deleted, {created} created")
logger.info(f"Embed refresh completed for guild {guild_id}: {deleted} deleted, {created} created")
except Exception as e:
logger.error(f"Embed refresh failed: {str(e)}")
logger.error(f"Embed refresh failed for guild {guild_id}: {str(e)}")
await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True)
@bot.tree.command(
name="purge_embeds",
description="Permanently delete all server status embeds (admin only)",
description="Permanently delete all server status embeds in this guild (admin only)",
)
async def purge_embeds(interaction: discord.Interaction) -> None:
"""
Slash command to permanently purge all server status embeds from Discord channels.
Slash command to permanently purge all server status embeds from the current guild.
Workflow:
1. Validates administrator permissions
2. Checks if any embeds are currently tracked
2. Checks if any embeds are currently tracked in this guild
3. Sends initial progress embed
4. Processes each embed sequentially with error handling
5. Updates progress embed in real-time
@@ -531,35 +693,38 @@ async def setup_commands(bot: PterodactylBot) -> None:
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Purge embeds command invoked by {interaction.user.name}")
guild_id = interaction.guild_id
logger.info(f"Purge embeds command invoked by {interaction.user.name} in guild {guild_id}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized purge attempt by {interaction.user.name}")
logger.warning(f"Unauthorized purge attempt by {interaction.user.name} in guild {guild_id}")
await interaction.followup.send(
"You need administrator permissions to purge all embeds.", ephemeral=True
)
return
try:
logger.info("Starting embed purge per admin request")
logger.info(f"Starting embed purge per admin request for guild {guild_id}")
guild_locations = bot.embed_manager.get_guild_locations(guild_id)
total_embeds = len(guild_locations)
if total_embeds == 0:
await interaction.followup.send(
"No embeds are currently being tracked in this guild. Nothing to purge.", ephemeral=True
)
return
deleted_count = 0
not_found_count = 0
error_count = 0
total_embeds = len(bot.embed_manager.embed_locations)
if total_embeds == 0:
await interaction.followup.send(
"No embeds are currently being tracked. Nothing to purge.", ephemeral=True
)
return
# Create progress embed
progress_embed = discord.Embed(
title="🔄 Purging Server Embeds",
description=f"Processing {total_embeds} embeds...",
description=f"Processing {total_embeds} embeds in this guild...",
color=discord.Color.orange(),
)
progress_embed.add_field(name="Deleted", value="0", inline=True)
@@ -569,31 +734,36 @@ async def setup_commands(bot: PterodactylBot) -> None:
progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True)
# Process each tracked embed
for server_id, location in list(bot.embed_manager.embed_locations.items()):
# Process each tracked embed in this guild
for server_id, location in list(guild_locations.items()):
try:
channel = bot.get_channel(int(location["channel_id"]))
if channel:
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}, skipping")
not_found_count += 1
else:
try:
message = await channel.fetch_message(int(location["message_id"]))
await message.delete()
deleted_count += 1
logger.debug(f"Successfully purged embed for server {server_id}")
logger.debug(f"Successfully purged embed for server {server_id} in guild {guild_id}")
except discord.NotFound:
not_found_count += 1
logger.debug(f"Embed for server {server_id} already deleted")
logger.debug(f"Embed for server {server_id} in guild {guild_id} already deleted")
except discord.Forbidden:
error_count += 1
logger.error(f"Permission denied when deleting embed for server {server_id}")
logger.error(f"Permission denied when deleting embed for server {server_id} in guild {guild_id}")
except Exception as e:
error_count += 1
logger.error(f"Error deleting embed for server {server_id}: {str(e)}")
logger.error(f"Error deleting embed for server {server_id} in guild {guild_id}: {str(e)}")
else:
not_found_count += 1
logger.warning(f"Channel not found for server {server_id}")
logger.warning(f"Channel not found for server {server_id} in guild {guild_id}")
# Remove from tracking immediately
await bot.embed_manager.untrack_embed(server_id)
await bot.embed_manager.untrack_embed(guild_id, server_id)
# Update progress every 5 embeds or for the last one
processed = deleted_count + not_found_count + error_count
@@ -610,7 +780,7 @@ async def setup_commands(bot: PterodactylBot) -> None:
except Exception as e:
error_count += 1
logger.error(f"Unexpected error processing server {server_id}: {str(e)}")
logger.error(f"Unexpected error processing server {server_id} in guild {guild_id}: {str(e)}")
# Create results embed
result_embed = discord.Embed(
@@ -627,13 +797,13 @@ async def setup_commands(bot: PterodactylBot) -> None:
value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%",
inline=True,
)
result_embed.set_footer(text="Embed tracking file has been cleared")
result_embed.set_footer(text="Embed tracking for this guild has been cleared")
await progress_message.edit(embed=result_embed)
logger.info(f"Embed purge completed: {deleted_count} deleted, {not_found_count} not found, {error_count} errors")
logger.info(f"Embed purge completed for guild {guild_id}: {deleted_count} deleted, {not_found_count} not found, {error_count} errors")
except Exception as e:
logger.error(f"Embed purge failed: {str(e)}")
logger.error(f"Embed purge failed for guild {guild_id}: {str(e)}")
await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True)
+1 -1
View File
@@ -5,4 +5,4 @@ ApplicationAPIKey = ptla_yourpterodactylapplicationapikey
[Discord]
Token = yourdiscordbottoken
AllowedGuildID = yourdiscordguildid
AllowedGuildIDs = yourdiscordguildid1,yourdiscordguildid2
+48 -11
View File
@@ -9,7 +9,7 @@ import configparser
import logging
import os
from pathlib import Path
from typing import Optional
from typing import List, Optional
from exceptions import ConfigValidationError
@@ -30,7 +30,8 @@ class Config:
client_api_key: Client API key for server operations
application_api_key: Application API key for admin operations
discord_token: Discord bot token
allowed_guild_id: Discord server ID where bot operates
allowed_guild_ids: List of Discord server IDs where bot operates
allowed_guild_id: First guild ID (backward compatibility)
required_role: Role name required for server control permissions
update_interval: Background task update interval in seconds
embed_storage_path: Path to embed locations JSON file
@@ -60,7 +61,9 @@ class Config:
self.client_api_key: str = self._config.get("Pterodactyl", "ClientAPIKey")
self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey")
self.discord_token: str = self._config.get("Discord", "Token")
self.allowed_guild_id: int = self._config.getint("Discord", "AllowedGuildID")
self.allowed_guild_ids: List[int] = self._parse_allowed_guild_ids()
# Backward compatibility: first guild ID
self.allowed_guild_id: int = self.allowed_guild_ids[0] if self.allowed_guild_ids else 0
self.required_role: str = "Game Server User"
self.update_interval: int = 10
self.embed_storage_path: Path = Path("./embed/embed_locations.json")
@@ -96,18 +99,29 @@ class Config:
if not self._config.has_section("Discord"):
errors.append("Missing [Discord] section in config.ini")
else:
required_discord = ["Token", "AllowedGuildID"]
required_discord = ["Token"] # Guild ID validated separately below
for key in required_discord:
if not self._config.get("Discord", key, fallback=None):
errors.append(f"Missing required Discord config value: {key}")
# Validate AllowedGuildID is a valid integer
# Validate AllowedGuildIDs (comma-separated list)
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
# Backward compatibility: also check for singular AllowedGuildID
if not allowed_guild_ids_str:
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
if not allowed_guild_ids_str:
errors.append("Missing required Discord config value: AllowedGuildIDs (or AllowedGuildID)")
else:
try:
guild_id = self._config.getint("Discord", "AllowedGuildID", fallback=0)
if guild_id <= 0:
errors.append("AllowedGuildID must be a positive integer")
guild_ids = [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
if not guild_ids:
errors.append("AllowedGuildIDs must contain at least one guild ID")
for gid in guild_ids:
if gid <= 0:
errors.append(f"Invalid guild ID: {gid} (must be positive integer)")
except ValueError:
errors.append("AllowedGuildID must be a valid integer")
errors.append("AllowedGuildIDs must be comma-separated integers")
# Validate API keys have correct prefixes (Pterodactyl and Pelican Panel)
client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="")
@@ -138,6 +152,24 @@ class Config:
or self.application_api_key.startswith("papp_")
)
def _parse_allowed_guild_ids(self) -> List[int]:
"""Parse comma-separated guild IDs from config.
Supports both new 'AllowedGuildIDs' and legacy 'AllowedGuildID' keys.
Returns:
List of guild IDs as integers
"""
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
if not allowed_guild_ids_str:
# Backward compatibility
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
if not allowed_guild_ids_str:
return []
return [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
@property
def panel_type(self) -> str:
"""Return the detected panel type."""
@@ -182,7 +214,7 @@ def generate_config_from_env(output_path: str = "config.ini") -> None:
Useful for Docker/container deployments where config comes from env vars.
Required environment variables:
DISCORD_TOKEN, ALLOWED_GUILD_ID, PANEL_URL,
DISCORD_TOKEN, ALLOWED_GUILD_IDS (or ALLOWED_GUILD_ID), PANEL_URL,
CLIENT_API_KEY, APPLICATION_API_KEY
Args:
@@ -196,9 +228,14 @@ def generate_config_from_env(output_path: str = "config.ini") -> None:
"ApplicationAPIKey": os.environ.get("APPLICATION_API_KEY", ""),
}
# Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID
allowed_guild_ids = os.environ.get("ALLOWED_GUILD_IDS", "")
if not allowed_guild_ids:
allowed_guild_ids = os.environ.get("ALLOWED_GUILD_ID", "")
config["Discord"] = {
"Token": os.environ.get("DISCORD_TOKEN", ""),
"AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""),
"AllowedGuildIDs": allowed_guild_ids,
}
with open(output_path, "w") as f:
+155 -56
View File
@@ -3,10 +3,12 @@ Embed Management Module
Handles creation, tracking, and persistence of Discord server status embeds.
Manages embed locations storage and provides utilities for embed lifecycle.
Supports multi-guild deployments where each guild tracks servers independently.
"""
import json
import logging
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
@@ -24,14 +26,15 @@ logger = logging.getLogger("pterodisbot")
class EmbedManager:
"""
Manages server status embeds lifecycle.
Manages server status embeds lifecycle across multiple guilds.
Handles:
- Embed creation with real-time server data
- Persistent storage of embed locations (channel_id, message_id)
- Embed refresh and recreation
- Persistent storage of embed locations per guild (guild_id -> server_id -> location)
- Embed refresh and recreation per guild
- Cleanup of deleted/missing embeds
- Rate limit protection during bulk operations
- Migration from legacy single-guild storage format
Attributes:
config: Application configuration
@@ -58,15 +61,16 @@ class EmbedManager:
self.api = api
self.metrics_manager = metrics_manager
self.storage_path = config.embed_storage_path
self.embed_locations: Dict[str, Dict[str, int]] = {}
self.previous_states: Dict[str, Tuple[str, float, Optional[float]]] = {}
# Structure: {guild_id: {server_id: {"channel_id": str, "message_id": str}}}
self.embed_locations: Dict[int, Dict[str, Dict[str, str]]] = defaultdict(dict)
# Previous states tracking moved to bot.py for multi-guild support
# Ensure storage directory exists
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
logger.info("Initialized EmbedManager")
logger.info("Initialized EmbedManager with multi-guild support")
async def load_locations(self) -> None:
"""Load saved embed locations from JSON storage file."""
"""Load saved embed locations from JSON storage file with migration support."""
logger.debug("Loading embed locations from storage")
if not self.storage_path.exists():
logger.info("No existing embed locations file found")
@@ -74,8 +78,47 @@ class EmbedManager:
try:
with open(self.storage_path, "r") as f:
self.embed_locations = json.load(f)
logger.info(f"Loaded {len(self.embed_locations)} embed locations from storage")
data = json.load(f)
# Detect format:
# New format: {guild_id: {server_id: location}} where values are dicts with channel_id/message_id
# Old format: {server_id: location} where location has channel_id/message_id directly
is_new_format = False
if data:
first_key = next(iter(data.keys()))
first_value = data[first_key]
# New format: value is a dict of server locations
# Old format: value is a location dict with channel_id/message_id
if isinstance(first_value, dict) and "channel_id" not in first_value:
# Value is {server_id: {channel_id, message_id}} - new format
is_new_format = True
elif isinstance(first_value, dict) and "channel_id" in first_value:
# Value is {channel_id, message_id} - old format
is_new_format = False
# If first_key is numeric and >= 17 digits, also treat as new format
elif first_key.isdigit() and len(first_key) >= 17:
is_new_format = True
if is_new_format:
# New format: {guild_id: {server_id: location}}
self.embed_locations = defaultdict(dict)
for guild_id_str, servers in data.items():
guild_id = int(guild_id_str)
self.embed_locations[guild_id] = servers
total_embeds = sum(len(s) for s in self.embed_locations.values())
logger.info(f"Loaded embed locations for {len(self.embed_locations)} guilds ({total_embeds} total embeds)")
else:
# Legacy format: {server_id: location} - migrate to first allowed guild
legacy_guild_id = self.config.allowed_guild_ids[0] if self.config.allowed_guild_ids else 0
if legacy_guild_id:
self.embed_locations = defaultdict(dict)
self.embed_locations[legacy_guild_id] = data
logger.info(f"Migrated legacy embed locations to guild {legacy_guild_id}")
# Save in new format immediately
await self.save_locations()
else:
logger.warning("No allowed guilds configured, cannot migrate legacy embed locations")
except Exception as e:
logger.error(f"Failed to load embed locations: {str(e)}")
@@ -83,40 +126,72 @@ class EmbedManager:
"""Save current embed locations to JSON storage file."""
logger.debug("Saving embed locations to storage")
try:
# Convert defaultdict to regular dict for JSON serialization
# Guild IDs as strings for JSON compatibility
data = {str(gid): servers for gid, servers in self.embed_locations.items()}
with open(self.storage_path, "w") as f:
json.dump(self.embed_locations, f, indent=2)
json.dump(data, f, indent=2)
logger.debug("Successfully saved embed locations")
except Exception as e:
logger.error(f"Failed to save embed locations: {str(e)}")
async def track_embed(self, server_id: str, message: discord.Message) -> None:
def get_guild_locations(self, guild_id: int) -> Dict[str, Dict[str, str]]:
"""
Track a newly created embed in storage.
Get all tracked embeds for a specific guild.
Args:
guild_id: Discord guild ID
Returns:
Dictionary mapping server_id to location info
"""
return self.embed_locations.get(guild_id, {})
def get_all_tracked_servers(self) -> List[Tuple[int, str]]:
"""
Get all (guild_id, server_id) pairs currently being tracked.
Returns:
List of (guild_id, server_id) tuples
"""
result = []
for guild_id, servers in self.embed_locations.items():
for server_id in servers:
result.append((guild_id, server_id))
return result
async def track_embed(self, guild_id: int, server_id: str, message: discord.Message) -> None:
"""
Track a newly created embed in storage for a specific guild.
Args:
guild_id: Discord guild ID
server_id: The server's panel identifier
message: Discord message containing the embed
"""
logger.debug(f"Tracking new embed for server {server_id} in channel {message.channel.id}")
self.embed_locations[server_id] = {
logger.debug(f"Tracking new embed for server {server_id} in guild {guild_id}, channel {message.channel.id}")
self.embed_locations[guild_id][server_id] = {
"channel_id": str(message.channel.id),
"message_id": str(message.id),
}
await self.save_locations()
async def untrack_embed(self, server_id: str) -> None:
async def untrack_embed(self, guild_id: int, server_id: str) -> None:
"""
Remove a server's embed from tracking.
Remove a server's embed from tracking for a specific guild.
Args:
guild_id: Discord guild ID
server_id: The server's panel identifier
"""
self.embed_locations.pop(server_id, None)
self.previous_states.pop(server_id, None)
self.embed_locations[guild_id].pop(server_id, None)
# Clean up empty guild entries
if not self.embed_locations[guild_id]:
del self.embed_locations[guild_id]
await self.save_locations()
async def create_server_embed(
self, server_data: ServerData, resources: ServerResources
self, server_data: ServerData, resources: ServerResources, guild_id: int
) -> Tuple[discord.Embed, discord.ui.View]:
"""
Create a status embed and interactive view for a server.
@@ -124,6 +199,7 @@ class EmbedManager:
Args:
server_data: Server information from panel
resources: Current resource usage data
guild_id: Discord guild ID this embed belongs to
Returns:
Tuple of (embed, view) ready for display
@@ -133,7 +209,7 @@ class EmbedManager:
name = attributes.name
description = attributes.description or "No description available"
logger.debug(f"Building status embed for server {name} ({identifier})")
logger.debug(f"Building status embed for server {name} ({identifier}) in guild {guild_id}")
# Parse resource data
current_state = resources.attributes.current_state.title()
@@ -165,7 +241,7 @@ class EmbedManager:
embed.set_footer(text="Last updated")
# Create interactive view with control buttons
# Create interactive view with control buttons (pass guild_id for validation)
from views import ServerStatusView
view = ServerStatusView(
@@ -173,9 +249,10 @@ class EmbedManager:
server_name=name,
api=self.api,
server_data=server_data.model_dump(),
guild_id=guild_id,
)
logger.debug(f"Successfully built status components for {name}")
logger.debug(f"Successfully built status components for {name} in guild {guild_id}")
return embed, view
def _add_resource_fields(
@@ -198,10 +275,10 @@ class EmbedManager:
disk_limit = limits.disk
# Format limit values - display ∞ for unlimited (0 limit)
def format_limit(value: int, unit: str = "") -> str:
def format_limit(value: int) -> str:
if value == 0:
return f"{'∞':>8} {unit}"
return f"{value:>8} {unit}"
return f"{'∞':<8}"
return f"{value:<8}"
# Format uptime
uptime_ms = resource_attrs.uptime
@@ -227,9 +304,9 @@ class EmbedManager:
# Resource usage text box
usage_text = (
f"```properties\n"
f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit, '%')}\n"
f"Memory : [{memory_usage:>8} / {format_limit(memory_limit, 'MiB')}\n"
f"Disk : [{disk_usage:>8} / {format_limit(disk_limit, 'MiB')}\n"
f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit)}] %\n"
f"Memory : [{memory_usage:>8} / {format_limit(memory_limit)}] MiB\n"
f"Disk : [{disk_usage:>8} / {format_limit(disk_limit)}] MiB\n"
f"```"
)
@@ -261,16 +338,19 @@ class EmbedManager:
return embed
async def refresh_all_embeds(self) -> Tuple[int, int]:
async def refresh_all_embeds(self, guild_id: int) -> Tuple[int, int]:
"""
Perform a complete refresh of all server status embeds.
Perform a complete refresh of all server status embeds for a specific guild.
Creates new embeds and deletes old ones to prevent duplication.
Args:
guild_id: Discord guild ID to refresh embeds for
Returns:
Tuple of (deleted_count, created_count)
"""
logger.info("Starting full refresh of all server embeds")
logger.info(f"Starting full refresh of all server embeds for guild {guild_id}")
from bot import PterodactylBot # Avoid circular import
bot = PterodactylBot.get_instance()
@@ -278,6 +358,11 @@ class EmbedManager:
created_count = 0
skipped_count = 0
guild_locations = self.get_guild_locations(guild_id)
if not guild_locations:
logger.info(f"No embeds tracked for guild {guild_id}, nothing to refresh")
return 0, 0
try:
# Get current server list
servers = await self.api.get_servers()
@@ -291,14 +376,19 @@ class EmbedManager:
new_embeds = {}
for server_id, server_data in server_cache.items():
if server_id not in self.embed_locations:
if server_id not in guild_locations:
skipped_count += 1
continue
channel_id = self.embed_locations[server_id]["channel_id"]
channel_id = guild_locations[server_id]["channel_id"]
channel = bot.get_channel(int(channel_id))
if not channel:
logger.warning(f"Channel {channel_id} not found for server {server_id}")
logger.warning(f"Channel {channel_id} not found for server {server_id} in guild {guild_id}")
continue
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {channel_id} not in guild {guild_id}, skipping")
continue
try:
@@ -306,7 +396,7 @@ class EmbedManager:
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
embed, view = await self.create_server_embed(server_obj, resources_obj)
embed, view = await self.create_server_embed(server_obj, resources_obj, guild_id)
message = await channel.send(embed=embed, view=view)
new_embeds[server_id] = {
@@ -314,48 +404,53 @@ class EmbedManager:
"message_id": str(message.id),
}
created_count += 1
logger.info(f"Created new embed for server {server_data['attributes']['name']}")
logger.info(f"Created new embed for server {server_data['attributes']['name']} in guild {guild_id}")
await asyncio.sleep(1) # Rate limit protection
except Exception as e:
logger.error(f"Failed to create new embed for server {server_id}: {str(e)}")
logger.error(f"Failed to create new embed for server {server_id} in guild {guild_id}: {str(e)}")
if not new_embeds:
logger.warning("No new embeds created during refresh")
logger.warning(f"No new embeds created during refresh for guild {guild_id}")
return 0, 0
# Delete old embeds
for server_id, location in list(self.embed_locations.items()):
for server_id, location in list(guild_locations.items()):
try:
channel = bot.get_channel(int(location["channel_id"]))
if channel:
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}, skipping delete")
continue
try:
message = await channel.fetch_message(int(location["message_id"]))
await message.delete()
deleted_count += 1
logger.debug(f"Deleted old embed for server {server_id}")
logger.debug(f"Deleted old embed for server {server_id} in guild {guild_id}")
await asyncio.sleep(0.5)
except discord.NotFound:
logger.debug(f"Old embed for server {server_id} already deleted")
logger.debug(f"Old embed for server {server_id} in guild {guild_id} already deleted")
except Exception as e:
logger.error(f"Failed to delete old embed for server {server_id}: {str(e)}")
logger.error(f"Failed to delete old embed for server {server_id} in guild {guild_id}: {str(e)}")
except Exception as e:
logger.error(f"Error processing old embed for server {server_id}: {str(e)}")
logger.error(f"Error processing old embed for server {server_id} in guild {guild_id}: {str(e)}")
logger.info(f"Deleted {deleted_count} old embeds, created {created_count} new embeds")
logger.info(f"Guild {guild_id}: Deleted {deleted_count} old embeds, created {created_count} new embeds")
# Update storage
self.embed_locations = new_embeds
# Update storage for this guild only
self.embed_locations[guild_id] = new_embeds
await self.save_locations()
return deleted_count, created_count
except Exception as e:
logger.error(f"Critical error during embed refresh: {str(e)}")
logger.error(f"Critical error during embed refresh for guild {guild_id}: {str(e)}")
raise
async def update_single_embed(
self,
guild_id: int,
server_id: str,
location: Dict[str, str],
server_data: ServerData,
@@ -364,9 +459,10 @@ class EmbedManager:
cpu_usage: float,
) -> bool:
"""
Update a single server's status embed.
Update a single server's status embed for a specific guild.
Args:
guild_id: Discord guild ID
server_id: Server identifier
location: Embed location dict with channel_id and message_id
server_data: Current server data
@@ -381,11 +477,16 @@ class EmbedManager:
bot = PterodactylBot.get_instance()
try:
embed, view = await self.create_server_embed(server_data, resources)
embed, view = await self.create_server_embed(server_data, resources, guild_id)
channel = bot.get_channel(int(location["channel_id"]))
if not channel:
logger.warning(f"Channel {location['channel_id']} not found for server {server_id}")
logger.warning(f"Channel {location['channel_id']} not found for server {server_id} in guild {guild_id}")
return False
# Verify channel belongs to the correct guild
if channel.guild.id != guild_id:
logger.warning(f"Channel {location['channel_id']} not in guild {guild_id}")
return False
message = await channel.fetch_message(int(location["message_id"]))
@@ -406,17 +507,15 @@ class EmbedManager:
# Empty files list removes existing attachments
await message.edit(embed=embed, view=view, attachments=files)
logger.debug(f"Updated status for {server_data.attributes.name}")
logger.debug(f"Updated status for {server_data.attributes.name} in guild {guild_id}")
return True
except discord.NotFound:
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
self.embed_locations.pop(server_id, None)
self.previous_states.pop(server_id, None)
await self.save_locations()
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
await self.untrack_embed(guild_id, server_id)
return False
except Exception as e:
logger.error(f"Failed to update status for server {server_id}: {str(e)}")
logger.error(f"Failed to update status for server {server_id} in guild {guild_id}: {str(e)}")
return False
+39 -9
View File
@@ -4,27 +4,47 @@ from pathlib import Path
from dotenv import load_dotenv
def validate_env_vars():
"""Validate that required environment variables are set and non-empty."""
required_vars = [
REQUIRED_VARS = [
'DISCORD_TOKEN',
'ALLOWED_GUILD_ID',
'ALLOWED_GUILD_ID', # Legacy
'ALLOWED_GUILD_IDS', # New
'PANEL_URL',
'CLIENT_API_KEY',
'APPLICATION_API_KEY'
]
]
missing_vars = [var for var in required_vars if not os.getenv(var)]
def validate_env_vars():
"""Validate that required environment variables are set and non-empty."""
# Check for either legacy or new guild ID variable
has_guild_id = bool(os.getenv('ALLOWED_GUILD_ID'))
has_guild_ids = bool(os.getenv('ALLOWED_GUILD_IDS'))
if not has_guild_id and not has_guild_ids:
raise ValueError(
"Missing required environment variable: ALLOWED_GUILD_IDS (or legacy ALLOWED_GUILD_ID)"
)
# Check other required vars
missing_vars = [
var for var in ['DISCORD_TOKEN', 'PANEL_URL', 'CLIENT_API_KEY', 'APPLICATION_API_KEY']
if not os.getenv(var)
]
if missing_vars:
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
def get_env_config():
"""Extract configuration from environment variables."""
# Support both new ALLOWED_GUILD_IDS and legacy ALLOWED_GUILD_ID
allowed_guild_ids = os.getenv('ALLOWED_GUILD_IDS', '')
if not allowed_guild_ids:
allowed_guild_ids = os.getenv('ALLOWED_GUILD_ID', '')
return {
'Discord': {
'Token': os.getenv('DISCORD_TOKEN'),
'AllowedGuildID': os.getenv('ALLOWED_GUILD_ID')
'AllowedGuildIDs': allowed_guild_ids
},
'Pterodactyl': {
'PanelURL': os.getenv('PANEL_URL'),
@@ -43,8 +63,18 @@ def configs_match(existing_config, env_config):
if env_value is None:
# Environment variable not set, don't require match
continue
existing_value = existing_config.get(section, key, fallback=None)
if existing_value != env_value:
# For AllowedGuildIDs, also check legacy AllowedGuildID key
check_keys = [key]
if key == 'AllowedGuildIDs':
check_keys.append('AllowedGuildID')
matched = False
for check_key in check_keys:
existing_value = existing_config.get(section, check_key, fallback=None)
if existing_value == env_value:
matched = True
break
if not matched:
return False
return True
+7 -1
View File
@@ -101,11 +101,17 @@ class ServerListResponse(BaseModel):
class EmbedLocation(BaseModel):
"""Embed location tracking data."""
"""Embed location tracking data with guild context."""
guild_id: int
channel_id: str
message_id: str
# Composite key for storage
@property
def storage_key(self) -> str:
return f"{self.guild_id}:{self.channel_id}:{self.message_id}"
# Rebuild models to resolve forward references
ResourceAttributes.model_rebuild()
-1585
View File
File diff suppressed because it is too large. Load diff
+31 -16
View File
@@ -14,6 +14,7 @@ import pytest
import asyncio
import json
import os
from collections import defaultdict
from unittest.mock import Mock, AsyncMock, patch, MagicMock
from datetime import datetime
import configparser
@@ -52,7 +53,8 @@ def mock_config():
config.client_api_key = 'ptlc_test_client_key_123'
config.application_api_key = 'ptla_test_app_key_456'
config.discord_token = 'test_discord_token'
config.allowed_guild_id = 123456789
config.allowed_guild_ids = [123456789, 987654321]
config.allowed_guild_id = 123456789 # Backward compat
config.required_role = "Game Server User"
config.update_interval = 10
config.embed_storage_path = Path("./embed/embed_locations.json")
@@ -177,8 +179,8 @@ def mock_bot(mock_config, mock_panel_api):
bot = PterodactylBot(mock_config, command_prefix="!", intents=intents)
bot.api = mock_panel_api
bot.server_cache = {}
bot.embed_manager.embed_locations = {}
bot.previous_states = {}
# Don't override embed_locations - let it use the defaultdict from EmbedManager
bot.previous_states = defaultdict(dict)
return bot
@@ -561,13 +563,15 @@ class TestServerStatusView:
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data
server_data=sample_server_data,
guild_id=123456789
)
assert view.server_id == 'abc123'
assert view.server_name == 'Test Server'
assert view.api is mock_panel_api
assert view.timeout is None # Persistent view
assert view.guild_id == 123456789
@pytest.mark.asyncio
async def test_interaction_check_authorized(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction):
@@ -576,7 +580,8 @@ class TestServerStatusView:
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data
server_data=sample_server_data,
guild_id=123456789
)
result = await view.interaction_check(mock_discord_interaction)
@@ -590,7 +595,8 @@ class TestServerStatusView:
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data
server_data=sample_server_data,
guild_id=123456789
)
mock_discord_interaction.guild_id = 999999999
@@ -607,7 +613,8 @@ class TestServerStatusView:
server_id='abc123',
server_name='Test Server',
api=mock_panel_api,
server_data=sample_server_data
server_data=sample_server_data,
guild_id=123456789
)
# Remove required role
@@ -638,7 +645,8 @@ class TestPterodactylBot:
assert bot.metrics_manager is not None
assert bot.embed_manager is not None
assert bot.server_cache == {}
assert bot.previous_states == {}
# previous_states is now a defaultdict
assert isinstance(bot.previous_states, dict)
@pytest.mark.asyncio
async def test_track_new_embed(self, mock_bot, mock_discord_interaction):
@@ -647,25 +655,29 @@ class TestPterodactylBot:
mock_message.channel.id = 987654321
mock_message.id = 123456789
await mock_bot.embed_manager.track_embed('abc123', mock_message)
await mock_bot.embed_manager.track_embed(123456789, 'abc123', mock_message)
assert 'abc123' in mock_bot.embed_manager.embed_locations
assert mock_bot.embed_manager.embed_locations['abc123']['channel_id'] == '987654321'
assert mock_bot.embed_manager.embed_locations['abc123']['message_id'] == '123456789'
assert 123456789 in mock_bot.embed_manager.embed_locations
assert 'abc123' in mock_bot.embed_manager.embed_locations[123456789]
assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['channel_id'] == '987654321'
assert mock_bot.embed_manager.embed_locations[123456789]['abc123']['message_id'] == '123456789'
@pytest.mark.asyncio
async def test_load_embed_locations(self, mock_bot, tmp_path):
"""Test loading embed locations from file."""
# Create a temporary embed locations file
# Create a temporary embed locations file with new multi-guild format
embed_file = tmp_path / "embed_locations.json"
embed_file.write_text(json.dumps({
"123456789": {
"server1": {"channel_id": "123", "message_id": "456"}
}
}))
mock_bot.embed_manager.storage_path = embed_file
await mock_bot.embed_manager.load_locations()
assert "server1" in mock_bot.embed_manager.embed_locations
assert 123456789 in mock_bot.embed_manager.embed_locations
assert "server1" in mock_bot.embed_manager.embed_locations[123456789]
@pytest.mark.asyncio
async def test_save_embed_locations(self, mock_bot, tmp_path):
@@ -674,13 +686,16 @@ class TestPterodactylBot:
mock_bot.embed_manager.storage_path = embed_file
mock_bot.embed_manager.embed_locations = {
123456789: {
"server1": {"channel_id": "123", "message_id": "456"}
}
}
await mock_bot.embed_manager.save_locations()
saved_data = json.loads(embed_file.read_text())
assert "server1" in saved_data
assert "123456789" in saved_data
assert "server1" in saved_data["123456789"]
# ==========================================
@@ -715,7 +730,7 @@ class TestIntegration:
server_obj = ServerData.model_validate(sample_server_data)
resources_obj = ServerResources.model_validate(sample_resources_data)
embed, view = await mock_bot.embed_manager.create_server_embed(server_obj, resources_obj)
embed, view = await mock_bot.embed_manager.create_server_embed(server_obj, resources_obj, 123456789)
assert embed is not None
assert view is not None
+90 -60
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,20 +160,43 @@ 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}")
"""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, "restart")
result = await self.api.send_power_action(self.server_id, action)
if result.get("status") == "success":
message = f"Server '{self.server_name}' is restarting..."
logger.info(f"Successfully restarted server {self.server_name}")
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 restart server: {result.get('message', 'Unknown error')}"
logger.error(f"Failed to restart server {self.server_name}: {message}")
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,
@@ -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:
# 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}")
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} already deleted")
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
)