""" 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, str_from_timedelta 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], default_value: Any, brief: Optional[str] = None, description: 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 default_value: Any Value to use if a guild has not yet configured one. 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. 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.default_value = default_value self.brief: Optional[str] = brief self.description: str = description or '' # Can't be None 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') -> 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: """Converts a configuration value to a JSON-compatible datatype.""" if self.datatype is timedelta: return native_value.total_seconds() return native_value def to_native_value(self, stored_value: Any) -> Any: """Converts the stored JSON-compatible datatype to its actual value.""" if self.datatype is timedelta and isinstance(stored_value, (int, float)): return timedelta(seconds=stored_value) return stored_value @staticmethod def native_value_to_str(native_value: Any) -> str: """Formats a native configuration value to a user-presentable string.""" if native_value is None: return '' if isinstance(native_value, timedelta): return str_from_timedelta(native_value) if isinstance(native_value, bool): return 'true' if native_value else 'false' return f'{native_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}' async def getter(interaction: Interaction) -> None: key = f'{cog.__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(cog.get_cog_default(setting.name)) await interaction.response.send_message( f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{CogSetting.native_value_to_str(value)}`', ephemeral=True ) else: await interaction.response.send_message( f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{CogSetting.native_value_to_str(value)}`', ephemeral=True ) 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: 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(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'{cog.__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 `{setting.to_native_value(new_value)}`', ephemeral=True ) await cog.on_setting_updated(interaction.guild, setting) cog.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(interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None: await setter_general(interaction, new_value) setter = setter_range else: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_int(interaction: Interaction, new_value: int) -> None: await setter_general(interaction, new_value) setter = setter_int elif self.datatype == float: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_float(interaction: Interaction, new_value: float) -> None: await setter_general(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(interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None: await setter_general(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(interaction: Interaction, new_value: dt) -> None: await 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: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_str(interaction: Interaction, new_value: str) -> None: await setter_general(interaction, new_value) setter = setter_str elif self.datatype == bool: @rename(new_value=self.name) @describe(new_value=self.brief) async def setter_bool(interaction: Interaction, new_value: bool) -> None: await setter_general(interaction, new_value) setter = setter_bool elif self.datatype is not None: raise ValueError(f'Invalid type {self.datatype}') 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: setting: CogSetting = self async def enabler(interaction: Interaction) -> None: key = f'{cog.__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 cog.on_setting_updated(interaction.guild, setting) cog.log(interaction.guild, f'{interaction.user.name} enabled {cog.__class__.__name__}') 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: setting: CogSetting = self async def disabler(interaction: Interaction) -> None: key = f'{cog.__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 cog.on_setting_updated(interaction.guild, setting) cog.log(interaction.guild, f'{interaction.user.name} disabled {cog.__class__.__name__}') 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 __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_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 getattr(cls, f'_CogSetting__set_group', None) is not None: return 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') + ' _(default)_' else: if value is not None: text += f'\n- `{bcog.config_prefix}_{setting.name}` = **{CogSetting.native_value_to_str(value)}**' else: text += f'\n- `{bcog.config_prefix}_{setting.name}` = {CogSetting.native_value_to_str(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)