""" A guild configuration setting available for editing via bot commands. """ from datetime import timedelta from typing import Any, Optional, Type, TypeVar, Literal from discord import Interaction, Permissions from discord.app_commands import Range, Transform from discord.app_commands.commands import Command, Group, CommandCallback from discord.ext.commands import Bot from config import CONFIG from rocketbot.storage import Storage from rocketbot.utils import bot_log, TimeDeltaTransformer # 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 BaseCog = TypeVar('BaseCog', bound='rocketbot.cogs.BaseCog') 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 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'): 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 __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(self, interaction: Interaction) -> None: print(f"invoking getter for {setting_name}") key = f'{self.__class__.__name__}.{setting.name}' value = Storage.get_config_value(interaction.guild, key) if value is None: value = self.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 ) setattr(cog.__class__, f'_cmd_get_{setting.name}', getter) getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}' getter.__self__ = cog 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, ) 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_general(self, 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'{self.__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 self.on_setting_updated(interaction.guild, setting) self.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 async def setter_range(self, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None: await self.setter_general(interaction, new_value) setter = setter_range else: async def setter_int(self, interaction: Interaction, new_value: int) -> None: await self.setter_general(interaction, new_value) setter = setter_int elif self.datatype == float: async def setter_float(self, interaction: Interaction, new_value: float) -> None: await self.setter_general(interaction, new_value) setter = setter_float elif self.datatype == timedelta: async def setter_timedelta(self, interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None: await self.setter_general(interaction, new_value) setter = setter_timedelta elif getattr(self.datatype, '__origin__', None) == Literal: dt = self.datatype async def setter_enum(self, interaction: Interaction, new_value: dt) -> None: await self.setter_general(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: async def setter_str(self, interaction: Interaction, new_value: str) -> None: await self.setter_general(interaction, new_value) setter = setter_str elif setting.datatype == bool: async def setter_bool(self, interaction: Interaction, new_value: bool) -> None: await self.setter_general(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, ) # 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(self: BaseCog, interaction: Interaction) -> None: print(f"invoking enable for {self.config_prefix}") key = f'{self.__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 self.on_setting_updated(interaction.guild, setting) self.log(interaction.guild, f'{interaction.user.name} enabled {self.__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.name} functionality', 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(self: BaseCog, interaction: Interaction) -> None: print(f"invoking disable for {self.config_prefix}") key = f'{self.__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 self.on_setting_updated(interaction.guild, setting) self.log(interaction.guild, f'{interaction.user.name} disabled {self.__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, ) # 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) 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 bot configuration value for this guild', default_permissions=cls.permissions ) cls.__get_group = Group( name='get', description='Shows a configured bot value for this guild', default_permissions=cls.permissions ) cls.__enable_group = Group( name='enable', description='Enables bot functionality for this guild', default_permissions=cls.permissions ) cls.__disable_group = Group( name='disable', description='Disables bot functionality for this guild', default_permissions=cls.permissions ) 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)