Experimental Discord bot written in Python
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

cogsetting.py 12KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328
  1. """
  2. A guild configuration setting available for editing via bot commands.
  3. """
  4. import inspect
  5. from typing import Any, Optional, Type, TypeVar, Coroutine, Literal
  6. from discord import Interaction, Permissions, permissions
  7. from discord.app_commands import Range, guild_only, default_permissions
  8. from discord.app_commands.commands import Command, Group, CommandCallback
  9. from discord.ext.commands import Bot
  10. from config import CONFIG
  11. from rocketbot.storage import Storage
  12. from rocketbot.utils import bot_log
  13. # def _fix_command(command: Command) -> None:
  14. # """
  15. # HACK: Fixes bug in discord.py 2.3.2 where it's requiring the user to
  16. # supply the context argument. This removes that argument from the list.
  17. # """
  18. # params = command.params
  19. # del params['context']
  20. # command.params = params
  21. BaseCog = TypeVar('BaseCog', bound='rocketbot.cogs.BaseCog')
  22. class CogSetting:
  23. permissions: Permissions = Permissions(Permissions.manage_messages.flag)
  24. """
  25. Describes a configuration setting for a guild that can be edited by the
  26. mods of those guilds. BaseCog can generate "get" and "set" commands (or
  27. "enable" and "disable" commands for boolean values) automatically, reducing
  28. the boilerplate of generating commands manually. Offers simple validation rules.
  29. """
  30. def __init__(self,
  31. name: str,
  32. datatype: Optional[Type],
  33. brief: Optional[str] = None,
  34. description: Optional[str] = None,
  35. usage: Optional[str] = None,
  36. min_value: Optional[Any] = None,
  37. max_value: Optional[Any] = None,
  38. enum_values: Optional[set[Any]] = None):
  39. """
  40. Parameters
  41. ----------
  42. name: str
  43. Setting identifier. Must follow variable naming conventions.
  44. datatype: Optional[Type]
  45. Datatype of the setting. E.g. int, float, str
  46. brief: Optional[str]
  47. Description of the setting, starting with lower case.
  48. Will be inserted into phrases like "Sets <brief>" and
  49. "Gets <brief".
  50. description: Optional[str]
  51. Long-form description. Min, max, and enum values will be
  52. appended to the end, so does not need to include these.
  53. usage: Optional[str]
  54. Description of the value argument in a set command, e.g. "<maxcount:int>"
  55. min_value: Optional[Any]
  56. Smallest allowable value. Must be of the same datatype as
  57. the value. None for no minimum.
  58. max_value: Optional[Any]
  59. Largest allowable value. None for no maximum.
  60. enum_values: Optional[set[Any]]
  61. Set of allowed values. None if unconstrained.
  62. """
  63. self.name: str = name
  64. self.datatype: Type = datatype
  65. self.brief: Optional[str] = brief
  66. self.description: str = description or '' # Can't be None
  67. self.usage: Optional[str] = usage
  68. self.min_value: Optional[Any] = min_value
  69. self.max_value: Optional[Any] = max_value
  70. self.enum_values: Optional[set[Any]] = enum_values
  71. if self.enum_values or self.min_value is not None or self.max_value is not None:
  72. self.description += '\n'
  73. if self.enum_values:
  74. allowed_values = '`' + ('`, `'.join(enum_values)) + '`'
  75. self.description += f'\nAllowed values: {allowed_values}'
  76. if self.min_value is not None:
  77. self.description += f'\nMin value: {self.min_value}'
  78. if self.max_value is not None:
  79. self.description += f'\nMax value: {self.max_value}'
  80. if self.usage is None:
  81. self.usage = f'<{self.name}>'
  82. def validate_value(self, new_value: Any) -> None:
  83. """
  84. Checks if a value is legal for this setting. Raises a ValueError if not.
  85. """
  86. if self.min_value is not None and new_value < self.min_value:
  87. raise ValueError(f'`{self.name}` must be >= {self.min_value}')
  88. if self.max_value is not None and new_value > self.max_value:
  89. raise ValueError(f'`{self.name}` must be <= {self.max_value}')
  90. if self.enum_values is not None and new_value not in self.enum_values:
  91. allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
  92. raise ValueError(f'`{self.name}` must be one of {allowed_values}')
  93. def set_up(self, cog: BaseCog, bot: Bot) -> None:
  94. """
  95. Sets up getter and setter commands for this setting. This should
  96. usually only be called by BaseCog.
  97. """
  98. if self.name in ('enabled', 'is_enabled'):
  99. self.__enable_group.add_command(self.__make_enable_command(cog))
  100. self.__disable_group.add_command(self.__make_disable_command(cog))
  101. else:
  102. self.__get_group.add_command(self.__make_getter_command(cog))
  103. self.__set_group.add_command(self.__make_setter_command(cog))
  104. def __make_getter_command(self, cog: BaseCog) -> Command:
  105. setting: CogSetting = self
  106. setting_name = setting.name
  107. if cog.config_prefix is not None:
  108. setting_name = f'{cog.config_prefix}_{setting_name}'
  109. async def getter(self, interaction: Interaction) -> None:
  110. print(f"invoking getter for {setting_name}")
  111. key = f'{self.__class__.__name__}.{setting.name}'
  112. value = Storage.get_config_value(interaction.guild, key)
  113. if value is None:
  114. value = self.get_cog_default(setting.name)
  115. await interaction.response.send_message(
  116. f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{value}`',
  117. ephemeral=True
  118. )
  119. else:
  120. await interaction.response.send_message(
  121. f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{value}`',
  122. ephemeral=True
  123. )
  124. setattr(cog.__class__, f'_cmd_get_{setting.name}', getter)
  125. getter.__qualname__ = f'{self.__class__.__name__}._cmd_get_{setting.name}'
  126. bot_log(None, cog.__class__, f"Creating /get {setting_name}")
  127. command = Command(
  128. name=setting_name,
  129. description=f'Shows value of {setting_name}',
  130. callback=getter,
  131. parent=CogSetting.__get_group,
  132. )
  133. return command
  134. def __make_setter_command(self, cog: BaseCog) -> Command:
  135. setting: CogSetting = self
  136. setting_name = setting.name
  137. if cog.config_prefix is not None:
  138. setting_name = f'{cog.config_prefix}_{setting_name}'
  139. async def setter_general(self, interaction: Interaction, new_value) -> None:
  140. print(f"invoking setter for {setting_name} with value {new_value}")
  141. try:
  142. setting.validate_value(new_value)
  143. except ValueError as ve:
  144. await interaction.response.send_message(
  145. f'{CONFIG["failure_emoji"]} {ve}',
  146. ephemeral=True
  147. )
  148. return
  149. key = f'{self.__class__.__name__}.{setting.name}'
  150. Storage.set_config_value(interaction.guild, key, new_value)
  151. await interaction.response.send_message(
  152. f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{new_value}`',
  153. ephemeral=True
  154. )
  155. await self.on_setting_updated(interaction.guild, setting)
  156. self.log(interaction.guild, f'{interaction.message.author.name} set {key} to {new_value}')
  157. type_str: str = 'any'
  158. setter: CommandCallback = setter_general
  159. if self.datatype == int:
  160. if self.min_value is not None or self.max_value is not None:
  161. type_str = f'discord.app_commands.Range[int, {self.min_value}, {self.max_value}]'
  162. r_min = self.min_value
  163. r_max = self.max_value
  164. async def setter_range(self, interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
  165. await self.setter_general(interaction, new_value)
  166. setter = setter_range
  167. else:
  168. type_str = 'int'
  169. async def setter_int(self, interaction: Interaction, new_value: int) -> None:
  170. await self.setter_general(interaction, new_value)
  171. setter = setter_int
  172. elif self.datatype == float:
  173. type_str = 'float'
  174. async def setter_float(self, interaction: Interaction, new_value: float) -> None:
  175. await self.setter_general(interaction, new_value)
  176. setter = setter_float
  177. elif getattr(self.datatype, '__origin__', None) == Literal:
  178. value_list = '"' + '", "'.join(self.enum_values) + '"'
  179. type_str = f'typing.Literal[{value_list}]'
  180. values = self.enum_values
  181. dt = self.datatype
  182. async def setter_enum(self, interaction: Interaction, new_value: dt) -> None:
  183. await self.setter_general(interaction, new_value)
  184. setter = setter_enum
  185. elif self.datatype == str:
  186. if self.enum_values is not None:
  187. raise ValueError('Type for a setting with enum values should be typing.Literal')
  188. else:
  189. type_str = 'str'
  190. async def setter_str(self, interaction: Interaction, new_value: str) -> None:
  191. await self.setter_general(interaction, new_value)
  192. setter = setter_str
  193. elif setting.datatype == bool:
  194. type_str = f'bool'
  195. async def setter_bool(self, interaction: Interaction, new_value: bool) -> None:
  196. await self.setter_general(interaction, new_value)
  197. setter = setter_bool
  198. elif setting.datatype is not None:
  199. raise ValueError(f'Invalid type {self.datatype}')
  200. setattr(cog.__class__, f'_cmd_set_{setting.name}', setter)
  201. setter.__qualname__ = f'{cog.__class__.__name__}._cmd_set_{setting.name}'
  202. bot_log(None, cog.__class__, f"Creating /set {setting_name} {type_str}")
  203. command = Command(
  204. name=setting_name,
  205. description=f'Sets value of {setting_name}',
  206. callback=setter,
  207. parent=CogSetting.__set_group,
  208. )
  209. # HACK: Passing `cog` in init gets ignored and set to `None` so set after.
  210. # This ensures the callback is passed the cog as `self` argument.
  211. # command.cog = cog
  212. # _fix_command(command)
  213. return command
  214. def __make_enable_command(self, cog: BaseCog) -> Command:
  215. setting: CogSetting = self
  216. async def enabler(self, interaction: Interaction) -> None:
  217. print(f"invoking enable for {self.config_prefix}")
  218. key = f'{self.__class__.__name__}.{setting.name}'
  219. Storage.set_config_value(interaction.guild, key, True)
  220. await interaction.response.send_message(
  221. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
  222. ephemeral=True
  223. )
  224. await self.on_setting_updated(interaction.guild, setting)
  225. self.log(interaction.guild, f'{interaction.message.author.name} enabled {self.__class__.__name__}')
  226. setattr(cog.__class__, f'_cmd_enable', enabler)
  227. enabler.__qualname__ = f'{cog.__class__.__name__}._cmd_enable'
  228. bot_log(None, cog.__class__, f"Creating /enable {cog.config_prefix}")
  229. command = Command(
  230. name=cog.config_prefix,
  231. description=f'Enables {cog.config_prefix} functionality',
  232. callback=enabler,
  233. parent=CogSetting.__enable_group,
  234. )
  235. # command.cog = cog
  236. # _fix_command(command)
  237. return command
  238. def __make_disable_command(self, cog: BaseCog) -> Command:
  239. setting: CogSetting = self
  240. async def disabler(self, interaction: Interaction) -> None:
  241. print(f"invoking disable for {self.config_prefix}")
  242. key = f'{self.__class__.__name__}.{setting.name}'
  243. Storage.set_config_value(interaction.guild, key, False)
  244. await interaction.response.send_message(
  245. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
  246. ephemeral=True
  247. )
  248. await self.on_setting_updated(interaction.guild, setting)
  249. self.log(interaction.guild, f'{interaction.message.author.name} disabled {self.__class__.__name__}')
  250. setattr(cog.__class__, f'_cmd_disable', disabler)
  251. disabler.__qualname__ = f'{cog.__class__.__name__}._cmd_disable'
  252. bot_log(None, cog.__class__, f"Creating /disable {cog.config_prefix}")
  253. command = Command(
  254. name=cog.config_prefix,
  255. description=f'Disables {cog.config_prefix} functionality',
  256. callback=disabler,
  257. parent=CogSetting.__disable_group,
  258. )
  259. # command.cog = cog
  260. # _fix_command(command)
  261. return command
  262. __has_set_up_base_commands: bool = False
  263. __set_group: Group
  264. __get_group: Group
  265. __enable_group: Group
  266. __disable_group: Group
  267. @classmethod
  268. def set_up_all(cls, cog: BaseCog, bot: Bot, settings: list['CogSetting']) -> None:
  269. """
  270. Sets up editing commands for a list of CogSettings and adds them to a
  271. cog. If the cog has a command Group, commands will be added to it.
  272. Otherwise, they will be added at the top level.
  273. """
  274. cls.__set_up_base_commands(bot)
  275. if len(settings) == 0:
  276. return
  277. bot_log(None, cog.__class__, f"Setting up slash commands for {cog.__class__.__name__}")
  278. for setting in settings:
  279. setting.set_up(cog, bot)
  280. bot_log(None, cog.__class__, f"Done setting up slash commands for {cog.__class__.__name__}")
  281. @classmethod
  282. def __set_up_base_commands(cls, bot: Bot) -> None:
  283. if cls.__has_set_up_base_commands:
  284. return
  285. cls.__has_set_up_base_commands = True
  286. cls.__set_group = Group(
  287. name='set',
  288. description='Sets a bot configuration value for this guild',
  289. default_permissions=cls.permissions
  290. )
  291. cls.__get_group = Group(
  292. name='get',
  293. description='Shows a configured bot value for this guild',
  294. default_permissions=cls.permissions
  295. )
  296. cls.__enable_group = Group(
  297. name='enable',
  298. description='Enables a set of bot functionality for this guild',
  299. default_permissions=cls.permissions
  300. )
  301. cls.__disable_group = Group(
  302. name='disable',
  303. description='Disables a set of bot functionality for this guild',
  304. default_permissions=cls.permissions
  305. )
  306. bot.tree.add_command(cls.__set_group)
  307. bot.tree.add_command(cls.__get_group)
  308. bot.tree.add_command(cls.__enable_group)
  309. bot.tree.add_command(cls.__disable_group)