""" 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 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_id: Discord server ID where bot operates 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_id: int = self._config.getint("Discord", "AllowedGuildID") 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", "AllowedGuildID"] for key in required_discord: if not self._config.get("Discord", key, fallback=None): errors.append(f"Missing required Discord config value: {key}") # Validate AllowedGuildID is a valid integer try: guild_id = self._config.getint("Discord", "AllowedGuildID", fallback=0) if guild_id <= 0: errors.append("AllowedGuildID must be a positive integer") except ValueError: errors.append("AllowedGuildID must be a valid integer") # 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_") ) @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_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", ""), } config["Discord"] = { "Token": os.environ.get("DISCORD_TOKEN", ""), "AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""), } with open(output_path, "w") as f: config.write(f) logger.info(f"Generated config.ini from environment variables at {output_path}")