""" 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: permissions: Permissions = Permissions(Permissions.manage_messages.flag) """ 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'): 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.config_prefix} 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)