Files
Pterodisbot/config.py
k.eaven bec06bd909 feat: Multi-guild support implementation
- 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
2026-10-30 10:37:13 +00:00

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