| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430 |
- """
- 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 <brief>" and
- "Gets <brief>".
- 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. "<maxcount:int>"
- 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)
|