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.

bangcommandcog.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340
  1. import re
  2. from typing import Optional, TypedDict
  3. from discord import Interaction, Guild, Message, TextChannel, SelectOption, TextStyle
  4. from discord.app_commands import Group, Choice, autocomplete
  5. from discord.ext.commands import Cog
  6. from discord.ui import Modal, Label, Select, TextInput
  7. from config import CONFIG
  8. from rocketbot.bot import Rocketbot
  9. from rocketbot.cogs.basecog import BaseCog
  10. from rocketbot.cogsetting import CogSetting
  11. from rocketbot.ui.pagedcontent import PAGE_BREAK, update_paged_content, paginate
  12. from rocketbot.utils import MOD_PERMISSIONS, blockquote_markdown, indent_markdown, dump_stacktrace
  13. _CURRENT_DATA_VERSION = 1
  14. _MAX_CONTENT_LENGTH = 2000
  15. class BangCommand(TypedDict):
  16. content: str
  17. mod_only: bool
  18. version: int
  19. async def command_autocomplete(interaction: Interaction, text: str) -> list[Choice[str]]:
  20. cmds = BangCommandCog.shared.get_saved_commands(interaction.guild)
  21. return [
  22. Choice(name=f'!{name}', value=name)
  23. for name, cmd in sorted(cmds.items())
  24. if len(text) == 0 or text.lower() in name
  25. ]
  26. class BangCommandCog(BaseCog, name='Bang Commands'):
  27. SETTING_COMMANDS = CogSetting(
  28. name='commands',
  29. datatype=dict[str, BangCommand],
  30. default_value={},
  31. )
  32. shared: Optional['BangCommandCog'] = None
  33. def __init__(self, bot: Rocketbot):
  34. super().__init__(
  35. bot,
  36. config_prefix='bangcommand',
  37. short_description='Provides custom informational chat !commands.',
  38. long_description='Bang commands are simple one-word messages starting with an exclamation '
  39. '(bang) that will make the bot respond with simple informational replies. '
  40. 'Useful for posting answers to frequently asked questions, reminding users '
  41. 'of rules, and similar.'
  42. )
  43. BangCommandCog.shared = self
  44. def get_saved_commands(self, guild: Guild) -> dict[str, BangCommand]:
  45. return self.get_guild_setting(guild, BangCommandCog.SETTING_COMMANDS)
  46. def get_saved_command(self, guild: Guild, name: str) -> Optional[BangCommand]:
  47. cmds = self.get_saved_commands(guild)
  48. name = BangCommandCog._normalize_name(name)
  49. return cmds.get(name, None)
  50. def set_saved_commands(self, guild: Guild, commands: dict[str, BangCommand]) -> None:
  51. self.set_guild_setting(guild, BangCommandCog.SETTING_COMMANDS, commands)
  52. bang = Group(
  53. name='command',
  54. description='Provides custom informational chat !commands.',
  55. guild_only=True,
  56. default_permissions=MOD_PERMISSIONS,
  57. )
  58. @bang.command(
  59. name='define',
  60. extras={
  61. 'long_description': 'Simple one-line content can be specified in the command. '
  62. 'For multi-line content, run the command without content '
  63. 'specified to use the editor popup.'
  64. }
  65. )
  66. @autocomplete(name=command_autocomplete)
  67. async def define_command(self, interaction: Interaction, name: str, definition: Optional[str] = None, mod_only: bool = False) -> None:
  68. """
  69. Defines or redefines a bang command.
  70. Parameters
  71. ----------
  72. interaction: Interaction
  73. name: string
  74. name of the command (lowercase a-z, underscores, and hyphens)
  75. definition: string
  76. content of the command
  77. mod_only: bool
  78. whether the command will only be recognized when a mod uses it
  79. """
  80. self.log(interaction.guild, f'{interaction.user.name} used command /bangcommand define {name} {definition} {mod_only}')
  81. name = BangCommandCog._normalize_name(name)
  82. if definition is None:
  83. cmd = self.get_saved_command(interaction.guild, name)
  84. await interaction.response.send_modal(
  85. _EditModal(
  86. name,
  87. content=cmd['content'] if cmd else None,
  88. mod_only=cmd['mod_only'] if cmd else None,
  89. exists=cmd is not None,
  90. )
  91. )
  92. return
  93. try:
  94. self.define(interaction.guild, name, definition, mod_only)
  95. await interaction.response.send_message(
  96. f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(definition)}',
  97. ephemeral=True,
  98. )
  99. except ValueError as e:
  100. await interaction.response.send_message(
  101. f'{CONFIG["failure_emoji"]} {e}',
  102. ephemeral=True,
  103. )
  104. return
  105. @bang.command(
  106. name='undefine'
  107. )
  108. @autocomplete(name=command_autocomplete)
  109. async def undefine_command(self, interaction: Interaction, name: str) -> None:
  110. """
  111. Removes a bang command.
  112. Parameters
  113. ----------
  114. interaction: Interaction
  115. name: string
  116. name of the previously defined command
  117. """
  118. try:
  119. self.undefine(interaction.guild, name)
  120. await interaction.response.send_message(
  121. f'{CONFIG["success_emoji"]} Command `!{name}` removed.',
  122. ephemeral=True,
  123. )
  124. except ValueError as e:
  125. await interaction.response.send_message(
  126. f'{CONFIG["failure_emoji"]} {e}',
  127. ephemeral=True,
  128. )
  129. @bang.command(
  130. name='list'
  131. )
  132. async def list_command(self, interaction: Interaction) -> None:
  133. """
  134. Lists all defined bang commands.
  135. Parameters
  136. ----------
  137. interaction: Interaction
  138. """
  139. cmds = self.get_saved_commands(interaction.guild)
  140. if cmds is None or len(cmds) == 0:
  141. await interaction.response.send_message(
  142. f'{CONFIG["info_emoji"]} No commands defined.',
  143. ephemeral=True,
  144. delete_after=15,
  145. )
  146. return
  147. text = '## Commands'
  148. for name, cmd in sorted(cmds.items()):
  149. text += PAGE_BREAK + f'\n- `!{name}`'
  150. if cmd['mod_only']:
  151. text += ' - **mod only**'
  152. text += f'\n{indent_markdown(cmd["content"])}'
  153. pages = paginate(text)
  154. await update_paged_content(interaction, None, 0, pages)
  155. @bang.command(
  156. name='invoke',
  157. extras={
  158. 'long_description': 'Useful when you do not want the command to show up in chat.',
  159. }
  160. )
  161. @autocomplete(name=command_autocomplete)
  162. async def invoke_command(self, interaction: Interaction, name: str) -> None:
  163. """
  164. Invokes a bang command without typing it in chat.
  165. Parameters
  166. ----------
  167. interaction: Interaction
  168. name: string
  169. the bang command name
  170. """
  171. cmd = self.get_saved_command(interaction.guild, name)
  172. if cmd is None:
  173. await interaction.response.send_message(
  174. f'{CONFIG["failure_emoji"]} Command `!{name}` does not exist.',
  175. ephemeral=True,
  176. )
  177. return
  178. resp = await interaction.response.defer(ephemeral=True, thinking=False)
  179. await interaction.channel.send(
  180. cmd['content']
  181. )
  182. if resp.resource:
  183. await resp.resource.delete()
  184. def define(self, guild: Guild, name: str, content: str, mod_only: bool, check_exists: bool = False) -> None:
  185. if not BangCommandCog._is_valid_name(name):
  186. raise ValueError('Invalid command name. Must consist of lowercase letters, underscores, and hyphens (no spaces).')
  187. name = BangCommandCog._normalize_name(name)
  188. if len(content) < 1 or len(content) > 2000:
  189. raise ValueError(f'Content must be between 1 and {_MAX_CONTENT_LENGTH} characters.')
  190. cmds = self.get_saved_commands(guild)
  191. if check_exists:
  192. if cmds.get(name, None) is not None:
  193. raise ValueError(f'Command with name "{name}" already exists.')
  194. cmds[name] = {
  195. 'content': content,
  196. 'mod_only': mod_only,
  197. 'version': _CURRENT_DATA_VERSION,
  198. }
  199. self.set_saved_commands(guild, cmds)
  200. def undefine(self, guild: Guild, name: str) -> None:
  201. name = BangCommandCog._normalize_name(name)
  202. cmds = self.get_saved_commands(guild)
  203. if cmds.get(name, None) is None:
  204. raise ValueError(f'Command with name "{name}" does not exist.')
  205. del cmds[name]
  206. self.set_saved_commands(guild, cmds)
  207. @Cog.listener()
  208. async def on_message(self, message: Message) -> None:
  209. if message.guild is None or message.channel is None or not isinstance(message.channel, TextChannel):
  210. return
  211. content = message.content
  212. if content is None or not content.startswith('!') or not BangCommandCog._is_valid_name(content):
  213. return
  214. name = BangCommandCog._normalize_name(content)
  215. cmd = self.get_saved_command(message.guild, name)
  216. if cmd is None:
  217. return
  218. if cmd['mod_only'] and not message.author.guild_permissions.ban_members:
  219. return
  220. text = cmd["content"]
  221. # text = f'{text}\n\n-# {message.author.name} used `!{name}`'
  222. await message.channel.send(
  223. text,
  224. )
  225. @staticmethod
  226. def _normalize_name(name: str) -> str:
  227. name = name.lower().strip()
  228. if name.startswith('!'):
  229. name = name[1:]
  230. return name
  231. @staticmethod
  232. def _is_valid_name(name: Optional[str]) -> bool:
  233. if name is None:
  234. return False
  235. return re.match(r'^!?([a-z]+)([_-][a-z]+)*$', name) is not None
  236. class _EditModal(Modal, title='Edit Command'):
  237. name_label = Label(
  238. text='Command name',
  239. description='What gets typed in chat to trigger the command. Must be a-z, underscores, and hyphens (no spaces).',
  240. component=TextInput(
  241. style=TextStyle.short, # one line
  242. placeholder='!command_name',
  243. min_length=1,
  244. max_length=100,
  245. )
  246. )
  247. content_label = Label(
  248. text='Content',
  249. description='The text the bot will respond with when someone uses the command. Can contain markdown.',
  250. component=TextInput(
  251. style=TextStyle.paragraph,
  252. placeholder='Lorem ipsum dolor...',
  253. min_length=1,
  254. max_length=2000,
  255. )
  256. )
  257. mod_only_label = Label(
  258. text='Mod only?',
  259. description='Whether mods are the only users who can invoke this command.',
  260. component=Select(
  261. options=[
  262. SelectOption(label='No', value='False',
  263. description='Anyone can invoke this command.'),
  264. SelectOption(label='Yes', value='True',
  265. description='Only mods can invoke this command.'),
  266. ],
  267. )
  268. )
  269. def __init__(self, name: Optional[str] = None, content: Optional[str] = None, mod_only: Optional[bool] = None, exists: bool = False):
  270. super().__init__()
  271. self.exists = exists
  272. # noinspection PyTypeChecker
  273. name_input: TextInput = self.name_label.component
  274. # noinspection PyTypeChecker
  275. content_input: TextInput = self.content_label.component
  276. # noinspection PyTypeChecker
  277. mod_only_input: Select = self.mod_only_label.component
  278. name_input.default = name
  279. content_input.default = content
  280. mod_only_input.options[0].default = mod_only != True
  281. mod_only_input.options[1].default = mod_only == True
  282. async def on_submit(self, interaction: Interaction) -> None:
  283. # noinspection PyTypeChecker
  284. name_input: TextInput = self.name_label.component
  285. # noinspection PyTypeChecker
  286. content_input: TextInput = self.content_label.component
  287. # noinspection PyTypeChecker
  288. mod_only_input: Select = self.mod_only_label.component
  289. name = name_input.value
  290. content = content_input.value
  291. mod_only = mod_only_input.values[0] == 'True'
  292. try:
  293. BangCommandCog.shared.define(interaction.guild, name, content, mod_only, not self.exists)
  294. await interaction.response.send_message(
  295. f'{CONFIG["success_emoji"]} Command `!{name}` has been defined.\n\n{blockquote_markdown(content)}',
  296. ephemeral=True,
  297. )
  298. except ValueError as e:
  299. await interaction.response.send_message(
  300. f'{CONFIG["failure_emoji"]} {e}',
  301. ephemeral=True,
  302. )
  303. async def on_error(self, interaction: Interaction, error: Exception) -> None:
  304. dump_stacktrace(error)
  305. try:
  306. await interaction.response.send_message(
  307. f'{CONFIG["failure_emoji"]} Save failed',
  308. ephemeral=True,
  309. )
  310. except:
  311. pass