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 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)