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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411
  1. """
  2. A guild configuration setting available for editing via bot commands.
  3. """
  4. from datetime import timedelta
  5. from typing import Any, Optional, Type, Literal
  6. from discord import Interaction, Permissions
  7. from discord.app_commands import Range, Transform, describe
  8. from discord.app_commands.commands import Command, Group, CommandCallback, rename
  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, TimeDeltaTransformer, MOD_PERMISSIONS, dump_stacktrace, str_from_timedelta
  13. class CogSetting:
  14. """
  15. Describes a configuration setting for a guild that can be edited by the
  16. mods of those guilds. BaseCog can generate "/get" and "/set" commands (or
  17. "/enable" and "/disable" commands for boolean values) automatically, reducing
  18. the boilerplate of generating commands manually. Offers simple validation rules.
  19. """
  20. permissions: Permissions = Permissions(Permissions.manage_messages.flag)
  21. def __init__(self,
  22. name: str,
  23. datatype: Optional[Type],
  24. default_value: Any,
  25. brief: Optional[str] = None,
  26. description: Optional[str] = None,
  27. min_value: Optional[Any] = None,
  28. max_value: Optional[Any] = None,
  29. enum_values: Optional[set[Any]] = None):
  30. """
  31. Parameters
  32. ----------
  33. name: str
  34. Setting identifier. Must follow variable naming conventions.
  35. datatype: Optional[Type]
  36. Datatype of the setting. E.g. int, float, str
  37. default_value: Any
  38. Value to use if a guild has not yet configured one.
  39. brief: Optional[str]
  40. Description of the setting, starting with lower case.
  41. Will be inserted into phrases like "Sets <brief>" and
  42. "Gets <brief>".
  43. description: Optional[str]
  44. Long-form description. Min, max, and enum values will be
  45. appended to the end, so does not need to include these.
  46. min_value: Optional[Any]
  47. Smallest allowable value. Must be of the same datatype as
  48. the value. None for no minimum.
  49. max_value: Optional[Any]
  50. Largest allowable value. None for no maximum.
  51. enum_values: Optional[set[Any]]
  52. Set of allowed values. None if unconstrained.
  53. """
  54. self.name: str = name
  55. self.datatype: Type = datatype
  56. self.default_value = default_value
  57. self.brief: Optional[str] = brief
  58. self.description: str = description or '' # Can't be None
  59. self.min_value: Optional[Any] = min_value
  60. self.max_value: Optional[Any] = max_value
  61. self.enum_values: Optional[set[Any]] = enum_values
  62. if self.enum_values:
  63. value_list = '`' + ('`, `'.join(self.enum_values)) + '`'
  64. self.description += f' (Permitted values: {value_list})'
  65. elif self.min_value is not None and self.max_value is not None:
  66. self.description += f' (Value must be between `{self.min_value}` and `{self.max_value}`)'
  67. elif self.min_value is not None:
  68. self.description += f' (Minimum value: {self.min_value})'
  69. elif self.max_value is not None:
  70. self.description += f' (Maximum value: {self.max_value})'
  71. def validate_value(self, new_value: Any) -> None:
  72. """
  73. Checks if a value is legal for this setting. Raises a ValueError if not.
  74. """
  75. if self.min_value is not None and new_value < self.min_value:
  76. raise ValueError(f'`{self.name}` must be >= {self.min_value}')
  77. if self.max_value is not None and new_value > self.max_value:
  78. raise ValueError(f'`{self.name}` must be <= {self.max_value}')
  79. if self.enum_values is not None and new_value not in self.enum_values:
  80. allowed_values = '`' + ('`, `'.join(self.enum_values)) + '`'
  81. raise ValueError(f'`{self.name}` must be one of {allowed_values}')
  82. def set_up(self, cog: 'BaseCog') -> None:
  83. """
  84. Sets up getter and setter commands for this setting. This should
  85. usually only be called by BaseCog.
  86. """
  87. if self.name in ('enabled', 'is_enabled'):
  88. self.__enable_group.add_command(self.__make_enable_command(cog))
  89. self.__disable_group.add_command(self.__make_disable_command(cog))
  90. else:
  91. self.__get_group.add_command(self.__make_getter_command(cog))
  92. self.__set_group.add_command(self.__make_setter_command(cog))
  93. def to_stored_value(self, native_value: Any) -> Any:
  94. """Converts a configuration value to a JSON-compatible datatype."""
  95. if self.datatype is timedelta:
  96. return native_value.total_seconds()
  97. return native_value
  98. def to_native_value(self, stored_value: Any) -> Any:
  99. """Converts the stored JSON-compatible datatype to its actual value."""
  100. if self.datatype is timedelta and isinstance(stored_value, (int, float)):
  101. return timedelta(seconds=stored_value)
  102. return stored_value
  103. @staticmethod
  104. def native_value_to_str(native_value: Any) -> str:
  105. """Formats a native configuration value to a user-presentable string."""
  106. if native_value is None:
  107. return '<no value>'
  108. if isinstance(native_value, timedelta):
  109. return str_from_timedelta(native_value)
  110. if isinstance(native_value, bool):
  111. return 'true' if native_value else 'false'
  112. return f'{native_value}'
  113. def __make_getter_command(self, cog: 'BaseCog') -> Command:
  114. setting: CogSetting = self
  115. setting_name = setting.name
  116. if cog.config_prefix is not None:
  117. setting_name = f'{cog.config_prefix}_{setting_name}'
  118. async def getter(interaction: Interaction) -> None:
  119. key = f'{cog.__class__.__name__}.{setting.name}'
  120. value = setting.to_native_value(Storage.get_config_value(interaction.guild, key))
  121. if value is None:
  122. value = setting.to_native_value(cog.get_cog_default(setting.name))
  123. await interaction.response.send_message(
  124. f'{CONFIG["info_emoji"]} `{setting_name}` is using default of `{CogSetting.native_value_to_str(value)}`',
  125. ephemeral=True
  126. )
  127. else:
  128. await interaction.response.send_message(
  129. f'{CONFIG["info_emoji"]} `{setting_name}` is set to `{CogSetting.native_value_to_str(value)}`',
  130. ephemeral=True
  131. )
  132. bot_log(None, cog.__class__, f"Creating /get {setting_name}")
  133. command = Command(
  134. name=setting_name,
  135. description=f'Shows {self.brief}.',
  136. callback=getter,
  137. parent=CogSetting.__get_group,
  138. extras={
  139. 'cog': cog,
  140. 'setting': setting,
  141. 'long_description': setting.description,
  142. },
  143. )
  144. return command
  145. def __make_setter_command(self, cog: 'BaseCog') -> Command:
  146. setting: CogSetting = self
  147. setting_name = setting.name
  148. if cog.config_prefix is not None:
  149. setting_name = f'{cog.config_prefix}_{setting_name}'
  150. async def setter_general(interaction: Interaction, new_value) -> None:
  151. try:
  152. setting.validate_value(new_value)
  153. except ValueError as ve:
  154. await interaction.response.send_message(
  155. f'{CONFIG["failure_emoji"]} {ve}',
  156. ephemeral=True
  157. )
  158. return
  159. key = f'{cog.__class__.__name__}.{setting.name}'
  160. Storage.set_config_value(interaction.guild, key, setting.to_stored_value(new_value))
  161. await interaction.response.send_message(
  162. f'{CONFIG["success_emoji"]} `{setting_name}` is now set to `{setting.to_native_value(new_value)}`',
  163. ephemeral=True
  164. )
  165. await cog.on_setting_updated(interaction.guild, setting)
  166. cog.log(interaction.guild, f'{interaction.user.name} set {key} to {new_value}')
  167. setter: CommandCallback = setter_general
  168. if self.datatype == int:
  169. if self.min_value is not None or self.max_value is not None:
  170. r_min = self.min_value
  171. r_max = self.max_value
  172. @rename(new_value=self.name)
  173. @describe(new_value=self.brief)
  174. async def setter_range(interaction: Interaction, new_value: Range[int, r_min, r_max]) -> None:
  175. await setter_general(interaction, new_value)
  176. setter = setter_range
  177. else:
  178. @rename(new_value=self.name)
  179. @describe(new_value=self.brief)
  180. async def setter_int(interaction: Interaction, new_value: int) -> None:
  181. await setter_general(interaction, new_value)
  182. setter = setter_int
  183. elif self.datatype == float:
  184. @rename(new_value=self.name)
  185. @describe(new_value=self.brief)
  186. async def setter_float(interaction: Interaction, new_value: float) -> None:
  187. await setter_general(interaction, new_value)
  188. setter = setter_float
  189. elif self.datatype == timedelta:
  190. @rename(new_value=self.name)
  191. @describe(new_value=f'{self.brief} (e.g. 30s, 5m, 1h30s, or 7d)')
  192. async def setter_timedelta(interaction: Interaction, new_value: Transform[timedelta, TimeDeltaTransformer]) -> None:
  193. await setter_general(interaction, new_value)
  194. setter = setter_timedelta
  195. elif getattr(self.datatype, '__origin__', None) == Literal:
  196. dt = self.datatype
  197. @rename(new_value=self.name)
  198. @describe(new_value=self.brief)
  199. async def setter_enum(interaction: Interaction, new_value: dt) -> None:
  200. await setter_general(interaction, new_value)
  201. setter = setter_enum
  202. elif self.datatype == str:
  203. if self.enum_values is not None:
  204. raise ValueError('Type for a setting with enum values should be typing.Literal')
  205. else:
  206. @rename(new_value=self.name)
  207. @describe(new_value=self.brief)
  208. async def setter_str(interaction: Interaction, new_value: str) -> None:
  209. await setter_general(interaction, new_value)
  210. setter = setter_str
  211. elif self.datatype == bool:
  212. @rename(new_value=self.name)
  213. @describe(new_value=self.brief)
  214. async def setter_bool(interaction: Interaction, new_value: bool) -> None:
  215. await setter_general(interaction, new_value)
  216. setter = setter_bool
  217. elif self.datatype is not None:
  218. raise ValueError(f'Invalid type {self.datatype}')
  219. bot_log(None, cog.__class__, f"Creating /set {setting_name} {self.datatype}")
  220. command = Command(
  221. name=setting_name,
  222. description=f'Sets {self.brief}.',
  223. callback=setter,
  224. parent=CogSetting.__set_group,
  225. extras={
  226. 'cog': cog,
  227. 'setting': setting,
  228. 'long_description': setting.description,
  229. },
  230. )
  231. return command
  232. def __make_enable_command(self, cog: 'BaseCog') -> Command:
  233. setting: CogSetting = self
  234. async def enabler(interaction: Interaction) -> None:
  235. key = f'{cog.__class__.__name__}.{setting.name}'
  236. Storage.set_config_value(interaction.guild, key, True)
  237. await interaction.response.send_message(
  238. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} enabled.',
  239. ephemeral=True
  240. )
  241. await cog.on_setting_updated(interaction.guild, setting)
  242. cog.log(interaction.guild, f'{interaction.user.name} enabled {cog.__class__.__name__}')
  243. bot_log(None, cog.__class__, f"Creating /enable {cog.config_prefix}")
  244. command = Command(
  245. name=cog.config_prefix,
  246. description=f'Enables {cog.qualified_name} functionality.',
  247. callback=enabler,
  248. parent=CogSetting.__enable_group,
  249. extras={
  250. 'cog': cog,
  251. 'setting': setting,
  252. 'long_description': setting.description,
  253. },
  254. )
  255. return command
  256. def __make_disable_command(self, cog: 'BaseCog') -> Command:
  257. setting: CogSetting = self
  258. async def disabler(interaction: Interaction) -> None:
  259. key = f'{cog.__class__.__name__}.{setting.name}'
  260. Storage.set_config_value(interaction.guild, key, False)
  261. await interaction.response.send_message(
  262. f'{CONFIG["success_emoji"]} {setting.brief.capitalize()} disabled.',
  263. ephemeral=True
  264. )
  265. await cog.on_setting_updated(interaction.guild, setting)
  266. cog.log(interaction.guild, f'{interaction.user.name} disabled {cog.__class__.__name__}')
  267. bot_log(None, cog.__class__, f"Creating /disable {cog.config_prefix}")
  268. command = Command(
  269. name=cog.config_prefix,
  270. description=f'Disables {cog.config_prefix} functionality',
  271. callback=disabler,
  272. parent=CogSetting.__disable_group,
  273. extras={
  274. 'cog': cog,
  275. 'setting': setting,
  276. 'long_description': setting.description,
  277. },
  278. )
  279. return command
  280. __set_group: Group
  281. __get_group: Group
  282. __enable_group: Group
  283. __disable_group: Group
  284. @classmethod
  285. def set_up_all(cls, cog: 'BaseCog', bot: Bot, settings: list['CogSetting']) -> None:
  286. """
  287. Sets up editing commands for a list of CogSettings and adds them to a
  288. cog. If the cog has a command Group, commands will be added to it.
  289. Otherwise, they will be added at the top level.
  290. """
  291. cls.__set_up_base_commands(bot)
  292. if len(settings) == 0:
  293. return
  294. bot_log(None, cog.__class__, f"Setting up slash commands for {cog.__class__.__name__}")
  295. for setting in settings:
  296. setting.set_up(cog)
  297. bot_log(None, cog.__class__, f"Done setting up slash commands for {cog.__class__.__name__}")
  298. @classmethod
  299. def __set_up_base_commands(cls, bot: Bot) -> None:
  300. if getattr(cls, f'_CogSetting__set_group', None) is not None:
  301. return
  302. cls.__set_group = Group(
  303. name='set',
  304. description='Sets a configuration value for this guild.',
  305. default_permissions=MOD_PERMISSIONS,
  306. extras={
  307. 'long_description': 'Settings are guild-specific. If no value is set, a default is used. Use `/get` to '
  308. 'see the current value for this guild.',
  309. },
  310. )
  311. cls.__get_group = Group(
  312. name='get',
  313. description='Shows a configuration value for this guild.',
  314. default_permissions=MOD_PERMISSIONS,
  315. extras={
  316. 'long_description': 'Settings are guild-specific. Shows the configured value or default value for a '
  317. 'variable for this guild. Use `/set` to change the value.',
  318. },
  319. )
  320. cls.__enable_group = Group(
  321. name='enable',
  322. description='Enables a module for this guild',
  323. default_permissions=MOD_PERMISSIONS,
  324. extras={
  325. 'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/disable` '
  326. 'to disable an enabled module.',
  327. },
  328. )
  329. cls.__disable_group = Group(
  330. name='disable',
  331. description='Disables a module for this guild.',
  332. default_permissions=MOD_PERMISSIONS,
  333. extras={
  334. 'long_description': 'Modules are enabled on a per-guild basis and are off by default. Use `/enable` '
  335. 're-enable a disabled module.',
  336. },
  337. )
  338. bot.tree.add_command(cls.__set_group)
  339. bot.tree.add_command(cls.__get_group)
  340. bot.tree.add_command(cls.__enable_group)
  341. bot.tree.add_command(cls.__disable_group)
  342. from rocketbot.cogs.basecog import BaseCog
  343. async def show_all(interaction: Interaction) -> None:
  344. try:
  345. guild = interaction.guild
  346. if guild is None:
  347. await interaction.response.send_message(
  348. f'{CONFIG["error_emoji"]} No guild.',
  349. ephemeral=True,
  350. delete_after=10,
  351. )
  352. return
  353. text = '## :information_source: Configuration'
  354. for cog_name, cog in sorted(bot.cogs.items()):
  355. if not isinstance(cog, BaseCog):
  356. continue
  357. bcog: BaseCog = cog
  358. if len(bcog.settings) == 0:
  359. continue
  360. text += f'\n### {bcog.qualified_name} Module'
  361. for setting in sorted(bcog.settings, key=lambda s: (s.name != 'enabled', s.name)):
  362. key = f'{bcog.__class__.__name__}.{setting.name}'
  363. value = setting.to_native_value(Storage.get_config_value(guild, key))
  364. deflt = setting.to_native_value(bcog.get_cog_default(setting.name))
  365. if setting.name == 'enabled':
  366. text += f'\n- Module is '
  367. if value is not None:
  368. text += '**' + ('enabled' if value else 'disabled') + '**'
  369. else:
  370. text += ('enabled' if deflt else 'disabled') + ' _(default)_'
  371. else:
  372. if value is not None:
  373. text += f'\n- `{bcog.config_prefix}_{setting.name}` = **{CogSetting.native_value_to_str(value)}**'
  374. else:
  375. text += f'\n- `{bcog.config_prefix}_{setting.name}` = {CogSetting.native_value_to_str(deflt)} _(using default)_'
  376. await interaction.response.send_message(
  377. text,
  378. ephemeral=True,
  379. )
  380. except BaseException as e:
  381. dump_stacktrace(e)
  382. show_all_command = Command(
  383. name='all',
  384. description='Shows all configuration for this guild.',
  385. callback=show_all,
  386. )
  387. cls.__get_group.add_command(show_all_command)