Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

cogsetting.py 17KB

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