Files
Pterodisbot/bot.py
T
k.eaven 346e6d9604
CI/CD Pipeline / Unit Tests (Python 3.11) (push) Successful in 29s
CI/CD Pipeline / Unit Tests (Python 3.12) (push) Successful in 30s
CI/CD Pipeline / Code Quality & Linting (push) Successful in 23s
CI/CD Pipeline / Unit Tests (Python 3.13) (push) Successful in 27s
CI/CD Pipeline / Integration Tests (push) Successful in 21s
CI/CD Pipeline / Security Scanning (push) Successful in 2m16s
CI/CD Pipeline / Generate Test Report (push) Successful in 4s
CI/CD Pipeline / Build Docker Image (push) Successful in 44s
CI/CD Pipeline / CI/CD Pipeline Status (push) Successful in 2s
Update: Major code refactor for modular extensibility
2026-10-24 10:58:01 +00:00

707 lines
29 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Main Bot Module
Core bot class and command definitions.
Manages the bot lifecycle, background tasks, and Discord interactions.
"""
import asyncio
import logging
import signal
import sys
import types
from datetime import datetime
from typing import TYPE_CHECKING, Optional, Tuple
import discord
from discord.ext import commands, tasks
from api import PanelAPI
from config import Config
from embeds import EmbedManager
from exceptions import ConfigValidationError
from metrics import ServerMetricsManager
from models import ServerData, ServerResources
if TYPE_CHECKING:
from views import ServerStatusView
logger = logging.getLogger("pterodisbot")
# Singleton instance holder
_bot_instance: Optional["PterodactylBot"] = None
def get_bot_instance() -> Optional["PterodactylBot"]:
"""Get the singleton bot instance."""
return _bot_instance
class PterodactylBot(commands.Bot):
"""
Main bot class for Pterodactyl/Pelican Panel server management.
Handles Discord interactions, embed management, and background tasks.
Manages server status embeds and user commands.
Features:
- Background status update task with smart update detection
- Persistent embed tracking with JSON storage
- Real-time server monitoring with metrics graphs
- Role-based access control
- Single-guild restriction
- Graceful shutdown handling
"""
def __init__(self, config: Config, *args, **kwargs) -> None:
"""
Initialize the Pterodactyl bot instance.
Args:
config: Validated application configuration
*args: Additional arguments for commands.Bot
**kwargs: Additional keyword arguments for commands.Bot
"""
global _bot_instance
_bot_instance = self
super().__init__(*args, **kwargs)
self.config = config
self.api = PanelAPI(config)
self.metrics_manager = ServerMetricsManager()
self.embed_manager = EmbedManager(config, self.api, self.metrics_manager)
# Server cache from panel
self.server_cache: dict = {}
# Concurrency control
self.update_lock = asyncio.Lock()
# State tracking for smart updates
# Format: {server_id: (state, cpu_usage, last_force_update)}
self.previous_states: dict = {}
logger.info("Initialized PterodactylBot instance with state tracking")
@classmethod
def get_instance(cls) -> Optional["PterodactylBot"]:
"""Get the singleton bot instance."""
return _bot_instance
async def setup_hook(self) -> None:
"""
Bot setup routine called when the bot is starting.
Initializes API client, loads saved data, and starts background tasks.
"""
logger.info("Running bot setup hook")
# Initialize API client
await self.api.initialize()
logger.info(f"Initialized {self.config.get_panel_display_name()} API client")
# Load saved embed locations
await self.embed_manager.load_locations()
# Start background update task
self.update_status.start()
logger.info("Started background status update task")
@tasks.loop(seconds=10) # UPDATE_INTERVAL = 10
async def update_status(self) -> None:
"""
Background task to update server status embeds when:
1. Server power state changes (started/stopped/restarted)
2. Significant CPU usage change (>50% difference)
3. First time seeing the server
4. Server has been running for 10 minutes (force update for uptime)
This minimizes API calls to Discord and updates while maintaining
real-time awareness of important server changes.
"""
logger.info("Starting optimized server status update cycle")
async with self.update_lock:
try:
# Fetch current server list from panel
servers = await self.api.get_servers()
if not servers:
logger.warning("No servers found in panel during update")
return
# Update our local cache with fresh server data
self.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Updated server cache with {len(servers)} servers")
# Clean up metrics for servers that no longer exist
active_server_ids = list(self.server_cache.keys())
self.metrics_manager.cleanup_old_servers(active_server_ids)
# Update statistics
update_count = 0
error_count = 0
missing_count = 0
skipped_count = 0
current_time = datetime.now().timestamp()
# Process each server we're tracking embeds for
for server_id, location in list(self.embed_manager.embed_locations.items()):
# Skip if server no longer exists in panel
if server_id not in self.server_cache:
logger.warning(f"Server {server_id} not found in cache, skipping update")
continue
server_data = self.server_cache[server_id]
server_name = server_data["attributes"]["name"]
try:
logger.debug(f"Checking status for server {server_name} ({server_id})")
# Get current server resource usage
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 data 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
)
logger.debug(
f"Added metrics data for {server_name}: "
f"CPU={cpu_usage}%, Memory={memory_usage}MB"
)
# Retrieve previous recorded state
prev_state, prev_cpu, last_force_update = self.previous_states.get(
server_id, (None, 0, None)
)
# DECISION LOGIC: Should we update the embed?
needs_update = False
# 1. Check if power state changed (most important)
if current_state != prev_state:
logger.debug(
f"Power state changed for {server_name}: "
f"{prev_state} -> {current_state}"
)
needs_update = True
# 2. Check for significant CPU change (only if server is running)
elif current_state == "running" and abs(cpu_usage - prev_cpu) > 50:
logger.debug(
f"Significant CPU change for {server_name}: "
f"{prev_cpu}% -> {cpu_usage}%"
)
needs_update = True
# 3. First time we're seeing this server (initial update)
elif prev_state is None:
logger.debug(f"First check for {server_name}, performing initial update")
needs_update = True
# 4. Force update every 10 minutes for running servers (for uptime counter)
elif current_state == "running" and (
last_force_update is None
or current_time - last_force_update >= 600
): # 10 minutes = 600 seconds
logger.debug(f"Executing 10-minute force update for running server {server_name}")
needs_update = True
last_force_update = current_time
# PERFORM UPDATE IF NEEDED
if needs_update:
# Convert to models for embed creation
server_obj = ServerData.model_validate(server_data)
resources_obj = ServerResources.model_validate(resources)
# Update the embed
success = await self.embed_manager.update_single_embed(
server_id=server_id,
location=location,
server_data=server_obj,
resources=resources_obj,
current_state=current_state,
cpu_usage=cpu_usage,
)
if success:
update_count += 1
# Update state tracking
new_last_force_update = (
last_force_update
if needs_update
and current_state == "running"
and current_time - (last_force_update or 0) >= 600
else last_force_update
)
self.previous_states[server_id] = (
current_state,
cpu_usage,
new_last_force_update,
)
else:
# No significant changes, but update tracking with current state
self.previous_states[server_id] = (
current_state,
cpu_usage,
last_force_update,
)
skipped_count += 1
logger.debug(f"No changes detected for {server_name}, skipping update")
except discord.NotFound:
# Embed message was deleted - clean up tracking
logger.warning(f"Embed for server {server_id} not found, removing from tracking")
await self.embed_manager.untrack_embed(server_id)
missing_count += 1
except Exception as e:
logger.error(f"Failed to update status for server {server_id}: {str(e)}")
error_count += 1
# Small delay between servers to avoid rate limits
await asyncio.sleep(0.5)
# Log summary of this update cycle
logger.info(
f"Update cycle complete: "
f"{update_count} updated, "
f"{skipped_count} skipped, "
f"{missing_count} missing, "
f"{error_count} errors"
)
except Exception as e:
logger.error(f"Error in update_status task: {str(e)}")
await asyncio.sleep(5)
@update_status.before_loop
async def before_update_status(self) -> None:
"""Wait for bot to be ready before starting update task."""
logger.debug("Waiting for bot readiness before starting update task")
await self.wait_until_ready()
await self.embed_manager.refresh_all_embeds()
@update_status.after_loop
async def after_update_status(self) -> None:
"""Handle update task stopping."""
if self.update_status.is_being_cancelled():
logger.info("Server status update task was cancelled")
elif self.update_status.failed():
logger.error("Server status update task failed")
async def close(self) -> None:
"""Cleanup when bot is shutting down."""
await self.embed_manager.save_locations()
if self.api:
await self.api.close()
await super().close()
async def check_allowed_guild(interaction: discord.Interaction, config: Config) -> bool:
"""
Verify that an interaction is coming from the allowed guild.
Args:
interaction: Discord interaction object
config: Application configuration
Returns:
bool: True if interaction is allowed, False otherwise
"""
if interaction.guild_id != config.allowed_guild_id:
logger.warning(
f"Command attempted from unauthorized guild {interaction.guild_id} "
f"by {interaction.user.name}"
)
await interaction.response.send_message(
"This bot is only available in a specific server.", ephemeral=True
)
return False
return True
async def setup_commands(bot: PterodactylBot) -> None:
"""
Set up all slash commands and event handlers for the bot.
Args:
bot: The bot instance
"""
config = bot.config
# ==============================================
# EVENT HANDLERS
# ==============================================
@bot.event
async def on_interaction(interaction: discord.Interaction) -> None:
"""Global interaction handler to check guild before processing any interaction."""
if interaction.guild_id != config.allowed_guild_id:
logger.debug(f"Ignoring interaction from unauthorized guild {interaction.guild_id}")
await interaction.response.send_message(
"This bot is only available in a specific server.", ephemeral=True
)
return
@bot.event
async def on_ready() -> None:
"""Called when the bot successfully connects to Discord."""
logger.info(f"Bot connected as {bot.user.name} (ID: {bot.user.id})")
try:
# Sync commands only to the allowed guild
guild = discord.Object(id=bot.config.allowed_guild_id)
bot.tree.copy_global_to(guild=guild)
synced = await bot.tree.sync(guild=guild)
logger.info(
f"Successfully synced {len(synced)} command(s) to guild {bot.config.allowed_guild_id}: "
f"{[cmd.name for cmd in synced]}"
)
except Exception as e:
logger.error(f"Command sync failed: {str(e)}")
# ==============================================
# SLASH COMMANDS
# ==============================================
@bot.tree.command(
name="server_status", description="Get a list of available game servers to control"
)
async def server_status(interaction: discord.Interaction) -> None:
"""
Slash command to display server status dashboard with interactive dropdown selection.
Workflow:
1. Validates guild permissions and defers ephemeral response
2. Refreshes server cache from Panel API
3. Calculates online/offline statistics by checking each server's state
4. Creates statistics embed with visual server status breakdown
5. Generates dropdown menu with all servers (name + description)
6. Handles user selection via ephemeral dropdown interaction
7. Creates permanent status embed in channel upon selection
8. Manages embed tracking and cleanup of previous embeds
"""
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Server status command invoked by {interaction.user.name}")
await interaction.response.defer(ephemeral=True)
try:
# Refresh server cache with current data from panel
servers = await bot.api.get_servers()
if not servers:
logger.warning("No servers found in panel")
await interaction.followup.send(
"No servers found in the panel.", ephemeral=True
)
return
bot.server_cache = {s["attributes"]["identifier"]: s for s in servers}
logger.debug(f"Refreshed server cache with {len(servers)} servers")
# Count online/offline servers
online_count = 0
offline_count = 0
for server_id, server_data in bot.server_cache.items():
resources = await bot.api.get_server_resources(server_id)
current_state = resources.get("attributes", {}).get("current_state", "offline")
if current_state == "running":
online_count += 1
else:
offline_count += 1
# Create statistics embed
stats_embed = discord.Embed(
title="🏗️ Server Status Dashboard",
description="Select a server from the dropdown below to view its detailed status and controls.",
color=discord.Color.blue(),
timestamp=datetime.now(),
)
stats_embed.add_field(
name="📊 Server Statistics",
value=f"**Total Servers:** {len(servers)}\n"
f"✅ **Online:** {online_count}\n"
f"❌ **Offline:** {offline_count}",
inline=False,
)
stats_embed.add_field(
name="ℹ️ How to Use",
value="Use the dropdown menu below to select a server. "
"The server's status embed will be posted in this channel.",
inline=False,
)
stats_embed.set_footer(text="Server status will update automatically")
# Create dropdown menu options
from views import ServerDropdown, ServerDropdownView
server_options = []
for server_id, server_data in bot.server_cache.items():
server_name = server_data["attributes"]["name"]
server_description = server_data["attributes"].get("description", "No description")
# Truncate description if too long for dropdown constraints
if len(server_description) > 50:
server_description = server_description[:47] + "..."
server_options.append(
discord.SelectOption(
label=server_name, value=server_id, description=server_description
)
)
# Send the initial dashboard embed with dropdown (ephemeral)
await interaction.followup.send(
embed=stats_embed,
view=ServerDropdownView(server_options),
ephemeral=True,
)
logger.info(f"Sent server status dashboard to {interaction.user.name} with {len(server_options)} servers")
except Exception as e:
logger.error(f"Server status command failed: {str(e)}")
await interaction.followup.send(
f"❌ Failed to load server status: {str(e)}", ephemeral=True
)
@bot.tree.command(
name="refresh_embeds", description="Refresh all server status embeds (admin only)"
)
async def refresh_embeds(interaction: discord.Interaction) -> None:
"""Slash command to refresh all server embeds."""
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Refresh embeds command invoked by {interaction.user.name}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized refresh attempt by {interaction.user.name}")
await interaction.followup.send(
"You need administrator permissions to refresh all embeds.", ephemeral=True
)
return
try:
logger.info("Starting full embed refresh per admin request")
deleted, created = await bot.embed_manager.refresh_all_embeds()
await interaction.followup.send(
f"Refreshed all embeds. Deleted {deleted} old embeds, created {created} new ones.",
ephemeral=True,
)
logger.info(f"Embed refresh completed: {deleted} deleted, {created} created")
except Exception as e:
logger.error(f"Embed refresh failed: {str(e)}")
await interaction.followup.send(f"Failed to refresh embeds: {str(e)}", ephemeral=True)
@bot.tree.command(
name="purge_embeds",
description="Permanently delete all server status embeds (admin only)",
)
async def purge_embeds(interaction: discord.Interaction) -> None:
"""
Slash command to permanently purge all server status embeds from Discord channels.
Workflow:
1. Validates administrator permissions
2. Checks if any embeds are currently tracked
3. Sends initial progress embed
4. Processes each embed sequentially with error handling
5. Updates progress embed in real-time
6. Saves cleared tracking data to disk
7. Sends final results with comprehensive statistics
"""
if not await check_allowed_guild(interaction, config):
return
logger.info(f"Purge embeds command invoked by {interaction.user.name}")
await interaction.response.defer(ephemeral=True)
# Require administrator permissions
if not interaction.user.guild_permissions.administrator:
logger.warning(f"Unauthorized purge attempt by {interaction.user.name}")
await interaction.followup.send(
"You need administrator permissions to purge all embeds.", ephemeral=True
)
return
try:
logger.info("Starting embed purge per admin request")
deleted_count = 0
not_found_count = 0
error_count = 0
total_embeds = len(bot.embed_manager.embed_locations)
if total_embeds == 0:
await interaction.followup.send(
"No embeds are currently being tracked. Nothing to purge.", ephemeral=True
)
return
# Create progress embed
progress_embed = discord.Embed(
title="🔄 Purging Server Embeds",
description=f"Processing {total_embeds} embeds...",
color=discord.Color.orange(),
)
progress_embed.add_field(name="Deleted", value="0", inline=True)
progress_embed.add_field(name="Not Found", value="0", inline=True)
progress_embed.add_field(name="Errors", value="0", inline=True)
progress_embed.set_footer(text="This may take a while...")
progress_message = await interaction.followup.send(embed=progress_embed, ephemeral=True)
# Process each tracked embed
for server_id, location in list(bot.embed_manager.embed_locations.items()):
try:
channel = bot.get_channel(int(location["channel_id"]))
if channel:
try:
message = await channel.fetch_message(int(location["message_id"]))
await message.delete()
deleted_count += 1
logger.debug(f"Successfully purged embed for server {server_id}")
except discord.NotFound:
not_found_count += 1
logger.debug(f"Embed for server {server_id} already deleted")
except discord.Forbidden:
error_count += 1
logger.error(f"Permission denied when deleting embed for server {server_id}")
except Exception as e:
error_count += 1
logger.error(f"Error deleting embed for server {server_id}: {str(e)}")
else:
not_found_count += 1
logger.warning(f"Channel not found for server {server_id}")
# Remove from tracking immediately
await bot.embed_manager.untrack_embed(server_id)
# Update progress every 5 embeds or for the last one
processed = deleted_count + not_found_count + error_count
if processed % 5 == 0 or processed == total_embeds:
progress_embed.description = f"Processed {processed}/{total_embeds} embeds"
progress_embed.set_field_at(0, name="Deleted", value=str(deleted_count), inline=True)
progress_embed.set_field_at(1, name="Not Found", value=str(not_found_count), inline=True)
progress_embed.set_field_at(2, name="Errors", value=str(error_count), inline=True)
await progress_message.edit(embed=progress_embed)
# Small delay to avoid rate limits
await asyncio.sleep(0.3)
except Exception as e:
error_count += 1
logger.error(f"Unexpected error processing server {server_id}: {str(e)}")
# Create results embed
result_embed = discord.Embed(
title="✅ Embed Purge Complete",
color=discord.Color.green(),
timestamp=datetime.now(),
)
result_embed.add_field(name="Total Tracked", value=str(total_embeds), inline=True)
result_embed.add_field(name="✅ Successfully Deleted", value=str(deleted_count), inline=True)
result_embed.add_field(name="❌ Already Missing", value=str(not_found_count), inline=True)
result_embed.add_field(name="⚠️ Errors", value=str(error_count), inline=True)
result_embed.add_field(
name="📊 Success Rate",
value=f"{((deleted_count + not_found_count) / total_embeds * 100):.1f}%",
inline=True,
)
result_embed.set_footer(text="Embed tracking file has been cleared")
await progress_message.edit(embed=result_embed)
logger.info(f"Embed purge completed: {deleted_count} deleted, {not_found_count} not found, {error_count} errors")
except Exception as e:
logger.error(f"Embed purge failed: {str(e)}")
await interaction.followup.send(f"❌ Failed to purge embeds: {str(e)}", ephemeral=True)
# ==============================================
# SYSTEM SIGNAL HANDLERS
# ==============================================
def handle_sigint(signum: int, frame: types.FrameType) -> None:
"""
Handle SIGINT signals (Ctrl+C) by initiating graceful shutdown.
Args:
signum: The signal number (signal.SIGINT)
frame: Current stack frame (unused but required by signal handler signature)
"""
logger.info("Received SIGINT (Ctrl+C), initiating graceful shutdown...")
raise KeyboardInterrupt
def handle_sigterm(signum: int, frame: types.FrameType) -> None:
"""
Handle SIGTERM signals (container stop) by initiating graceful shutdown.
Args:
signum: The signal number (signal.SIGTERM)
frame: Current stack frame (unused but required by signal handler signature)
"""
logger.info("Received SIGTERM (container stop), initiating graceful shutdown...")
raise KeyboardInterrupt
# ==============================================
# BOT STARTUP
# ==============================================
async def run_bot(config: Config) -> None:
"""
Main async entry point for the bot application.
Args:
config: Validated application configuration
"""
logger.info("Starting bot initialization")
# Create bot instance
intents = discord.Intents.default()
intents.message_content = True
bot = PterodactylBot(config, command_prefix="!", intents=intents)
# Set up commands and events
await setup_commands(bot)
# Register signal handlers
signal.signal(signal.SIGINT, handle_sigint)
signal.signal(signal.SIGTERM, handle_sigterm)
logger.info("System signal handlers registered")
try:
await bot.start(config.discord_token)
except KeyboardInterrupt:
logger.info("Bot shutting down")
except Exception as e:
logger.error(f"Bot crashed: {str(e)}")
sys.exit(1)
finally:
logger.info("Bot shutdown initiated - performing cleanup")
await bot.close()
logger.info("Bot shutdown complete")