""" A guild configuration setting available for editing via bot commands. """ from datetime import timedelta from typing import Any, Optional, Type, Literal from discord import Interaction, Permissions from discord.app_commands import Range, Transform, describe from discord.app_commands.commands import Command, Group, CommandCallback, rename from discord.ext.commands import Bot from config import CONFIG from rocketbot.storage import Storage from rocketbot.utils import bot_log, TimeDeltaTransformer, MOD_PERMISSIONS, dump_stacktrace # 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. """ permissions: Permissions = Permissions(Permissions.manage_messages.flag) 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 ". description: Optional[str] Long-form description. Min, max, and enum values will be appended to the end, so does not need to include these. usage: Optional[str] Description of the value argument in a set command, e.g. "" 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: value_list = '`' + ('`, `'.join(self.enum_values)) + '`' self.description += f' (Permitted values: {value_list})' elif self.min_value is not None and self.max_value is not None: self.description += f' (Value must be between `{self.min_value}` and `{self.max_value}`)' elif self.min_value is not None: self.description += f' (Minimum value: {self.min_value})' elif self.max_value is not None: self.description += f' (Maximum value: {self.max_value})' 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'): self.__enable_group.add_command(self.__make_enable_command(cog)) self.__disable_group.add_command(self.__make_disable_command(cog)) else: self.__get_group.add_command(self.__make_getter_command(cog)) self.__set_group.add_command(self.__make_setter_command(cog)) def to_stored_value(self, native_value: Any) -> Any: if self.datatype is timedelta: return native_value.total_seconds() return native_value def to_native_value(self, stored_value: Any) -> Any: if self.datatype is timedelta and isinstance(stored_value, (int, float)): return timedelta(seconds=stored_value) return stored_value 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}' datatype = self.datatype async def getter(cog0, interaction: Interaction) -> None: print(f"invoking getter for {setting_name}") key = f'{cog0.__class__.__name__}.{setting.name}' value = setting.to_native_value(Storage.get_config_value(interaction.guild, key)) if value is None: value = setting.to_native_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 ) # We have to do some surgery to make the getter function a proper method on the cog # that discord.py will recognize and wire up correctly. Same for other accessors below. setattr(cog.__class__, f'_cmd_get_{setting.name}', getter) # add method to cog class getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}' # discord.py checks this to know if it's a method vs function getter.__self__ = cog # discord.py uses this as the self argument bot_log(None, cog.__class__, f"Creating /get {setting_name}") command = Command( name=setting_name, description=f'Shows {self.brief}.', callback=getter, parent=CogSetting.__get_group, extras={ 'cog': cog, 'setting': setting, 'long_description': setting.description, }, ) return command def __make_setter_command(self, cog: 'BaseCog') -> Command: from rocketbot.cogs.basecog import BaseCog 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_general(cog0: BaseCog, interaction: Interaction, new_value) -> None: print(f"invoking setter for {setting_name} with value {new_value}") 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, setting.to_stored_value(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.user.name} set {key} to {new_value}') setter: CommandCallback = setter_general if self.datatype == int: if self.min_value is not None or self.max_value is not None: r_min = self.min_value r_max = self.max_value @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_range(cog0, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None: await setter_general(cog0, interaction, new_value) setter = setter_range else: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_int(cog0, interaction: Interaction, new_value: int) -> None: await setter_general(cog0, interaction, new_value) setter = setter_int elif self.datatype == float: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_float(cog0, interaction: Interaction, new_value: float) -> None: await setter_general(cog0, interaction, new_value) setter = setter_float elif self.datatype == timedelta: @rename(new_value=self.name) @describe(new_value=f'{self.brief} (e.g. 30s, 5m, 1h30s, or 7d)') async def setter_timedelta(cog0, interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None: await setter_general(cog0, interaction, new_value) setter = setter_timedelta elif getattr(self.datatype, '__origin__', None) == Literal: dt = self.datatype @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_enum(cog0, interaction: Interaction, new_value: dt) -> None: await setter_general(cog0, interaction, new_value) setter = setter_enum elif self.datatype == str: if self.enum_values is not None: raise ValueError('Type for a setting with enum values should be typing.Literal') else: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_str(cog0, interaction: Interaction, new_value: str) -> None: await setter_general(cog0, interaction, new_value) setter = setter_str elif setting.datatype == bool: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_bool(cog0, interaction: Interaction, new_value: bool) -> None: await setter_general(cog0, interaction, new_value) setter = setter_bool elif setting.datatype is not None: raise ValueError(f'Invalid type {self.datatype}') setattr(cog.__class__, f'_cmd_set_{setting.name}', setter) setter.__qualname__ = f'{cog.__class__.__name__}._cmd_set_{setting.name}' setter.__self__ = cog bot_log(None, cog.__class__, f"Creating /set {setting_name} {self.datatype}") command = Command( name=setting_name, description=f'Sets {self.brief}.', callback=setter, parent=CogSetting.__set_group, extras={ 'cog': cog, 'setting': setting, 'long_description': setting.description, }, ) return command def __make_enable_command(self, cog: 'BaseCog') -> Command: from rocketbot.cogs.basecog import BaseCog setting: CogSetting = self async def enabler(cog0: BaseCog, interaction: Interaction) -> None: print(f"invoking enable for {cog0.config_prefix}") 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.user.name} enabled {cog0.__class__.__name__}') setattr(cog.__class__, f'_cmd_enable', enabler) enabler.__qualname__ = f'{cog.__class__.__name__}._cmd_enable' enabler.__self__ = cog bot_log(None, cog.__class__, f"Creating /enable {cog.config_prefix}") command = Command( name=cog.config_prefix, description=f'Enables {cog.qualified_name} functionality.', callback=enabler, parent=CogSetting.__enable_group, extras={ 'cog': cog, 'setting': setting, 'long_description': setting.description, }, ) return command def __make_disable_command(self, cog: 'BaseCog') -> Command: from rocketbot.cogs.basecog import BaseCog setting: CogSetting = self async def disabler(cog0: BaseCog, interaction: Interaction) -> None: print(f"invoking disable for {cog0.config_prefix}") 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.user.name} disabled {cog0.__class__.__name__}') setattr(cog.__class__, f'_cmd_disable', disabler) disabler.__qualname__ = f'{cog.__class__.__name__}._cmd_disable' disabler.__self__ = cog bot_log(None, cog.__class__, f"Creating /disable {cog.config_prefix}") command = Command( name=cog.config_prefix, description=f'Disables {cog.config_prefix} functionality', callback=disabler, parent=CogSetting.__disable_group, extras={ 'cog': cog, 'setting': setting, 'long_description': setting.description, }, ) 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) if len(settings) == 0: return bot_log(None, cog.__class__, f"Setting up slash commands for {cog.__class__.__name__}") for setting in settings: setting.set_up(cog, bot) bot_log(None, cog.__class__, f"Done setting up slash commands for {cog.__class__.__name__}") @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 configuration value for this guild.', default_permissions=MOD_PERMISSIONS, extras={ 'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/get` to ' 'see the current value for this guild.', }, ) cls.__get_group = Group( name='get', description='Shows a configuration value for this guild.', default_permissions=MOD_PERMISSIONS, extras={ 'long_description': 'Settings are guild-specific. Shows the configured value or default value for a ' 'variable for this guild. Use `/set` to change the value.', }, ) cls.__enable_group = Group( name='enable', description='Enables a module for this guild', default_permissions=MOD_PERMISSIONS, extras={ 'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/disable` ' 'to disable an enabled module.', }, ) cls.__disable_group = Group( name='disable', description='Disables a module for this guild.', default_permissions=MOD_PERMISSIONS, extras={ 'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/enable` ' 're-enable a disabled module.', }, ) 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) from rocketbot.cogs.basecog import BaseCog async def show_all(interaction: Interaction) -> None: try: guild = interaction.guild if guild is None: await interaction.response.send_message( f'{CONFIG["error_emoji"]} No guild.', ephemeral=True, delete_after=10, ) return text = '## :information_source: Configuration' for cog_name, cog in sorted(bot.cogs.items()): if not isinstance(cog, BaseCog): continue bcog: BaseCog = cog if len(bcog.settings) == 0: continue text += f'\n### {bcog.qualified_name} Module' for setting in sorted(bcog.settings, key=lambda s: (s.name != 'enabled', s.name)): key = f'{bcog.__class__.__name__}.{setting.name}' value = setting.to_native_value(Storage.get_config_value(guild, key)) deflt = setting.to_native_value(bcog.get_cog_default(setting.name)) if setting.name == 'enabled': text += f'\n- Module is ' if value is not None: text += '**' + ('enabled' if value else 'disabled') + '**' else: text += ('enabled' if deflt else 'disabled') + ' _(using default)_' else: if value is not None: text += f'\n- `{bcog.config_prefix}_{setting.name}` = **{value}**' else: text += f'\n- `{bcog.config_prefix}_{setting.name}` = {deflt} _(using default)_' await interaction.response.send_message( text, ephemeral=True, ) except BaseException as e: dump_stacktrace(e) show_all_command = Command( name='all', description='Shows all configuration for this guild.', callback=show_all, ) cls.__get_group.add_command(show_all_command)