- Added multi-guild support with comma-separated AllowedGuildIDs config - Per-guild embed tracking with isolated state - Per-server action locking to prevent race conditions across guilds - Cross-guild embed updates after power actions - Debug logging for embed update triggers - Fixed resource usage field formatting (brackets now properly closed) - Updated config.ini.sample, generate_config.py for backward compatibility - Updated README.md with multi-guild documentation - Updated tests for multi-guild support
244 lines
9.2 KiB
Python
244 lines
9.2 KiB
Python
"""
|
|
Configuration Management Module
|
|
|
|
Handles loading, validation, and access to application configuration.
|
|
Supports both config.ini files and environment variable overrides.
|
|
"""
|
|
|
|
import configparser
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import List, Optional
|
|
|
|
from exceptions import ConfigValidationError
|
|
|
|
logger = logging.getLogger("pterodisbot")
|
|
|
|
|
|
class Config:
|
|
"""
|
|
Application configuration manager.
|
|
|
|
Loads configuration from config.ini file, validates all required values,
|
|
and provides typed access to configuration parameters.
|
|
Supports both Pterodactyl (ptlc_/ptla_) and Pelican Panel (pacc_/papp_)
|
|
API key formats.
|
|
|
|
Attributes:
|
|
pterodactyl_url: URL of the panel (must include http:// or https://)
|
|
client_api_key: Client API key for server operations
|
|
application_api_key: Application API key for admin operations
|
|
discord_token: Discord bot token
|
|
allowed_guild_ids: List of Discord server IDs where bot operates
|
|
allowed_guild_id: First guild ID (backward compatibility)
|
|
required_role: Role name required for server control permissions
|
|
update_interval: Background task update interval in seconds
|
|
embed_storage_path: Path to embed locations JSON file
|
|
"""
|
|
|
|
def __init__(self, config_path: str = "config.ini") -> None:
|
|
"""
|
|
Initialize configuration from file.
|
|
|
|
Args:
|
|
config_path: Path to config.ini file
|
|
|
|
Raises:
|
|
ConfigValidationError: If required values are missing or invalid
|
|
"""
|
|
self._config = configparser.ConfigParser()
|
|
self._config_path = Path(config_path)
|
|
|
|
if not self._config_path.exists():
|
|
raise ConfigValidationError(f"Configuration file not found: {config_path}")
|
|
|
|
self._config.read(self._config_path)
|
|
self._validate()
|
|
|
|
# Validated configuration values
|
|
self.pterodactyl_url: str = self._config.get("Pterodactyl", "PanelURL").rstrip("/")
|
|
self.client_api_key: str = self._config.get("Pterodactyl", "ClientAPIKey")
|
|
self.application_api_key: str = self._config.get("Pterodactyl", "ApplicationAPIKey")
|
|
self.discord_token: str = self._config.get("Discord", "Token")
|
|
self.allowed_guild_ids: List[int] = self._parse_allowed_guild_ids()
|
|
# Backward compatibility: first guild ID
|
|
self.allowed_guild_id: int = self.allowed_guild_ids[0] if self.allowed_guild_ids else 0
|
|
self.required_role: str = "Game Server User"
|
|
self.update_interval: int = 10
|
|
self.embed_storage_path: Path = Path("./embed/embed_locations.json")
|
|
|
|
logger.info("Configuration loaded and validated successfully")
|
|
|
|
def _validate(self) -> None:
|
|
"""
|
|
Validate all required configuration values.
|
|
|
|
Checks:
|
|
- Required sections exist
|
|
- All required keys are present
|
|
- AllowedGuildID is a positive integer
|
|
- API keys have correct prefixes (ptlc_/pacc_ and ptla_/papp_)
|
|
- PanelURL has valid protocol
|
|
|
|
Raises:
|
|
ConfigValidationError: If any validation fails
|
|
"""
|
|
errors = []
|
|
|
|
# Validate Pterodactyl section
|
|
if not self._config.has_section("Pterodactyl"):
|
|
errors.append("Missing [Pterodactyl] section in config.ini")
|
|
else:
|
|
required_ptero = ["PanelURL", "ClientAPIKey", "ApplicationAPIKey"]
|
|
for key in required_ptero:
|
|
if not self._config.get("Pterodactyl", key, fallback=None):
|
|
errors.append(f"Missing required Pterodactyl config value: {key}")
|
|
|
|
# Validate Discord section
|
|
if not self._config.has_section("Discord"):
|
|
errors.append("Missing [Discord] section in config.ini")
|
|
else:
|
|
required_discord = ["Token"] # Guild ID validated separately below
|
|
for key in required_discord:
|
|
if not self._config.get("Discord", key, fallback=None):
|
|
errors.append(f"Missing required Discord config value: {key}")
|
|
|
|
# Validate AllowedGuildIDs (comma-separated list)
|
|
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
|
|
# Backward compatibility: also check for singular AllowedGuildID
|
|
if not allowed_guild_ids_str:
|
|
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
|
|
|
|
if not allowed_guild_ids_str:
|
|
errors.append("Missing required Discord config value: AllowedGuildIDs (or AllowedGuildID)")
|
|
else:
|
|
try:
|
|
guild_ids = [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
|
|
if not guild_ids:
|
|
errors.append("AllowedGuildIDs must contain at least one guild ID")
|
|
for gid in guild_ids:
|
|
if gid <= 0:
|
|
errors.append(f"Invalid guild ID: {gid} (must be positive integer)")
|
|
except ValueError:
|
|
errors.append("AllowedGuildIDs must be comma-separated integers")
|
|
|
|
# Validate API keys have correct prefixes (Pterodactyl and Pelican Panel)
|
|
client_key = self._config.get("Pterodactyl", "ClientAPIKey", fallback="")
|
|
if client_key and not (client_key.startswith("ptlc_") or client_key.startswith("pacc_")):
|
|
errors.append("ClientAPIKey should start with 'ptlc_' (Pterodactyl) or 'pacc_' (Pelican Panel)")
|
|
|
|
app_key = self._config.get("Pterodactyl", "ApplicationAPIKey", fallback="")
|
|
if app_key and not (app_key.startswith("ptla_") or app_key.startswith("papp_")):
|
|
errors.append("ApplicationAPIKey should start with 'ptla_' (Pterodactyl) or 'papp_' (Pelican Panel)")
|
|
|
|
# Validate PanelURL is a valid URL
|
|
panel_url = self._config.get("Pterodactyl", "PanelURL", fallback="")
|
|
if panel_url and not (panel_url.startswith("http://") or panel_url.startswith("https://")):
|
|
errors.append("PanelURL must start with http:// or https://")
|
|
|
|
if errors:
|
|
error_msg = "Configuration validation failed:\n- " + "\n- ".join(errors)
|
|
logger.error(error_msg)
|
|
raise ConfigValidationError(error_msg)
|
|
|
|
logger.info("Configuration validation passed")
|
|
|
|
@property
|
|
def is_pelican_panel(self) -> bool:
|
|
"""Check if using Pelican Panel API keys (pacc_/papp_ prefixes)."""
|
|
return (
|
|
self.client_api_key.startswith("pacc_")
|
|
or self.application_api_key.startswith("papp_")
|
|
)
|
|
|
|
def _parse_allowed_guild_ids(self) -> List[int]:
|
|
"""Parse comma-separated guild IDs from config.
|
|
|
|
Supports both new 'AllowedGuildIDs' and legacy 'AllowedGuildID' keys.
|
|
|
|
Returns:
|
|
List of guild IDs as integers
|
|
"""
|
|
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildIDs", fallback="")
|
|
if not allowed_guild_ids_str:
|
|
# Backward compatibility
|
|
allowed_guild_ids_str = self._config.get("Discord", "AllowedGuildID", fallback="")
|
|
|
|
if not allowed_guild_ids_str:
|
|
return []
|
|
|
|
return [int(gid.strip()) for gid in allowed_guild_ids_str.split(",") if gid.strip()]
|
|
|
|
@property
|
|
def panel_type(self) -> str:
|
|
"""Return the detected panel type."""
|
|
return "Pelican Panel" if self.is_pelican_panel else "Pterodactyl"
|
|
|
|
def get_panel_display_name(self) -> str:
|
|
"""Get display name for the panel type."""
|
|
return "Pelican Panel" if self.is_pelican_panel else "Pterodactyl"
|
|
|
|
@classmethod
|
|
def get_instance(cls) -> Optional["Config"]:
|
|
"""Get the singleton config instance."""
|
|
return cls._instance
|
|
|
|
|
|
# Singleton instance for access from views
|
|
Config._instance = None
|
|
|
|
|
|
def load_config(config_path: str = "config.ini") -> Config:
|
|
"""
|
|
Load and validate configuration from file.
|
|
|
|
Args:
|
|
config_path: Path to config.ini file
|
|
|
|
Returns:
|
|
Config: Validated configuration instance
|
|
|
|
Raises:
|
|
ConfigValidationError: If configuration is invalid
|
|
"""
|
|
config = Config(config_path)
|
|
Config._instance = config
|
|
return config
|
|
|
|
|
|
def generate_config_from_env(output_path: str = "config.ini") -> None:
|
|
"""
|
|
Generate config.ini from environment variables.
|
|
|
|
Useful for Docker/container deployments where config comes from env vars.
|
|
|
|
Required environment variables:
|
|
DISCORD_TOKEN, ALLOWED_GUILD_IDS (or ALLOWED_GUILD_ID), PANEL_URL,
|
|
CLIENT_API_KEY, APPLICATION_API_KEY
|
|
|
|
Args:
|
|
output_path: Path to write generated config.ini
|
|
"""
|
|
config = configparser.ConfigParser()
|
|
|
|
config["Pterodactyl"] = {
|
|
"PanelURL": os.environ.get("PANEL_URL", ""),
|
|
"ClientAPIKey": os.environ.get("CLIENT_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"] = {
|
|
"Token": os.environ.get("DISCORD_TOKEN", ""),
|
|
"AllowedGuildIDs": allowed_guild_ids,
|
|
}
|
|
|
|
with open(output_path, "w") as f:
|
|
config.write(f)
|
|
|
|
logger.info(f"Generated config.ini from environment variables at {output_path}") |