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.

bangcommandcog.py 11KB

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