diff --git a/.gitignore b/.gitignore index 08038cc..7d091c4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ logs/ *.pyc .env tests/ -.env.* \ No newline at end of file +.env.* +*-cwd diff --git a/bot/core/app.py b/bot/core/app.py index 50531d9..796c9b0 100644 --- a/bot/core/app.py +++ b/bot/core/app.py @@ -50,6 +50,7 @@ def __init__(self, guild_id: int) -> None: self.guild = discord.Object(id=guild_id) async def setup_hook(self) -> None: + self.tree.on_error = self.on_tree_error self.tree.clear_commands(guild=None) await self.tree.sync() diff --git a/bot/core/config.py b/bot/core/config.py index e3caaea..a9f373b 100644 --- a/bot/core/config.py +++ b/bot/core/config.py @@ -25,7 +25,10 @@ def _project_root() -> Path: def load_env() -> AppEnv: root = _project_root() log = logging.getLogger(__name__) - app_env = os.getenv("APP_ENV", "dev").strip().lower() + try: + app_env = os.getenv("APP_ENV", "dev").strip().lower() + except Exception as e: + log.error(f"Error reading APP_ENV environment variable: {e}") if app_env not in ("dev", "prod"): raise ValueError("APP_ENV environment variable must be 'dev' or 'prod'.") @@ -44,13 +47,19 @@ def load_env() -> AppEnv: if path.exists(): log.info(f"Loaded environment variables from {path}") load_dotenv(dotenv_path=path, override=override) - break - load_dotenv() - - discord_token = os.getenv("DISCORD_TOKEN").strip() - guild_id_str = os.getenv("GUILD_ID").strip() - config_path_str = os.getenv("CONFIG_PATH", "config.toml").strip() - staff_roles_ids_str = os.getenv("STAFF_ROLES_IDS", "").strip() + else: + load_dotenv() + + try: + discord_token = os.getenv("DISCORD_TOKEN").strip() + guild_id_str = os.getenv("GUILD_ID").strip() + config_path_str = os.getenv("CONFIG_PATH", "config.toml").strip() + staff_roles_ids_str = os.getenv("STAFF_ROLES_IDS", "").strip() + except Exception as e: + log.error(f"Error reading environment variables: {e}") + raise ValueError( + "Error reading environment variables. Please check your .env files and environment settings." + ) from e if not discord_token: log.error("DISCORD_TOKEN environment variable is missing.") diff --git a/bot/core/loader.py b/bot/core/loader.py index daa3a6e..5e2e1cc 100644 --- a/bot/core/loader.py +++ b/bot/core/loader.py @@ -2,14 +2,16 @@ import importlib import logging -from typing import Dict, List +from typing import Dict, List, Tuple log = logging.getLogger(__name__) + def _command_qualified_keys(tree) -> set[str]: return {cmd.name for cmd in tree.get_commands()} -def load_features(tree, config: Dict) -> List: + +def load_features(tree, config: Dict) -> Tuple: """Dynamically load and register features based on config, return dict of loaded modules and dict of failed ones with error messages Each feature module must be located at features/{slug}/feature.py and define: @@ -26,11 +28,11 @@ def load_features(tree, config: Dict) -> List: """ params_needed: List[str] = ["slug", "name", "description", "version", "author", "requires_config", "permissions"] enabled: List[str] = config["enabled_features"] - features_config : Dict = config.get("features", {}) - - loaded : Dict[str, object] = {} - failed : Dict[str, str] = {} - + features_config: Dict = config.get("features", {}) + + loaded: Dict[str, object] = {} + failed: Dict[str, str] = {} + for slug in enabled: module_path = f"features.{slug}.feature" try: @@ -39,73 +41,74 @@ def load_features(tree, config: Dict) -> List: failed[slug] = "ImportError: " + str(e) log.error(f"Failed to import feature module {module_path}: {e}") continue - + if not hasattr(module, "FEATURE"): failed[slug] = "Missing FEATURE dictionary" log.error(f"Feature module {module_path} is missing FEATURE dictionary.") continue - + if not hasattr(module, "register"): failed[slug] = "Missing register function" log.error(f"Feature module {module_path} is missing register function.") continue - - feature_info : Dict = module.FEATURE - + + feature_info: Dict = module.FEATURE + if not isinstance(feature_info, dict): failed[slug] = "FEATURE is not a dictionary" log.error(f"Feature module {module_path} FEATURE is not a dictionary.") continue - + if not feature_info.get("slug"): failed[slug] = "FEATURE missing slug" log.error(f"Feature module {module_path} FEATURE dictionary missing slug.") continue - + if not all(param in feature_info for param in params_needed): missing_params = [param for param in params_needed if param not in feature_info] failed[slug] = "Missing parameters: " + ", ".join(missing_params) - log.error(f"Feature module {module_path} FEATURE dictionary missing parameters: {', '.join(missing_params)}.") + log.error( + f"Feature module {module_path} FEATURE dictionary missing parameters: {', '.join(missing_params)}." + ) continue - + if feature_info.get("slug") != slug: failed[slug] = "Slug mismatch" log.error(f"Feature module {module_path} slug mismatch: expected {slug}, got {feature_info.get('slug')}.") continue - - feature_cfg : Dict = features_config.get(slug, {}) - + + feature_cfg: Dict = features_config.get(slug, {}) + if feature_info.get("requires_config", True) and not feature_cfg: failed[slug] = "Missing required configuration" log.error(f"Feature module {module_path} requires configuration but none was provided.") continue - + try: - + before = _command_qualified_keys(tree) log.debug(f"Commands before loading feature {slug}: {before}") + before_cmds = {cmd.name: cmd for cmd in tree.get_commands()} module.register(tree, feature_cfg) - - after = _command_qualified_keys(tree) - log.debug(f"Commands after loading feature {slug}: {after}") - added = after - before - duplicates = before & added + after_cmds = {cmd.name: cmd for cmd in tree.get_commands()} + duplicates = { + name for name, cmd in after_cmds.items() if name in before_cmds and before_cmds[name] is not cmd + } if duplicates: failed[slug] = "Command name conflict: " + ", ".join(sorted(duplicates)) log.error(f"Feature module {module_path} command name conflict: {', '.join(sorted(duplicates))}.") - - for cmd_name in added: + + for cmd_name in duplicates: try: tree.remove_command(cmd_name) except Exception as e: - pass + log.error(f"Failed to remove command {cmd_name} after conflict in feature {slug}: {e}") continue - - + loaded[slug] = module log.info(f"Successfully loaded feature module {module_path}.") except Exception as e: failed[slug] = "RegistrationError: " + str(e) log.error(f"Failed to register feature module {module_path}: {e}") - - return loaded, failed \ No newline at end of file + + return loaded, failed diff --git a/features/say/feature.py b/features/say/feature.py index 1418f02..b42134a 100644 --- a/features/say/feature.py +++ b/features/say/feature.py @@ -1,26 +1,36 @@ import discord from discord import app_commands +from bot.core.checks import is_staff + FEATURE = { "slug": "say", # The unique identifier for the feature - "name": "Say Feature", # The display name of the feature - "description": "A feature that allows the bot to say messages.", # A brief description of the feature - "version": "1.0.0", # The version of the feature - "author": "Tryno", # The author of the feature - "requires_config": True, # Whether the feature requires configuration - "permissions": ["send_messages", "embed_links"] # Required permissions + "name": "Say Feature", # The display name of the feature + "description": "A feature that allows the bot to say messages.", # A brief description of the feature + "version": "1.0.0", # The version of the feature + "author": "Tryno", # The author of the feature + "requires_config": True, # Whether the feature requires configuration + "permissions": ["send_messages", "embed_links"], # Required permissions } -def register(tree : app_commands.CommandTree, config): # Register the feature's commands with the bot's command tree - @tree.command(name=FEATURE["slug"], description=FEATURE["description"]) # Define a new command in the command tree - @app_commands.describe(message="The message for the bot to say.") # Describe the command parameter - async def say_command(interaction: discord.Interaction, message: str): # The command function that will be called when the command is invoked + +def register(tree: app_commands.CommandTree, config): # Register the feature's commands with the bot's command tree + @tree.command(name=FEATURE["slug"], description=FEATURE["description"]) # Define a new command in the command tree + @is_staff() + @app_commands.describe(message="The message for the bot to say.") # Describe the command parameter + async def say_command( + interaction: discord.Interaction, message: str + ): # The command function that will be called when the command is invoked """ Make the bot say a message. Arguments: interaction: The interaction object. message: The message to be sent by the bot. """ - ephemeral_default = bool(config.get("ephemeral_default")) if isinstance(config, dict) else False # Get ephemeral default from config - await interaction.response.send_message(message, ephemeral=ephemeral_default) # Respond with the provided message - # Add any additional logic for your custom feature here \ No newline at end of file + ephemeral_default = ( + bool(config.get("ephemeral_default")) if isinstance(config, dict) else False + ) # Get ephemeral default from config + await interaction.response.send_message( + message, ephemeral=ephemeral_default + ) # Respond with the provided message + # Add any additional logic for your custom feature here diff --git a/features/utils/feature.py b/features/utils/feature.py index bdd40f2..a6176b7 100644 --- a/features/utils/feature.py +++ b/features/utils/feature.py @@ -8,19 +8,23 @@ "version": "1.0.0", "author": "Tryno", "requires_config": True, - "permissions": ["send_messages", "embed_links"] + "permissions": ["send_messages", "embed_links"], } + def register(tree: app_commands.CommandTree, config): group = app_commands.Group(name=FEATURE["slug"], description="Help commands") - + @group.command(name="help", description="List all available commands") async def help_commands(interaction: discord.Interaction): commands_list = [] - print(f"Registering help command with config: {config.get('guild_id')}") - discord_guild = discord.utils.get(interaction.client.guilds, id=config.get("guild_id") if isinstance(config, dict) else None) + discord_guild = ( + discord.utils.get(interaction.client.guilds, id=interaction.guild_id) if isinstance(config, dict) else None + ) if not discord_guild: - await interaction.response.send_message("❌ Impossible de récupérer les commandes pour ce serveur.", ephemeral=True) + await interaction.response.send_message( + "❌ Impossible de récupérer les commandes pour ce serveur.", ephemeral=True + ) return for cmd in tree.get_commands(guild=discord_guild): if isinstance(cmd, app_commands.Group): @@ -28,9 +32,10 @@ async def help_commands(interaction: discord.Interaction): commands_list.append(f"/{cmd.name} {subcmd.name} - {subcmd.description}") else: commands_list.append(f"/{cmd.name} - {cmd.description}") - + help_message = "Voici la liste des commandes disponibles :\n\n" + "\n".join(commands_list) - await interaction.response.send_message(help_message, ephemeral=config.get("ephemeral_default", True) if isinstance(config, dict) else True) - + await interaction.response.send_message( + help_message, ephemeral=config.get("ephemeral_default", True) if isinstance(config, dict) else True + ) - tree.add_command(group) \ No newline at end of file + tree.add_command(group)