""" A guild configuration setting available for editing via bot commands. """ from types import coroutine from typing import Any, Optional, Type from discord.ext import commands from discord.ext.commands import Bot, Cog, Command, Context, Group from config import CONFIG from rocketbot.storage import Storage from rocketbot.utils import first_command_group 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. """ def __init__(self, name: str, datatype: 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): """ Params: - name Setting identifier. Must follow variable naming conventions. - datatype Datatype of the setting. E.g. int, float, str - brief Description of the setting, starting with lower case. Will be inserted into phrases like "Sets " and "Gets " - min_value Smallest allowable value. Must be of the same datatype as the value. None for no minimum. - max_value Largest allowable value. None for no maximum. - enum_values 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) -> 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: Cog, bot: Bot, group: Group) -> 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'): (group or bot).add_command(self.__make_enable_command(cog)) (group or bot).add_command(self.__make_disable_command(cog)) else: (group or bot).add_command(self.__make_getter_command(cog)) (group or bot).add_command(self.__make_setter_command(cog)) def __make_getter_command(self, cog: Cog) -> Command: setting: CogSetting = self async def getter(cog: Cog, context: Context) -> None: setting_name = setting.name if context.command.parent: setting_name = f'{context.command.parent.name}.{setting_name}' key = f'{cog.__class__.__name__}.{setting.name}' value = Storage.get_config_value(context.guild, key) if value is None: value = cog.get_cog_default(setting.name) await context.message.reply( f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`', mention_author=False) else: await context.message.reply( f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`', mention_author=False) command = Command( getter, name=f'get{setting.name}', brief=f'Shows {setting.brief}', description=setting.description, checks=[ commands.has_permissions(ban_members=True), commands.guild_only(), ]) command.cog = cog return command def __make_setter_command(self, cog: Cog) -> Command: setting: CogSetting = self async def setter_common(cog: Cog, context: Context, new_value) -> None: try: setting.validate_value(new_value) except ValueError as ve: await context.message.reply( f'{CONFIG["failure_emoji"]} {ve}', mention_author=False) return setting_name = setting.name if context.command.parent: setting_name = f'{context.command.parent.name}.{setting_name}' key = f'{cog.__class__.__name__}.{setting.name}' Storage.set_config_value(context.guild, key, new_value) await context.message.reply( f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`', mention_author=False) await cog.on_setting_updated(context.guild, setting) cog.log(context.guild, f'{context.author.name} set {key} to {new_value}') async def setter_int(cog, context, new_value: int): await setter_common(cog, context, new_value) async def setter_float(cog, context, new_value: float): await setter_common(cog, context, new_value) async def setter_str(cog, context, new_value: str): await setter_common(cog, context, new_value) async def setter_bool(cog, context, new_value: bool): await setter_common(cog, context, new_value) setter: coroutine = None if setting.datatype == int: setter = setter_int elif setting.datatype == float: setter = setter_float elif setting.datatype == str: setter = setter_str elif setting.datatype == bool: setter = setter_bool else: raise ValueError(f'Datatype {setting.datatype} unsupported') command = Command( setter, name=f'set{setting.name}', brief=f'Sets {setting.brief}', description=setting.description, usage=setting.usage, checks=[ commands.has_permissions(ban_members=True), commands.guild_only(), ]) # 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 return command def __make_enable_command(self, cog: Cog) -> Command: setting: CogSetting = self async def enabler(cog: Cog, context: Context) -> None: key = f'{cog.__class__.__name__}.{setting.name}' Storage.set_config_value(context.guild, key, True) await context.message.reply( f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.', mention_author=False) await cog.on_setting_updated(context.guild, setting) cog.log(context.guild, f'{context.author.name} enabled {cog.__class__.__name__}') command = Command( enabler, name='enable', brief=f'Enables {setting.brief}', description=setting.description, checks=[ commands.has_permissions(ban_members=True), commands.guild_only(), ]) command.cog = cog return command def __make_disable_command(self, cog: Cog) -> Command: setting: CogSetting = self async def disabler(cog: Cog, context: Context) -> None: key = f'{cog.__class__.__name__}.{setting.name}' Storage.set_config_value(context.guild, key, False) await context.message.reply( f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.', mention_author=False) await cog.on_setting_updated(context.guild, setting) cog.log(context.guild, f'{context.author.name} disabled {cog.__class__.__name__}') command = Command( disabler, name='disable', brief=f'Disables {setting.brief}', description=setting.description, checks=[ commands.has_permissions(ban_members=True), commands.guild_only(), ]) command.cog = cog return command @classmethod def set_up_all(cls, cog: Cog, 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. """ group: Group = first_command_group(cog) for setting in settings: setting.set_up(cog, bot, group)