Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ logs/
*.pyc
.env
tests/
.env.*
.env.*
*-cwd
1 change: 1 addition & 0 deletions bot/core/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
25 changes: 17 additions & 8 deletions bot/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'.")
Expand All @@ -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.")
Expand Down
69 changes: 36 additions & 33 deletions bot/core/loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand All @@ -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

return loaded, failed
36 changes: 23 additions & 13 deletions features/say/feature.py
Original file line number Diff line number Diff line change
@@ -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
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
23 changes: 14 additions & 9 deletions features/utils/feature.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,34 @@
"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):
for subcmd in cmd.commands:
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)
tree.add_command(group)