""" 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}")