Merge development into main: Multi-guild support + fixes #5
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
|
- **Background Task Management**: Asynchronous update loop runs every 10 seconds
|
||||||
|
|
||||||
### 🔒 Security & Access Control
|
### 🔒 Security & Access Control
|
||||||
- **Single-Guild Restriction**: Bot operates exclusively in your designated Discord server
|
- **Multi-Guild Support**: Bot operates across multiple designated Discord servers simultaneously
|
||||||
- **Role-based Permissions**: "Game Server User" role required for all server interactions
|
- **Per-Guild Server Tracking**: Each guild tracks servers independently with isolated embeds
|
||||||
- **Administrator Commands**: Special management commands restricted to server administrators
|
- **Role-based Permissions**: "Game Server User" role required for all server interactions (per guild)
|
||||||
- **Interaction Validation**: Every button press and command verified for proper authorization
|
- **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
|
### 💾 Data Persistence
|
||||||
- **Embed Location Storage**: Automatically remembers where embeds are posted (survives bot restarts)
|
- **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:
|
When using Docker or generating the config automatically, these environment variables are required:
|
||||||
|
|
||||||
- `DISCORD_TOKEN`: Your Discord bot token (from Discord Developer Portal)
|
- `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://)
|
- `PANEL_URL`: Your Pterodactyl panel URL (must include http:// or https://)
|
||||||
- `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`)
|
- `CLIENT_API_KEY`: Pterodactyl client API key (starts with `ptlc_`)
|
||||||
- `APPLICATION_API_KEY`: Pterodactyl application API key (starts with `ptla_`)
|
- `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
|
### Configuration File
|
||||||
|
|
||||||
The bot uses a `config.ini` file with this structure:
|
The bot uses a `config.ini` file with this structure:
|
||||||
@@ -455,7 +458,7 @@ ApplicationAPIKey = ptla_yourapplicationkey
|
|||||||
|
|
||||||
[Discord]
|
[Discord]
|
||||||
Token = your.discord.bot.token
|
Token = your.discord.bot.token
|
||||||
AllowedGuildID = 123456789012345678
|
AllowedGuildIDs = 123456789012345678,987654321098765432
|
||||||
```
|
```
|
||||||
|
|
||||||
To generate the config file from environment variables:
|
To generate the config file from environment variables:
|
||||||
@@ -479,6 +482,8 @@ python generate_config.py
|
|||||||
|
|
||||||
### Bot Commands
|
### Bot Commands
|
||||||
|
|
||||||
|
All commands are guild-scoped - they only affect the guild (server) where they are invoked.
|
||||||
|
|
||||||
#### `/server_status`
|
#### `/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.
|
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
|
- Shows total server count and online/offline statistics
|
||||||
- Lists all servers with descriptions
|
- Lists all servers with descriptions
|
||||||
- Creates persistent, auto-updating embeds
|
- 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)
|
#### `/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
|
- Recreates all tracked embeds with fresh data
|
||||||
- Deletes old embed messages to prevent duplication
|
- Deletes old embed messages to prevent duplication
|
||||||
- Updates embed tracking storage
|
- Updates embed tracking storage
|
||||||
@@ -501,9 +506,9 @@ Force a complete refresh of all server status embeds. This command:
|
|||||||
- Following Discord API issues
|
- Following Discord API issues
|
||||||
|
|
||||||
#### `/purge_embeds` (Admin only)
|
#### `/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
|
- Removes all tracked embed messages
|
||||||
- Clears the embed tracking file
|
- Clears the embed tracking file for this guild
|
||||||
- Provides real-time progress updates
|
- Provides real-time progress updates
|
||||||
- Shows detailed statistics (deleted, missing, errors)
|
- Shows detailed statistics (deleted, missing, errors)
|
||||||
|
|
||||||
@@ -554,24 +559,26 @@ Embed locations are stored in `embed/embed_locations.json` and persist between b
|
|||||||
|
|
||||||
## Permissions
|
## Permissions
|
||||||
|
|
||||||
The bot implements two levels of access control:
|
The bot implements multi-guild access control with per-guild isolation:
|
||||||
|
|
||||||
### 1. Guild Restriction
|
### 1. Guild Allowlist
|
||||||
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.
|
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
|
### 2. Per-Guild Role-Based Access
|
||||||
Users must have the **"Game Server User"** role (exact name match) to:
|
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)
|
- Use server control buttons (Start/Stop/Restart)
|
||||||
- View server addresses
|
- View server addresses
|
||||||
- Interact with any bot features
|
- Interact with any bot features
|
||||||
|
|
||||||
### 3. Administrator Access
|
Each guild maintains its own "Game Server User" role independently.
|
||||||
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
|
|
||||||
|
|
||||||
**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
|
## Logging System
|
||||||
|
|
||||||
@@ -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 multiple Pterodactyl domains and multiple Discord guilds simultaneously. Each guild tracks servers independently while sharing the same Pterodactyl infrastructure.
|
||||||
@@ -3,6 +3,7 @@ Main Bot Module
|
|||||||
|
|
||||||
Core bot class and command definitions.
|
Core bot class and command definitions.
|
||||||
Manages the bot lifecycle, background tasks, and Discord interactions.
|
Manages the bot lifecycle, background tasks, and Discord interactions.
|
||||||
|
Supports multi-guild deployments with independent server tracking per guild.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -10,8 +11,9 @@ import logging
|
|||||||
import signal
|
import signal
|
||||||
import sys
|
import sys
|
||||||
import types
|
import types
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import TYPE_CHECKING, Optional, Tuple
|
from typing import TYPE_CHECKING, Dict, List, Optional, Tuple
|
||||||
|
|
||||||
import discord
|
import discord
|
||||||
from discord.ext import commands, tasks
|
from discord.ext import commands, tasks
|
||||||
@@ -43,14 +45,15 @@ class PterodactylBot(commands.Bot):
|
|||||||
Main bot class for Pterodactyl/Pelican Panel server management.
|
Main bot class for Pterodactyl/Pelican Panel server management.
|
||||||
|
|
||||||
Handles Discord interactions, embed management, and background tasks.
|
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:
|
Features:
|
||||||
- Background status update task with smart update detection
|
- Background status update task with smart update detection per guild
|
||||||
- Persistent embed tracking with JSON storage
|
- Persistent embed tracking with JSON storage per guild
|
||||||
- Real-time server monitoring with metrics graphs
|
- Real-time server monitoring with metrics graphs
|
||||||
- Role-based access control
|
- Role-based access control per guild
|
||||||
- Single-guild restriction
|
- Multi-guild support with independent server tracking
|
||||||
|
- Per-server action locking to prevent race conditions
|
||||||
- Graceful shutdown handling
|
- Graceful shutdown handling
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -73,23 +76,121 @@ class PterodactylBot(commands.Bot):
|
|||||||
self.metrics_manager = ServerMetricsManager()
|
self.metrics_manager = ServerMetricsManager()
|
||||||
self.embed_manager = EmbedManager(config, self.api, self.metrics_manager)
|
self.embed_manager = EmbedManager(config, self.api, self.metrics_manager)
|
||||||
|
|
||||||
# Server cache from panel
|
# Server cache from panel (shared across guilds, read-mostly)
|
||||||
self.server_cache: dict = {}
|
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()
|
self.update_lock = asyncio.Lock()
|
||||||
|
|
||||||
# State tracking for smart updates
|
# Per-server action locks to serialize concurrent power actions across guilds
|
||||||
# Format: {server_id: (state, cpu_usage, last_force_update)}
|
# Key: server_id, Value: asyncio.Lock
|
||||||
self.previous_states: dict = {}
|
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
|
@classmethod
|
||||||
def get_instance(cls) -> Optional["PterodactylBot"]:
|
def get_instance(cls) -> Optional["PterodactylBot"]:
|
||||||
"""Get the singleton bot instance."""
|
"""Get the singleton bot instance."""
|
||||||
return _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:
|
async def setup_hook(self) -> None:
|
||||||
"""
|
"""
|
||||||
Bot setup routine called when the bot is starting.
|
Bot setup routine called when the bot is starting.
|
||||||
@@ -112,25 +213,28 @@ class PterodactylBot(commands.Bot):
|
|||||||
@tasks.loop(seconds=10) # UPDATE_INTERVAL = 10
|
@tasks.loop(seconds=10) # UPDATE_INTERVAL = 10
|
||||||
async def update_status(self) -> None:
|
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)
|
1. Server power state changes (started/stopped/restarted)
|
||||||
2. Significant CPU usage change (>50% difference)
|
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)
|
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.
|
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:
|
async with self.update_lock:
|
||||||
try:
|
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()
|
servers = await self.api.get_servers()
|
||||||
if not servers:
|
if not servers:
|
||||||
logger.warning("No servers found in panel during update")
|
logger.warning("No servers found in panel during update")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Update our local cache with fresh server data
|
# 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}
|
self.server_cache = {s["attributes"]["identifier"]: s for s in servers}
|
||||||
logger.debug(f"Updated server cache with {len(servers)} 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())
|
active_server_ids = list(self.server_cache.keys())
|
||||||
self.metrics_manager.cleanup_old_servers(active_server_ids)
|
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()
|
current_time = datetime.now().timestamp()
|
||||||
|
|
||||||
# Process each server we're tracking embeds for
|
# Process each guild's embeds
|
||||||
for server_id, location in list(self.embed_manager.embed_locations.items()):
|
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
|
# Skip if server no longer exists in panel
|
||||||
if server_id not in self.server_cache:
|
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
|
continue
|
||||||
|
|
||||||
server_data = self.server_cache[server_id]
|
server_data = self.server_cache[server_id]
|
||||||
server_name = server_data["attributes"]["name"]
|
server_name = server_data["attributes"]["name"]
|
||||||
|
|
||||||
try:
|
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
|
# Get current server resource usage
|
||||||
resources = await self.api.get_server_resources(server_id)
|
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
|
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":
|
if current_state == "running":
|
||||||
memory_usage = round(
|
memory_usage = round(
|
||||||
resources["attributes"]["resources"].get("memory_bytes", 0)
|
resources["attributes"]["resources"].get("memory_bytes", 0)
|
||||||
@@ -180,52 +327,29 @@ class PterodactylBot(commands.Bot):
|
|||||||
f"CPU={cpu_usage}%, Memory={memory_usage}MB"
|
f"CPU={cpu_usage}%, Memory={memory_usage}MB"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Retrieve previous recorded state
|
# Retrieve previous recorded state for THIS guild
|
||||||
prev_state, prev_cpu, last_force_update = self.previous_states.get(
|
prev_state, prev_cpu, last_force_update = guild_prev_states.get(
|
||||||
server_id, (None, 0, None)
|
server_id, (None, 0, None)
|
||||||
)
|
)
|
||||||
|
|
||||||
# DECISION LOGIC: Should we update the embed?
|
# DECISION LOGIC: Should we update the embed?
|
||||||
needs_update = False
|
needs_update, trigger_reason = self._should_update_embed(
|
||||||
|
current_state, prev_state, cpu_usage, prev_cpu,
|
||||||
# 1. Check if power state changed (most important)
|
last_force_update, current_time
|
||||||
if current_state != prev_state:
|
|
||||||
logger.debug(
|
|
||||||
f"Power state changed for {server_name}: "
|
|
||||||
f"{prev_state} -> {current_state}"
|
|
||||||
)
|
)
|
||||||
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
|
# PERFORM UPDATE IF NEEDED
|
||||||
if needs_update:
|
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
|
# Convert to models for embed creation
|
||||||
server_obj = ServerData.model_validate(server_data)
|
server_obj = ServerData.model_validate(server_data)
|
||||||
resources_obj = ServerResources.model_validate(resources)
|
resources_obj = ServerResources.model_validate(resources)
|
||||||
|
|
||||||
# Update the embed
|
# Update the embed
|
||||||
success = await self.embed_manager.update_single_embed(
|
success = await self.embed_manager.update_single_embed(
|
||||||
|
guild_id=guild_id,
|
||||||
server_id=server_id,
|
server_id=server_id,
|
||||||
location=location,
|
location=location,
|
||||||
server_data=server_obj,
|
server_data=server_obj,
|
||||||
@@ -236,60 +360,94 @@ class PterodactylBot(commands.Bot):
|
|||||||
|
|
||||||
if success:
|
if success:
|
||||||
update_count += 1
|
update_count += 1
|
||||||
# Update state tracking
|
# Update state tracking for this guild
|
||||||
new_last_force_update = (
|
new_last_force_update = current_time if (
|
||||||
last_force_update
|
current_state == "running" and
|
||||||
if needs_update
|
(last_force_update is None or current_time - last_force_update >= 600)
|
||||||
and current_state == "running"
|
) else last_force_update
|
||||||
and current_time - (last_force_update or 0) >= 600
|
guild_prev_states[server_id] = (
|
||||||
else last_force_update
|
|
||||||
)
|
|
||||||
self.previous_states[server_id] = (
|
|
||||||
current_state,
|
current_state,
|
||||||
cpu_usage,
|
cpu_usage,
|
||||||
new_last_force_update,
|
new_last_force_update,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No significant changes, but update tracking with current state
|
# No significant changes, but update tracking with current state
|
||||||
self.previous_states[server_id] = (
|
guild_prev_states[server_id] = (
|
||||||
current_state,
|
current_state,
|
||||||
cpu_usage,
|
cpu_usage,
|
||||||
last_force_update,
|
last_force_update,
|
||||||
)
|
)
|
||||||
skipped_count += 1
|
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:
|
except discord.NotFound:
|
||||||
# Embed message was deleted - clean up tracking
|
# Embed message was deleted - clean up tracking for this guild
|
||||||
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
|
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
|
||||||
await self.embed_manager.untrack_embed(server_id)
|
await self.embed_manager.untrack_embed(guild_id, server_id)
|
||||||
missing_count += 1
|
missing_count += 1
|
||||||
except Exception as e:
|
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
|
error_count += 1
|
||||||
|
|
||||||
# Small delay between servers to avoid rate limits
|
# Small delay between servers to avoid rate limits
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# Log summary of this update cycle
|
return update_count, skipped_count, missing_count, error_count
|
||||||
logger.info(
|
|
||||||
f"Update cycle complete: "
|
|
||||||
f"{update_count} updated, "
|
|
||||||
f"{skipped_count} skipped, "
|
|
||||||
f"{missing_count} missing, "
|
|
||||||
f"{error_count} errors"
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
def _should_update_embed(
|
||||||
logger.error(f"Error in update_status task: {str(e)}")
|
self,
|
||||||
await asyncio.sleep(5)
|
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
|
@update_status.before_loop
|
||||||
async def before_update_status(self) -> None:
|
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")
|
logger.debug("Waiting for bot readiness before starting update task")
|
||||||
await self.wait_until_ready()
|
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
|
@update_status.after_loop
|
||||||
async def after_update_status(self) -> None:
|
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:
|
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:
|
Args:
|
||||||
interaction: Discord interaction object
|
interaction: Discord interaction object
|
||||||
@@ -318,13 +476,13 @@ async def check_allowed_guild(interaction: discord.Interaction, config: Config)
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if interaction is allowed, False otherwise
|
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(
|
logger.warning(
|
||||||
f"Command attempted from unauthorized guild {interaction.guild_id} "
|
f"Command attempted from unauthorized guild {interaction.guild_id} "
|
||||||
f"by {interaction.user.name}"
|
f"by {interaction.user.name}"
|
||||||
)
|
)
|
||||||
await interaction.response.send_message(
|
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 False
|
||||||
return True
|
return True
|
||||||
@@ -346,10 +504,10 @@ async def setup_commands(bot: PterodactylBot) -> None:
|
|||||||
@bot.event
|
@bot.event
|
||||||
async def on_interaction(interaction: discord.Interaction) -> None:
|
async def on_interaction(interaction: discord.Interaction) -> None:
|
||||||
"""Global interaction handler to check guild before processing any interaction."""
|
"""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}")
|
logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}")
|
||||||
await interaction.response.send_message(
|
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
|
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})")
|
logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Sync commands only to the allowed guild
|
# Sync commands to ALL allowed guilds
|
||||||
guild = discord.Object(id=bot.config.allowed_guild_id)
|
for guild_id in bot.config.allowed_guild_ids:
|
||||||
|
guild = discord.Object(id=guild_id)
|
||||||
bot.tree.copy_global_to(guild=guild)
|
bot.tree.copy_global_to(guild=guild)
|
||||||
synced = await bot.tree.sync(guild=guild)
|
synced = await bot.tree.sync(guild=guild)
|
||||||
logger.info(
|
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]}"
|
f"{[cmd.name for cmd in synced]}"
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -389,12 +548,13 @@ async def setup_commands(bot: PterodactylBot) -> None:
|
|||||||
5. Generates dropdown menu with all servers (name + description)
|
5. Generates dropdown menu with all servers (name + description)
|
||||||
6. Handles user selection via ephemeral dropdown interaction
|
6. Handles user selection via ephemeral dropdown interaction
|
||||||
7. Creates permanent status embed in channel upon selection
|
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):
|
if not await check_allowed_guild(interaction, config):
|
||||||
return
|
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)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -407,6 +567,7 @@ async def setup_commands(bot: PterodactylBot) -> None:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
async with bot._server_cache_lock:
|
||||||
bot.server_cache = {s["attributes"]["identifier"]: s for s in servers}
|
bot.server_cache = {s["attributes"]["identifier"]: s for s in servers}
|
||||||
logger.debug(f"Refreshed server cache with {len(servers)} 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),
|
view=ServerDropdownView(server_options),
|
||||||
ephemeral=True,
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Server status command failed: {str(e)}")
|
logger.error(f"Server status command failed: {str(e)}")
|
||||||
@@ -481,47 +642,48 @@ async def setup_commands(bot: PterodactylBot) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@bot.tree.command(
|
@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:
|
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):
|
if not await check_allowed_guild(interaction, config):
|
||||||
return
|
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)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
# Require administrator permissions
|
# Require administrator permissions
|
||||||
if not interaction.user.guild_permissions.administrator:
|
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(
|
await interaction.followup.send(
|
||||||
"You need administrator permissions to refresh all embeds.", ephemeral=True
|
"You need administrator permissions to refresh all embeds.", ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
logger.info("Starting full embed refresh per admin request")
|
logger.info(f"Starting full embed refresh per admin request for guild {guild_id}")
|
||||||
deleted, created = await bot.embed_manager.refresh_all_embeds()
|
deleted, created = await bot.embed_manager.refresh_all_embeds(guild_id)
|
||||||
await interaction.followup.send(
|
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,
|
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:
|
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)
|
await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True)
|
||||||
|
|
||||||
@bot.tree.command(
|
@bot.tree.command(
|
||||||
name="purge_embeds",
|
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:
|
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:
|
Workflow:
|
||||||
1. Validates administrator permissions
|
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
|
3. Sends initial progress embed
|
||||||
4. Processes each embed sequentially with error handling
|
4. Processes each embed sequentially with error handling
|
||||||
5. Updates progress embed in real-time
|
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):
|
if not await check_allowed_guild(interaction, config):
|
||||||
return
|
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)
|
await interaction.response.defer(ephemeral=True)
|
||||||
|
|
||||||
# Require administrator permissions
|
# Require administrator permissions
|
||||||
if not interaction.user.guild_permissions.administrator:
|
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(
|
await interaction.followup.send(
|
||||||
"You need administrator permissions to purge all embeds.", ephemeral=True
|
"You need administrator permissions to purge all embeds.", ephemeral=True
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
try:
|
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
|
deleted_count = 0
|
||||||
not_found_count = 0
|
not_found_count = 0
|
||||||
error_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
|
# Create progress embed
|
||||||
progress_embed = discord.Embed(
|
progress_embed = discord.Embed(
|
||||||
title="🔄 Purging Server Embeds",
|
title="🔄 Purging Server Embeds",
|
||||||
description=f"Processing {total_embeds} embeds...",
|
description=f"Processing {total_embeds} embeds in this guild...",
|
||||||
color=discord.Color.orange(),
|
color=discord.Color.orange(),
|
||||||
)
|
)
|
||||||
progress_embed.add_field(name="Deleted", value="0", inline=True)
|
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)
|
progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True)
|
||||||
|
|
||||||
# Process each tracked embed
|
# Process each tracked embed in this guild
|
||||||
for server_id, location in list(bot.embed_manager.embed_locations.items()):
|
for server_id, location in list(guild_locations.items()):
|
||||||
try:
|
try:
|
||||||
channel = bot.get_channel(int(location["channel_id"]))
|
channel = bot.get_channel(int(location["channel_id"]))
|
||||||
if channel:
|
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:
|
try:
|
||||||
message = await channel.fetch_message(int(location["message_id"]))
|
message = await channel.fetch_message(int(location["message_id"]))
|
||||||
await message.delete()
|
await message.delete()
|
||||||
deleted_count += 1
|
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:
|
except discord.NotFound:
|
||||||
not_found_count += 1
|
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:
|
except discord.Forbidden:
|
||||||
error_count += 1
|
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:
|
except Exception as e:
|
||||||
error_count += 1
|
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:
|
else:
|
||||||
not_found_count += 1
|
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
|
# 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
|
# Update progress every 5 embeds or for the last one
|
||||||
processed = deleted_count + not_found_count + error_count
|
processed = deleted_count + not_found_count + error_count
|
||||||
@@ -610,7 +780,7 @@ async def setup_commands(bot: PterodactylBot) -> None:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_count += 1
|
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
|
# Create results embed
|
||||||
result_embed = discord.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}%",
|
value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%",
|
||||||
inline=True,
|
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)
|
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:
|
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)
|
await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -5,4 +5,4 @@ ApplicationAPIKey = ptla_yourpterodactylapplicationapikey
|
|||||||
|
|
||||||
[Discord]
|
[Discord]
|
||||||
Token = yourdiscordbottoken
|
Token = yourdiscordbottoken
|
||||||
AllowedGuildID = yourdiscordguildid
|
AllowedGuildIDs = yourdiscordguildid1,yourdiscordguildid2
|
||||||
@@ -9,7 +9,7 @@ import configparser
|
|||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from exceptions import ConfigValidationError
|
from exceptions import ConfigValidationError
|
||||||
|
|
||||||
@@ -30,7 +30,8 @@ class Config:
|
|||||||
client_api_key: Client API key for server operations
|
client_api_key: Client API key for server operations
|
||||||
application_api_key: Application API key for admin operations
|
application_api_key: Application API key for admin operations
|
||||||
discord_token: Discord bot token
|
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
|
required_role: Role name required for server control permissions
|
||||||
update_interval: Background task update interval in seconds
|
update_interval: Background task update interval in seconds
|
||||||
embed_storage_path: Path to embed locations JSON file
|
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.client_api_key: str = self._config.get("Pterodactyl", "ClientAPIKey")
|
||||||
self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey")
|
self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey")
|
||||||
self.discord_token: str = self._config.get("Discord", "Token")
|
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.required_role: str = "Game Server User"
|
||||||
self.update_interval: int = 10
|
self.update_interval: int = 10
|
||||||
self.embed_storage_path: Path = Path("./embed/embed_locations.json")
|
self.embed_storage_path: Path = Path("./embed/embed_locations.json")
|
||||||
@@ -96,18 +99,29 @@ class Config:
|
|||||||
if not self._config.has_section("Discord"):
|
if not self._config.has_section("Discord"):
|
||||||
errors.append("Missing [Discord] section in config.ini")
|
errors.append("Missing [Discord] section in config.ini")
|
||||||
else:
|
else:
|
||||||
required_discord = ["Token", "AllowedGuildID"]
|
required_discord = ["Token"] # Guild ID validated separately below
|
||||||
for key in required_discord:
|
for key in required_discord:
|
||||||
if not self._config.get("Discord", key, fallback=None):
|
if not self._config.get("Discord", key, fallback=None):
|
||||||
errors.append(f"Missing required Discord config value: {key}")
|
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:
|
try:
|
||||||
guild_id = self._config.getint("Discord", "AllowedGuildID", fallback=0)
|
guild_ids = [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
|
||||||
if guild_id <= 0:
|
if not guild_ids:
|
||||||
errors.append("AllowedGuildID must be a positive integer")
|
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:
|
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)
|
# Validate API keys have correct prefixes (Pterodactyl and Pelican Panel)
|
||||||
client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="")
|
client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="")
|
||||||
@@ -138,6 +152,24 @@ class Config:
|
|||||||
or self.application_api_key.startswith("papp_")
|
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
|
@property
|
||||||
def panel_type(self) -> str:
|
def panel_type(self) -> str:
|
||||||
"""Return the detected panel type."""
|
"""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.
|
Useful for Docker/container deployments where config comes from env vars.
|
||||||
|
|
||||||
Required environment variables:
|
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
|
CLIENT_API_KEY, APPLICATION_API_KEY
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -196,9 +228,14 @@ def generate_config_from_env(output_path: str = "config.ini") -> None:
|
|||||||
"ApplicationAPIKey": os.environ.get("APPLICATION_API_KEY", ""),
|
"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"] = {
|
config["Discord"] = {
|
||||||
"Token": os.environ.get("DISCORD_TOKEN", ""),
|
"Token": os.environ.get("DISCORD_TOKEN", ""),
|
||||||
"AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""),
|
"AllowedGuildIDs": allowed_guild_ids,
|
||||||
}
|
}
|
||||||
|
|
||||||
with open(output_path, "w") as f:
|
with open(output_path, "w") as f:
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ Embed Management Module
|
|||||||
|
|
||||||
Handles creation, tracking, and persistence of Discord server status embeds.
|
Handles creation, tracking, and persistence of Discord server status embeds.
|
||||||
Manages embed locations storage and provides utilities for embed lifecycle.
|
Manages embed locations storage and provides utilities for embed lifecycle.
|
||||||
|
Supports multi-guild deployments where each guild tracks servers independently.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from collections import defaultdict
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Any, Dict, List, Optional, Tuple
|
||||||
@@ -24,14 +26,15 @@ logger = logging.getLogger("pterodisbot")
|
|||||||
|
|
||||||
class EmbedManager:
|
class EmbedManager:
|
||||||
"""
|
"""
|
||||||
Manages server status embeds lifecycle.
|
Manages server status embeds lifecycle across multiple guilds.
|
||||||
|
|
||||||
Handles:
|
Handles:
|
||||||
- Embed creation with real-time server data
|
- Embed creation with real-time server data
|
||||||
- Persistent storage of embed locations (channel_id, message_id)
|
- Persistent storage of embed locations per guild (guild_id -> server_id -> location)
|
||||||
- Embed refresh and recreation
|
- Embed refresh and recreation per guild
|
||||||
- Cleanup of deleted/missing embeds
|
- Cleanup of deleted/missing embeds
|
||||||
- Rate limit protection during bulk operations
|
- Rate limit protection during bulk operations
|
||||||
|
- Migration from legacy single-guild storage format
|
||||||
|
|
||||||
Attributes:
|
Attributes:
|
||||||
config: Application configuration
|
config: Application configuration
|
||||||
@@ -58,15 +61,16 @@ class EmbedManager:
|
|||||||
self.api = api
|
self.api = api
|
||||||
self.metrics_manager = metrics_manager
|
self.metrics_manager = metrics_manager
|
||||||
self.storage_path = config.embed_storage_path
|
self.storage_path = config.embed_storage_path
|
||||||
self.embed_locations: Dict[str, Dict[str, int]] = {}
|
# Structure: {guild_id: {server_id: {"channel_id": str, "message_id": str}}}
|
||||||
self.previous_states: Dict[str, Tuple[str, float, Optional[float]]] = {}
|
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
|
# Ensure storage directory exists
|
||||||
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
|
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:
|
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")
|
logger.debug("Loading embed locations from storage")
|
||||||
if not self.storage_path.exists():
|
if not self.storage_path.exists():
|
||||||
logger.info("No existing embed locations file found")
|
logger.info("No existing embed locations file found")
|
||||||
@@ -74,8 +78,47 @@ class EmbedManager:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
with open(self.storage_path, "r") as f:
|
with open(self.storage_path, "r") as f:
|
||||||
self.embed_locations = json.load(f)
|
data = json.load(f)
|
||||||
logger.info(f"Loaded {len(self.embed_locations)} embed locations from storage")
|
|
||||||
|
# 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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to load embed locations: {str(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."""
|
"""Save current embed locations to JSON storage file."""
|
||||||
logger.debug("Saving embed locations to storage")
|
logger.debug("Saving embed locations to storage")
|
||||||
try:
|
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:
|
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")
|
logger.debug("Successfully saved embed locations")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to save embed locations: {str(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:
|
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
|
server_id: The server's panel identifier
|
||||||
message: Discord message containing the embed
|
message: Discord message containing the embed
|
||||||
"""
|
"""
|
||||||
logger.debug(f"Tracking new embed for server {server_id} in channel {message.channel.id}")
|
logger.debug(f"Tracking new embed for server {server_id} in guild {guild_id}, channel {message.channel.id}")
|
||||||
self.embed_locations[server_id] = {
|
self.embed_locations[guild_id][server_id] = {
|
||||||
"channel_id": str(message.channel.id),
|
"channel_id": str(message.channel.id),
|
||||||
"message_id": str(message.id),
|
"message_id": str(message.id),
|
||||||
}
|
}
|
||||||
await self.save_locations()
|
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:
|
Args:
|
||||||
|
guild_id: Discord guild ID
|
||||||
server_id: The server's panel identifier
|
server_id: The server's panel identifier
|
||||||
"""
|
"""
|
||||||
self.embed_locations.pop(server_id, None)
|
self.embed_locations[guild_id].pop(server_id, None)
|
||||||
self.previous_states.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()
|
await self.save_locations()
|
||||||
|
|
||||||
async def create_server_embed(
|
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]:
|
) -> Tuple[discord.Embed, discord.ui.View]:
|
||||||
"""
|
"""
|
||||||
Create a status embed and interactive view for a server.
|
Create a status embed and interactive view for a server.
|
||||||
@@ -124,6 +199,7 @@ class EmbedManager:
|
|||||||
Args:
|
Args:
|
||||||
server_data: Server information from panel
|
server_data: Server information from panel
|
||||||
resources: Current resource usage data
|
resources: Current resource usage data
|
||||||
|
guild_id: Discord guild ID this embed belongs to
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (embed, view) ready for display
|
Tuple of (embed, view) ready for display
|
||||||
@@ -133,7 +209,7 @@ class EmbedManager:
|
|||||||
name = attributes.name
|
name = attributes.name
|
||||||
description = attributes.description or "No description available"
|
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
|
# Parse resource data
|
||||||
current_state = resources.attributes.current_state.title()
|
current_state = resources.attributes.current_state.title()
|
||||||
@@ -165,7 +241,7 @@ class EmbedManager:
|
|||||||
|
|
||||||
embed.set_footer(text="Last updated")
|
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
|
from views import ServerStatusView
|
||||||
|
|
||||||
view = ServerStatusView(
|
view = ServerStatusView(
|
||||||
@@ -173,9 +249,10 @@ class EmbedManager:
|
|||||||
server_name=name,
|
server_name=name,
|
||||||
api=self.api,
|
api=self.api,
|
||||||
server_data=server_data.model_dump(),
|
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
|
return embed, view
|
||||||
|
|
||||||
def _add_resource_fields(
|
def _add_resource_fields(
|
||||||
@@ -198,10 +275,10 @@ class EmbedManager:
|
|||||||
disk_limit = limits.disk
|
disk_limit = limits.disk
|
||||||
|
|
||||||
# Format limit values - display ∞ for unlimited (0 limit)
|
# 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:
|
if value == 0:
|
||||||
return f"{'∞':>8} {unit}"
|
return f"{'∞':>8}"
|
||||||
return f"{value:>8} {unit}"
|
return f"{value:>8}"
|
||||||
|
|
||||||
# Format uptime
|
# Format uptime
|
||||||
uptime_ms = resource_attrs.uptime
|
uptime_ms = resource_attrs.uptime
|
||||||
@@ -227,9 +304,9 @@ class EmbedManager:
|
|||||||
# Resource usage text box
|
# Resource usage text box
|
||||||
usage_text = (
|
usage_text = (
|
||||||
f"```properties\n"
|
f"```properties\n"
|
||||||
f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit, '%')}\n"
|
f"CPU : [{cpu_usage:>8} / {format_limit(cpu_limit)}] %\n"
|
||||||
f"Memory : [{memory_usage:>8} / {format_limit(memory_limit, 'MiB')}\n"
|
f"Memory : [{memory_usage:>8} / {format_limit(memory_limit)}] MiB\n"
|
||||||
f"Disk : [{disk_usage:>8} / {format_limit(disk_limit, 'MiB')}\n"
|
f"Disk : [{disk_usage:>8} / {format_limit(disk_limit)}] MiB\n"
|
||||||
f"```"
|
f"```"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -261,16 +338,19 @@ class EmbedManager:
|
|||||||
|
|
||||||
return embed
|
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.
|
Creates new embeds and deletes old ones to prevent duplication.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
guild_id: Discord guild ID to refresh embeds for
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (deleted_count, created_count)
|
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
|
from bot import PterodactylBot # Avoid circular import
|
||||||
bot = PterodactylBot.get_instance()
|
bot = PterodactylBot.get_instance()
|
||||||
|
|
||||||
@@ -278,6 +358,11 @@ class EmbedManager:
|
|||||||
created_count = 0
|
created_count = 0
|
||||||
skipped_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:
|
try:
|
||||||
# Get current server list
|
# Get current server list
|
||||||
servers = await self.api.get_servers()
|
servers = await self.api.get_servers()
|
||||||
@@ -291,14 +376,19 @@ class EmbedManager:
|
|||||||
new_embeds = {}
|
new_embeds = {}
|
||||||
|
|
||||||
for server_id, server_data in server_cache.items():
|
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
|
skipped_count += 1
|
||||||
continue
|
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))
|
channel = bot.get_channel(int(channel_id))
|
||||||
if not channel:
|
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
|
continue
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -306,7 +396,7 @@ class EmbedManager:
|
|||||||
server_obj = ServerData.model_validate(server_data)
|
server_obj = ServerData.model_validate(server_data)
|
||||||
resources_obj = ServerResources.model_validate(resources)
|
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)
|
message = await channel.send(embed=embed, view=view)
|
||||||
|
|
||||||
new_embeds[server_id] = {
|
new_embeds[server_id] = {
|
||||||
@@ -314,48 +404,53 @@ class EmbedManager:
|
|||||||
"message_id": str(message.id),
|
"message_id": str(message.id),
|
||||||
}
|
}
|
||||||
created_count += 1
|
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
|
await asyncio.sleep(1) # Rate limit protection
|
||||||
except Exception as e:
|
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:
|
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
|
return 0, 0
|
||||||
|
|
||||||
# Delete old embeds
|
# Delete old embeds
|
||||||
for server_id, location in list(self.embed_locations.items()):
|
for server_id, location in list(guild_locations.items()):
|
||||||
try:
|
try:
|
||||||
channel = bot.get_channel(int(location["channel_id"]))
|
channel = bot.get_channel(int(location["channel_id"]))
|
||||||
if channel:
|
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:
|
try:
|
||||||
message = await channel.fetch_message(int(location["message_id"]))
|
message = await channel.fetch_message(int(location["message_id"]))
|
||||||
await message.delete()
|
await message.delete()
|
||||||
deleted_count += 1
|
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)
|
await asyncio.sleep(0.5)
|
||||||
except discord.NotFound:
|
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:
|
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:
|
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
|
# Update storage for this guild only
|
||||||
self.embed_locations = new_embeds
|
self.embed_locations[guild_id] = new_embeds
|
||||||
await self.save_locations()
|
await self.save_locations()
|
||||||
|
|
||||||
return deleted_count, created_count
|
return deleted_count, created_count
|
||||||
|
|
||||||
except Exception as e:
|
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
|
raise
|
||||||
|
|
||||||
async def update_single_embed(
|
async def update_single_embed(
|
||||||
self,
|
self,
|
||||||
|
guild_id: int,
|
||||||
server_id: str,
|
server_id: str,
|
||||||
location: Dict[str, str],
|
location: Dict[str, str],
|
||||||
server_data: ServerData,
|
server_data: ServerData,
|
||||||
@@ -364,9 +459,10 @@ class EmbedManager:
|
|||||||
cpu_usage: float,
|
cpu_usage: float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Update a single server's status embed.
|
Update a single server's status embed for a specific guild.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
guild_id: Discord guild ID
|
||||||
server_id: Server identifier
|
server_id: Server identifier
|
||||||
location: Embed location dict with channel_id and message_id
|
location: Embed location dict with channel_id and message_id
|
||||||
server_data: Current server data
|
server_data: Current server data
|
||||||
@@ -381,11 +477,16 @@ class EmbedManager:
|
|||||||
bot = PterodactylBot.get_instance()
|
bot = PterodactylBot.get_instance()
|
||||||
|
|
||||||
try:
|
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"]))
|
channel = bot.get_channel(int(location["channel_id"]))
|
||||||
if not channel:
|
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
|
return False
|
||||||
|
|
||||||
message = await channel.fetch_message(int(location["message_id"]))
|
message = await channel.fetch_message(int(location["message_id"]))
|
||||||
@@ -406,17 +507,15 @@ class EmbedManager:
|
|||||||
# Empty files list removes existing attachments
|
# Empty files list removes existing attachments
|
||||||
|
|
||||||
await message.edit(embed=embed, view=view, attachments=files)
|
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
|
return True
|
||||||
|
|
||||||
except discord.NotFound:
|
except discord.NotFound:
|
||||||
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
|
logger.warning(f"Embed for server {server_id} in guild {guild_id} not found, removing from tracking")
|
||||||
self.embed_locations.pop(server_id, None)
|
await self.untrack_embed(guild_id, server_id)
|
||||||
self.previous_states.pop(server_id, None)
|
|
||||||
await self.save_locations()
|
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+38
-8
@@ -4,27 +4,47 @@ from pathlib import Path
|
|||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
|
||||||
def validate_env_vars():
|
REQUIRED_VARS = [
|
||||||
"""Validate that required environment variables are set and non-empty."""
|
|
||||||
required_vars = [
|
|
||||||
'DISCORD_TOKEN',
|
'DISCORD_TOKEN',
|
||||||
'ALLOWED_GUILD_ID',
|
'ALLOWED_GUILD_ID', # Legacy
|
||||||
|
'ALLOWED_GUILD_IDS', # New
|
||||||
'PANEL_URL',
|
'PANEL_URL',
|
||||||
'CLIENT_API_KEY',
|
'CLIENT_API_KEY',
|
||||||
'APPLICATION_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:
|
if missing_vars:
|
||||||
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
|
raise ValueError(f"Missing required environment variables: {', '.join(missing_vars)}")
|
||||||
|
|
||||||
|
|
||||||
def get_env_config():
|
def get_env_config():
|
||||||
"""Extract configuration from environment variables."""
|
"""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 {
|
return {
|
||||||
'Discord': {
|
'Discord': {
|
||||||
'Token': os.getenv('DISCORD_TOKEN'),
|
'Token': os.getenv('DISCORD_TOKEN'),
|
||||||
'AllowedGuildID': os.getenv('ALLOWED_GUILD_ID')
|
'AllowedGuildIDs': allowed_guild_ids
|
||||||
},
|
},
|
||||||
'Pterodactyl': {
|
'Pterodactyl': {
|
||||||
'PanelURL': os.getenv('PANEL_URL'),
|
'PanelURL': os.getenv('PANEL_URL'),
|
||||||
@@ -43,8 +63,18 @@ def configs_match(existing_config, env_config):
|
|||||||
if env_value is None:
|
if env_value is None:
|
||||||
# Environment variable not set, don't require match
|
# Environment variable not set, don't require match
|
||||||
continue
|
continue
|
||||||
existing_value = existing_config.get(section, key, fallback=None)
|
# For AllowedGuildIDs, also check legacy AllowedGuildID key
|
||||||
if existing_value != env_value:
|
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 False
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|||||||
@@ -101,11 +101,17 @@ class ServerListResponse(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class EmbedLocation(BaseModel):
|
class EmbedLocation(BaseModel):
|
||||||
"""Embed location tracking data."""
|
"""Embed location tracking data with guild context."""
|
||||||
|
|
||||||
|
guild_id: int
|
||||||
channel_id: str
|
channel_id: str
|
||||||
message_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
|
# Rebuild models to resolve forward references
|
||||||
ResourceAttributes.model_rebuild()
|
ResourceAttributes.model_rebuild()
|
||||||
|
|||||||
+31
-16
@@ -14,6 +14,7 @@ import pytest
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
from collections import defaultdict
|
||||||
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
from unittest.mock import Mock, AsyncMock, patch, MagicMock
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import configparser
|
import configparser
|
||||||
@@ -52,7 +53,8 @@ def mock_config():
|
|||||||
config.client_api_key = 'ptlc_test_client_key_123'
|
config.client_api_key = 'ptlc_test_client_key_123'
|
||||||
config.application_api_key = 'ptla_test_app_key_456'
|
config.application_api_key = 'ptla_test_app_key_456'
|
||||||
config.discord_token = 'test_discord_token'
|
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.required_role = "Game Server User"
|
||||||
config.update_interval = 10
|
config.update_interval = 10
|
||||||
config.embed_storage_path = Path("./embed/embed_locations.json")
|
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 = PterodactylBot(mock_config, command_prefix="!", intents=intents)
|
||||||
bot.api = mock_panel_api
|
bot.api = mock_panel_api
|
||||||
bot.server_cache = {}
|
bot.server_cache = {}
|
||||||
bot.embed_manager.embed_locations = {}
|
# Don't override embed_locations - let it use the defaultdict from EmbedManager
|
||||||
bot.previous_states = {}
|
bot.previous_states = defaultdict(dict)
|
||||||
return bot
|
return bot
|
||||||
|
|
||||||
|
|
||||||
@@ -561,13 +563,15 @@ class TestServerStatusView:
|
|||||||
server_id='abc123',
|
server_id='abc123',
|
||||||
server_name='Test Server',
|
server_name='Test Server',
|
||||||
api=mock_panel_api,
|
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_id == 'abc123'
|
||||||
assert view.server_name == 'Test Server'
|
assert view.server_name == 'Test Server'
|
||||||
assert view.api is mock_panel_api
|
assert view.api is mock_panel_api
|
||||||
assert view.timeout is None # Persistent view
|
assert view.timeout is None # Persistent view
|
||||||
|
assert view.guild_id == 123456789
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_interaction_check_authorized(self, mock_config, mock_panel_api, sample_server_data, mock_discord_interaction):
|
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_id='abc123',
|
||||||
server_name='Test Server',
|
server_name='Test Server',
|
||||||
api=mock_panel_api,
|
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)
|
result = await view.interaction_check(mock_discord_interaction)
|
||||||
@@ -590,7 +595,8 @@ class TestServerStatusView:
|
|||||||
server_id='abc123',
|
server_id='abc123',
|
||||||
server_name='Test Server',
|
server_name='Test Server',
|
||||||
api=mock_panel_api,
|
api=mock_panel_api,
|
||||||
server_data=sample_server_data
|
server_data=sample_server_data,
|
||||||
|
guild_id=123456789
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_discord_interaction.guild_id = 999999999
|
mock_discord_interaction.guild_id = 999999999
|
||||||
@@ -607,7 +613,8 @@ class TestServerStatusView:
|
|||||||
server_id='abc123',
|
server_id='abc123',
|
||||||
server_name='Test Server',
|
server_name='Test Server',
|
||||||
api=mock_panel_api,
|
api=mock_panel_api,
|
||||||
server_data=sample_server_data
|
server_data=sample_server_data,
|
||||||
|
guild_id=123456789
|
||||||
)
|
)
|
||||||
|
|
||||||
# Remove required role
|
# Remove required role
|
||||||
@@ -638,7 +645,8 @@ class TestPterodactylBot:
|
|||||||
assert bot.metrics_manager is not None
|
assert bot.metrics_manager is not None
|
||||||
assert bot.embed_manager is not None
|
assert bot.embed_manager is not None
|
||||||
assert bot.server_cache == {}
|
assert bot.server_cache == {}
|
||||||
assert bot.previous_states == {}
|
# previous_states is now a defaultdict
|
||||||
|
assert isinstance(bot.previous_states, dict)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_track_new_embed(self, mock_bot, mock_discord_interaction):
|
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.channel.id = 987654321
|
||||||
mock_message.id = 123456789
|
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 123456789 in mock_bot.embed_manager.embed_locations
|
||||||
assert mock_bot.embed_manager.embed_locations['abc123']['channel_id'] == '987654321'
|
assert 'abc123' in mock_bot.embed_manager.embed_locations[123456789]
|
||||||
assert mock_bot.embed_manager.embed_locations['abc123']['message_id'] == '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
|
@pytest.mark.asyncio
|
||||||
async def test_load_embed_locations(self, mock_bot, tmp_path):
|
async def test_load_embed_locations(self, mock_bot, tmp_path):
|
||||||
"""Test loading embed locations from file."""
|
"""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 = tmp_path / "embed_locations.json"
|
||||||
embed_file.write_text(json.dumps({
|
embed_file.write_text(json.dumps({
|
||||||
|
"123456789": {
|
||||||
"server1": {"channel_id": "123", "message_id": "456"}
|
"server1": {"channel_id": "123", "message_id": "456"}
|
||||||
|
}
|
||||||
}))
|
}))
|
||||||
|
|
||||||
mock_bot.embed_manager.storage_path = embed_file
|
mock_bot.embed_manager.storage_path = embed_file
|
||||||
await mock_bot.embed_manager.load_locations()
|
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
|
@pytest.mark.asyncio
|
||||||
async def test_save_embed_locations(self, mock_bot, tmp_path):
|
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.storage_path = embed_file
|
||||||
|
|
||||||
mock_bot.embed_manager.embed_locations = {
|
mock_bot.embed_manager.embed_locations = {
|
||||||
|
123456789: {
|
||||||
"server1": {"channel_id": "123", "message_id": "456"}
|
"server1": {"channel_id": "123", "message_id": "456"}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await mock_bot.embed_manager.save_locations()
|
await mock_bot.embed_manager.save_locations()
|
||||||
|
|
||||||
saved_data = json.loads(embed_file.read_text())
|
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)
|
server_obj = ServerData.model_validate(sample_server_data)
|
||||||
resources_obj = ServerResources.model_validate(sample_resources_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 embed is not None
|
||||||
assert view is not None
|
assert view is not None
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ Discord Views Module
|
|||||||
|
|
||||||
Contains all Discord UI views and interactive components.
|
Contains all Discord UI views and interactive components.
|
||||||
Handles server control buttons, dropdowns, and permission checks.
|
Handles server control buttons, dropdowns, and permission checks.
|
||||||
|
Supports multi-guild deployments with per-guild validation.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
@@ -28,10 +29,11 @@ class ServerStatusView(discord.ui.View):
|
|||||||
Buttons: Start (green), Stop (red), Restart (blurple), Show Address (grey).
|
Buttons: Start (green), Stop (red), Restart (blurple), Show Address (grey).
|
||||||
|
|
||||||
Features:
|
Features:
|
||||||
- Guild restriction (only works in configured server)
|
- Guild restriction (only works in the guild the embed was created in)
|
||||||
- Role-based access control (requires "Game Server User" role)
|
- Role-based access control (requires "Game Server User" role in that guild)
|
||||||
- Persistent views (timeout=None for infinite persistence)
|
- Persistent views (timeout=None for infinite persistence)
|
||||||
- Comprehensive error handling with user feedback
|
- Comprehensive error handling with user feedback
|
||||||
|
- Per-server action locking to prevent race conditions across guilds
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -40,6 +42,7 @@ class ServerStatusView(discord.ui.View):
|
|||||||
server_name: str,
|
server_name: str,
|
||||||
api: "PanelAPI",
|
api: "PanelAPI",
|
||||||
server_data: dict,
|
server_data: dict,
|
||||||
|
guild_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the server status view with control buttons.
|
Initialize the server status view with control buttons.
|
||||||
@@ -49,17 +52,24 @@ class ServerStatusView(discord.ui.View):
|
|||||||
server_name: Human-readable server name
|
server_name: Human-readable server name
|
||||||
api: Panel API client instance
|
api: Panel API client instance
|
||||||
server_data: Full server data from panel
|
server_data: Full server data from panel
|
||||||
|
guild_id: Discord guild ID this view belongs to
|
||||||
"""
|
"""
|
||||||
super().__init__(timeout=None) # Persistent view
|
super().__init__(timeout=None) # Persistent view
|
||||||
self.server_id = server_id
|
self.server_id = server_id
|
||||||
self.server_name = server_name
|
self.server_name = server_name
|
||||||
self.api = api
|
self.api = api
|
||||||
self.server_data = server_data
|
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:
|
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:
|
Args:
|
||||||
interaction: Discord interaction object
|
interaction: Discord interaction object
|
||||||
@@ -69,28 +79,40 @@ class ServerStatusView(discord.ui.View):
|
|||||||
"""
|
"""
|
||||||
config = Config.get_instance()
|
config = Config.get_instance()
|
||||||
|
|
||||||
# First check if interaction is from the allowed guild
|
# First check if interaction is from the SAME guild as this view
|
||||||
if interaction.guild_id != config.allowed_guild_id:
|
if interaction.guild_id != self.guild_id:
|
||||||
logger.warning(f"Unauthorized interaction attempt from guild {interaction.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(
|
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
|
return False
|
||||||
|
|
||||||
# Then check for required role
|
# Then check if the guild is in the allowed list
|
||||||
logger.debug(f"Checking permissions for {interaction.user.name} on server {self.server_name}")
|
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)
|
has_role = any(role.name == config.required_role for role in interaction.user.roles)
|
||||||
if not has_role:
|
if not has_role:
|
||||||
logger.warning(
|
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(
|
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,
|
ephemeral=True,
|
||||||
)
|
)
|
||||||
return False
|
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
|
return True
|
||||||
|
|
||||||
async def on_error(
|
async def on_error(
|
||||||
@@ -104,7 +126,7 @@ class ServerStatusView(discord.ui.View):
|
|||||||
error: Exception that occurred
|
error: Exception that occurred
|
||||||
item: The UI item that triggered the error
|
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():
|
if not interaction.response.is_done():
|
||||||
await interaction.response.send_message(
|
await interaction.response.send_message(
|
||||||
"An error occurred while processing your request.", ephemeral=True
|
"An error occurred while processing your request.", ephemeral=True
|
||||||
@@ -120,19 +142,8 @@ class ServerStatusView(discord.ui.View):
|
|||||||
async def start_button(
|
async def start_button(
|
||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a start command to the server."""
|
"""Send a start command to the server with cross-guild embed update."""
|
||||||
logger.info(f"Start button pressed for {self.server_name} by {interaction.user.name}")
|
await self._handle_power_action(interaction, "start")
|
||||||
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)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
@discord.ui.button(
|
||||||
label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button"
|
label="Stop", style=discord.ButtonStyle.red, custom_id="stop_button"
|
||||||
@@ -140,19 +151,8 @@ class ServerStatusView(discord.ui.View):
|
|||||||
async def stop_button(
|
async def stop_button(
|
||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a stop command to the server."""
|
"""Send a stop command to the server with cross-guild embed update."""
|
||||||
logger.info(f"Stop button pressed for {self.server_name} by {interaction.user.name}")
|
await self._handle_power_action(interaction, "stop")
|
||||||
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)
|
|
||||||
|
|
||||||
@discord.ui.button(
|
@discord.ui.button(
|
||||||
label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button"
|
label="Restart", style=discord.ButtonStyle.blurple, custom_id="restart_button"
|
||||||
@@ -160,20 +160,43 @@ class ServerStatusView(discord.ui.View):
|
|||||||
async def restart_button(
|
async def restart_button(
|
||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send a restart command to the server."""
|
"""Send a restart command to the server with cross-guild embed update."""
|
||||||
logger.info(f"Restart button pressed for {self.server_name} by {interaction.user.name}")
|
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)
|
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":
|
if result.get("status") == "success":
|
||||||
message = f"Server '{self.server_name}' is restarting..."
|
message = f"Server '{self.server_name}' is {action}ing..."
|
||||||
logger.info(f"Successfully restarted server {self.server_name}")
|
logger.info(f"Successfully executed {action} on server {self.server_name}")
|
||||||
else:
|
else:
|
||||||
message = f"Failed to restart server: {result.get('message', 'Unknown error')}"
|
message = f"Failed to {action} server: {result.get('message', 'Unknown error')}"
|
||||||
logger.error(f"Failed to restart server {self.server_name}: {message}")
|
logger.error(f"Failed to {action} server {self.server_name}: {message}")
|
||||||
|
|
||||||
await interaction.followup.send(message, ephemeral=True)
|
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(
|
@discord.ui.button(
|
||||||
label="Show Address",
|
label="Show Address",
|
||||||
style=discord.ButtonStyle.grey,
|
style=discord.ButtonStyle.grey,
|
||||||
@@ -183,7 +206,7 @@ class ServerStatusView(discord.ui.View):
|
|||||||
self, interaction: discord.Interaction, button: discord.ui.Button
|
self, interaction: discord.Interaction, button: discord.ui.Button
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show server's default allocation IP and port."""
|
"""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:
|
try:
|
||||||
await interaction.response.defer(ephemeral=True)
|
await interaction.response.defer(ephemeral=True)
|
||||||
logger.debug(f"Fetching server details for {self.server_id}")
|
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.
|
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
|
from bot import PterodactylBot
|
||||||
|
|
||||||
@@ -248,27 +272,33 @@ class ServerDropdown(discord.ui.Select):
|
|||||||
return
|
return
|
||||||
|
|
||||||
server_name = server_data["attributes"]["name"]
|
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:
|
try:
|
||||||
# Get current server status for embed creation
|
# Get current server status for embed creation
|
||||||
resources = await bot.api.get_server_resources(selected_server_id)
|
resources = await bot.api.get_server_resources(selected_server_id)
|
||||||
|
|
||||||
# Delete old embed if it exists to prevent duplication
|
# Delete old embed if it exists in THIS guild to prevent duplication
|
||||||
if selected_server_id in bot.embed_manager.embed_locations:
|
guild_locations = bot.embed_manager.get_guild_locations(guild_id)
|
||||||
logger.debug(f"Found existing embed for {selected_server_id}, attempting to delete")
|
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:
|
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"]))
|
old_channel = bot.get_channel(int(old_location["channel_id"]))
|
||||||
if old_channel:
|
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:
|
try:
|
||||||
old_message = await old_channel.fetch_message(
|
old_message = await old_channel.fetch_message(
|
||||||
int(old_location["message_id"])
|
int(old_location["message_id"])
|
||||||
)
|
)
|
||||||
await old_message.delete()
|
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:
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Failed to delete old embed: {str(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)
|
server_obj = ServerData.model_validate(server_data)
|
||||||
resources_obj = ServerResources.model_validate(resources)
|
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)
|
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(
|
await interaction.followup.send(
|
||||||
f"✅ **{server_name}** status has been posted in {interaction.channel.mention}",
|
f"✅ **{server_name}** status has been posted in {interaction.channel.mention}",
|
||||||
ephemeral=True,
|
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:
|
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(
|
await interaction.followup.send(
|
||||||
f"❌ Failed to create status embed for **{server_name}**: {str(e)}", ephemeral=True
|
f"❌ Failed to create status embed for **{server_name}**: {str(e)}", ephemeral=True
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in new issue
Block a user