Experimental Discord bot written in Python
Vous ne pouvez pas sélectionner plus de 25 sujets Les noms de sujets doivent commencer par une lettre ou un nombre, peuvent contenir des tirets ('-') et peuvent comporter jusqu'à 35 caractères.

cogsetting.py 16KB

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