Update: Major code refactor for modular extensibility
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
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
This commit is contained in:
1 parent
d891b21ffd
commit
346e6d9604
13 files changed
+3266
-443
No files matched your search
@@ -0,0 +1,707 @@
|
||||
"""
|
||||
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")
|
||||
Reference in new issue
Block a user