Main #6
No files matched your search
@@ -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.
|
||||
@@ -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
@@ -5,4 +5,4 @@ ApplicationAPIKey = ptla_yourpterodactylapplicationapikey
|
||||
|
||||
[Discord]
|
||||
Token = yourdiscordbottoken
|
||||
AllowedGuildID = yourdiscordguildid
|
||||
AllowedGuildIDs = yourdiscordguildid1,yourdiscordguildid2
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+31
-16
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
Reference in new issue
Block a user