Experimental Discord bot written in Python
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

bangcommandcog.py 11KB

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