""" A guild configuration setting available for editing via bot commands. """ from __future__ import annotations from typing import Any, Optional, Type from discord import Interaction, Permissions from discord.app_commands.commands import Command, Group from discord.ext.commands import Bot from config import CONFIG from rocketbot.cogs.basecog import BaseCog from rocketbot.storage import Storage # def _fix_command(command: Command) -> None: # """ # HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to # supply the context argument. This removes that argument from the list. # """ # params = command.params # del params['context'] # command.params = params class CogSetting: """ Describes a configuration setting for a guild that can be edited by the mods of those guilds. BaseCog can generate "get" and "set" commands (or "enable" and "disable" commands for boolean values) automatically, reducing the boilerplate of generating commands manually. Offers simple validation rules. """ def __init__(self, name: str, datatype: Optional[Type], brief: Optional[str] = None, description: Optional[str] = None, usage: Optional[str] = None, min_value: Optional[Any] = None, max_value: Optional[Any] = None, enum_values: Optional[set[Any]] = None): """ Parameters ---------- name: str Setting identifier. Must follow variable naming conventions. datatype: Optional[Type] Datatype of the setting. E.g. int, float, str brief: Optional[str] Description of the setting, starting with lower case. Will be inserted into phrases like "Sets " and "Gets " min_value: Optional[Any] Smallest allowable value. Must be of the same datatype as the value. None for no minimum. max_value: Optional[Any] Largest allowable value. None for no maximum. enum_values: Optional[set[Any]] Set of allowed values. None if unconstrained. """ self.name: str = name self.datatype: Type = datatype self.brief: Optional[str] = brief self.description: str = description or '' # Can't be None self.usage: Optional[str] = usage self.min_value: Optional[Any] = min_value self.max_value: Optional[Any] = max_value self.enum_values: Optional[set[Any]] = enum_values if self.enum_values or self.min_value is not None or self.max_value is not None: self.description += '\n' if self.enum_values: allowed_values = '`' + ('`, `'.join(enum_values)) + '`' self.description += f'\nAllowed values: {allowed_values}' if self.min_value is not None: self.description += f'\nMin value: {self.min_value}' if self.max_value is not None: self.description += f'\nMax value: {self.max_value}' if self.usage is None: self.usage = f'<{self.name}>' def validate_value(self, new_value: Any) -> None: """ Checks if a value is legal for this setting. Raises a ValueError if not. """ if self.min_value is not None and new_value < self.min_value: raise ValueError(f'`{self.name}` must be >= {self.min_value}') if self.max_value is not None and new_value > self.max_value: raise ValueError(f'`{self.name}` must be <= {self.max_value}') if self.enum_values is not None and new_value not in self.enum_values: allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`' raise ValueError(f'`{self.name}` must be one of {allowed_values}') def set_up(self, cog: BaseCog, bot: Bot) -> None: """ Sets up getter and setter commands for this setting. This should usually only be called by BaseCog. """ if self.name in ('enabled', 'is_enabled'): bot.tree.add_command(self.__make_enable_command(cog)) bot.tree.add_command(self.__make_disable_command(cog)) else: bot.tree.add_command(self.__make_getter_command(cog)) bot.tree.add_command(self.__make_setter_command(cog)) def __make_getter_command(self, cog: BaseCog) -> Command: setting: CogSetting = self setting_name = setting.name if cog.config_prefix is not None: setting_name = f'{cog.config_prefix}.{setting_name}' async def getter(cog0: BaseCog, interaction: Interaction) -> None: key = f'{cog0.__class__.__name__}.{setting.name}' value = Storage.get_config_value(interaction.guild, key) if value is None: value = cog0.get_cog_default(setting.name) await interaction.response.send_message( f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`', ephemeral=True ) else: await interaction.response.send_message( f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`', ephemeral=True ) command = Command( name=setting_name, description=setting.description, callback=getter, parent=CogSetting.__get_group ) return command def __make_setter_command(self, cog: BaseCog) -> Command: setting: CogSetting = self setting_name = setting.name if cog.config_prefix is not None: setting_name = f'{cog.config_prefix}.{setting_name}' async def setter(cog0: BaseCog, interaction: Interaction, new_value) -> None: try: setting.validate_value(new_value) except ValueError as ve: await interaction.response.send_message( f'{CONFIG["failure_emoji"]} {ve}', ephemeral=True ) return key = f'{cog0.__class__.__name__}.{setting.name}' Storage.set_config_value(interaction.guild, key, new_value) await interaction.response.send_message( f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`', ephemeral=True ) await cog0.on_setting_updated(interaction.guild, setting) cog0.log(interaction.guild, f'{interaction.message.author.name} set {key} to {new_value}') type_str: str = 'any' if self.datatype == int: if self.min_value is not None or self.max_value is not None: type_str = f'discord.app_commands.Range[int, {self.min_value}, {self.max_value}]' else: type_str = 'int' elif setting.datatype == str: if self.enum_values is not None: value_list = '"' + '", "'.join(self.enum_values) + '"' type_str = f'typing.Literal[{value_list}]' else: type_str = 'str' elif setting.datatype == bool: type_str = f'typing.Literal["false", "true"]' setter.__doc__ = f"""Sets {self.description}. Parameters ---------- cog: discord.ext.commands.Cog interaction: discord.Interaction new_value: {type_str} """ command = Command( name=f'{setting.name}', description=setting.description, callback=setter, parent=CogSetting.__set_group, ) # HACK: Passing `cog` in init gets ignored and set to `None` so set after. # This ensures the callback is passed the cog as `self` argument. # command.cog = cog # _fix_command(command) return command def __make_enable_command(self, cog: BaseCog) -> Command: setting: CogSetting = self async def enabler(cog0: BaseCog, interaction: Interaction) -> None: key = f'{cog0.__class__.__name__}.{setting.name}' Storage.set_config_value(interaction.guild, key, True) await interaction.response.send_message( f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.', ephemeral=True ) await cog0.on_setting_updated(interaction.guild, setting) cog0.log(interaction.guild, f'{interaction.message.author.name} enabled {cog0.__class__.__name__}') command = Command( name=cog.config_prefix, description=setting.description, callback=enabler, parent=CogSetting.__enable_group, ) # command.cog = cog # _fix_command(command) return command def __make_disable_command(self, cog: BaseCog) -> Command: setting: CogSetting = self async def disabler(cog0: BaseCog, interaction: Interaction) -> None: key = f'{cog0.__class__.__name__}.{setting.name}' Storage.set_config_value(interaction.guild, key, False) await interaction.response.send_message( f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.', ephemeral=True ) await cog0.on_setting_updated(interaction.guild, setting) cog0.log(interaction.guild, f'{interaction.message.author.name} disabled {cog0.__class__.__name__}') command = Command( name=cog.config_prefix, description=setting.description, callback=disabler, parent=CogSetting.__disable_group, ) # command.cog = cog # _fix_command(command) return command __has_set_up_base_commands: bool = False __set_group: Group __get_group: Group __enable_group: Group __disable_group: Group @classmethod def set_up_all(cls, cog: BaseCog, bot: Bot, settings: list['CogSetting']) -> None: """ Sets up editing commands for a list of CogSettings and adds them to a cog. If the cog has a command Group, commands will be added to it. Otherwise, they will be added at the top level. """ cls.__set_up_base_commands(bot) for setting in settings: setting.set_up(cog, bot) @classmethod def __set_up_base_commands(cls, bot: Bot) -> None: if cls.__has_set_up_base_commands: return cls.__has_set_up_base_commands = True cls.__set_group = Group( name='set', description='Sets a bot configuration value for this guild', default_permissions=Permissions.manage_messages ) cls.__get_group = Group( name='get', description='Shows a configured bot value for this guild', default_permissions=Permissions.manage_messages ) cls.__enable_group = Group( name='enable', description='Enables a set of bot functionality for this guild', default_permissions=Permissions.manage_messages ) cls.__disable_group = Group( name='disable', description='Disables a set of bot functionality for this guild', default_permissions=Permissions.manage_messages ) bot.tree.add_command(cls.__set_group) bot.tree.add_command(cls.__get_group) bot.tree.add_command(cls.__enable_group) bot.tree.add_command(cls.__disable_group)