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
This commit is contained in:
k.eaven committed 2026-10-30 10:37:13 +00:00
1 parent e68b248033
commit bec06bd909
10 files changed
+802 -408

No files matched your search

+50 -13
View File
@@ -9,7 +9,7 @@ import configparser
import logging
import os
from pathlib import Path
from typing import Optional
from typing import List, Optional
from exceptions import ConfigValidationError
@@ -30,7 +30,8 @@ class Config:
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
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
@@ -60,7 +61,9 @@ class Config:
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.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")
@@ -96,18 +99,29 @@ class Config:
if not self._config.has_section("Discord"):
errors.append("Missing [Discord] section in config.ini")
else:
required_discord = ["Token", "AllowedGuildID"]
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 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 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="")
@@ -138,6 +152,24 @@ class Config:
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."""
@@ -182,7 +214,7 @@ def generate_config_from_env(output_path: str = "config.ini") -> None:
Useful for Docker/container deployments where config comes from env vars.
Required environment variables:
DISCORD_TOKEN, ALLOWED_GUILD_ID, PANEL_URL,
DISCORD_TOKEN, ALLOWED_GUILD_IDS (or ALLOWED_GUILD_ID), PANEL_URL,
CLIENT_API_KEY, APPLICATION_API_KEY
Args:
@@ -196,9 +228,14 @@ def generate_config_from_env(output_path: str = "config.ini") -> None:
"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", ""),
"AllowedGuildID": os.environ.get("ALLOWED_GUILD_ID", ""),
"AllowedGuildIDs": allowed_guild_ids,
}
with open(output_path, "w") as f: