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.

generalcog.py 11KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295
  1. """
  2. Cog for handling most ungrouped commands and basic behaviors.
  3. """
  4. from datetime import datetime, timedelta, timezone
  5. from typing import Optional, Union
  6. from discord import Interaction, Message, User, Permissions
  7. from discord.app_commands import Command, Group, command, default_permissions, guild_only, Transform, rename, Choice, \
  8. autocomplete
  9. from discord.errors import DiscordException
  10. from discord.ext.commands import Cog
  11. from config import CONFIG
  12. from rocketbot.bot import Rocketbot
  13. from rocketbot.cogs.basecog import BaseCog, BotMessage
  14. from rocketbot.utils import describe_timedelta, TimeDeltaTransformer, dump_stacktrace
  15. from rocketbot.storage import ConfigKey, Storage
  16. async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  17. choices: list[Choice] = []
  18. try:
  19. if current.startswith('/'):
  20. current = current[1:]
  21. current = current.lower().strip()
  22. user_permissions = interaction.permissions
  23. cmds = GeneralCog.shared.get_command_list(user_permissions)
  24. return [
  25. Choice(name=f'/{cmdname}', value=f'/{cmdname}')
  26. for cmdname in sorted(cmds.keys())
  27. if len(current) == 0 or cmdname.startswith(current)
  28. ]
  29. except BaseException as e:
  30. dump_stacktrace(e)
  31. return choices
  32. async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  33. try:
  34. current = current.lower().strip()
  35. cmd_name = interaction.namespace['command']
  36. if cmd_name.startswith('/'):
  37. cmd_name = cmd_name[1:]
  38. user_permissions = interaction.permissions
  39. cmd = GeneralCog.shared.get_command_list(user_permissions).get(cmd_name)
  40. if cmd is None or not isinstance(cmd, Group):
  41. print(f'No command found named {cmd_name}')
  42. return []
  43. grp = cmd
  44. subcmds = GeneralCog.shared.get_subcommand_list(grp, user_permissions)
  45. if subcmds is None:
  46. print(f'Subcommands for {cmd_name} was None')
  47. return []
  48. return [
  49. Choice(name=subcmd_name, value=subcmd_name)
  50. for subcmd_name in sorted(subcmds.keys())
  51. if len(current) == 0 or subcmd_name.startswith(current)
  52. ]
  53. except BaseException as e:
  54. dump_stacktrace(e)
  55. return []
  56. def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
  57. return user_permissions is not None and \
  58. (cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions))
  59. class GeneralCog(BaseCog, name='General'):
  60. """
  61. Cog for handling high-level bot functionality and commands. Should be the
  62. first cog added to the bot.
  63. """
  64. shared: Optional['GeneralCog'] = None
  65. def __init__(self, bot: Rocketbot):
  66. super().__init__(
  67. bot,
  68. config_prefix=None,
  69. name='',
  70. short_description='',
  71. )
  72. self.is_connected = False
  73. self.is_first_connect = True
  74. self.last_disconnect_time: Optional[datetime] = None
  75. self.noteworthy_disconnect_duration = timedelta(seconds=5)
  76. GeneralCog.shared = self
  77. @Cog.listener()
  78. async def on_connect(self):
  79. """Event handler"""
  80. if self.is_first_connect:
  81. self.log(None, 'Connected')
  82. self.is_first_connect = False
  83. else:
  84. disconnect_duration = datetime.now(
  85. timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
  86. if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
  87. self.log(None, f'Reconnected after {disconnect_duration.total_seconds()} seconds')
  88. self.is_connected = True
  89. @Cog.listener()
  90. async def on_disconnect(self):
  91. """Event handler"""
  92. self.last_disconnect_time = datetime.now(timezone.utc)
  93. # self.log(None, 'Disconnected')
  94. @Cog.listener()
  95. async def on_resumed(self):
  96. """Event handler"""
  97. disconnect_duration = datetime.now(timezone.utc) - self.last_disconnect_time if self.last_disconnect_time else None
  98. if disconnect_duration is not None and disconnect_duration > self.noteworthy_disconnect_duration:
  99. self.log(None, f'Session resumed after {disconnect_duration.total_seconds()} seconds')
  100. @command(
  101. description='Posts a test warning',
  102. extras={
  103. 'long_description': 'Tests whether a warning channel is configured for this ' + \
  104. 'guild by posting a test warning. If a mod mention is ' + \
  105. 'configured, that user/role will be tagged in the test warning.',
  106. },
  107. )
  108. @guild_only()
  109. @default_permissions(ban_members=True)
  110. async def test_warn(self, interaction: Interaction):
  111. """Command handler"""
  112. if Storage.get_config_value(interaction.guild, ConfigKey.WARNING_CHANNEL_ID) is None:
  113. await interaction.response.send_message(
  114. f'{CONFIG["warning_emoji"]} No warning channel set!',
  115. ephemeral=True,
  116. )
  117. else:
  118. bm = BotMessage(
  119. interaction.guild,
  120. f'Test warning message (requested by {interaction.user.name})',
  121. type=BotMessage.TYPE_MOD_WARNING)
  122. await self.post_message(bm)
  123. await interaction.response.send_message(
  124. 'Warning issued',
  125. ephemeral=True,
  126. )
  127. @command(
  128. description='Simple test reply',
  129. extras={
  130. 'long_description': 'Replies to the command message. Useful to ensure the ' + \
  131. 'bot is working properly.',
  132. },
  133. )
  134. async def hello(self, interaction: Interaction):
  135. """Command handler"""
  136. await interaction.response.send_message(
  137. f'Hey, {interaction.user.name}!',
  138. ephemeral=True,
  139. )
  140. @command(
  141. description='Shuts down the bot',
  142. extras={
  143. 'long_description': 'Causes the bot script to terminate. Only usable by a ' + \
  144. 'user with server admin permissions.',
  145. },
  146. )
  147. @guild_only()
  148. @default_permissions(administrator=True)
  149. async def shutdown(self, interaction: Interaction):
  150. """Command handler"""
  151. await interaction.response.send_message('👋', ephemeral=True)
  152. await self.bot.close()
  153. @command(
  154. description='Mass deletes messages',
  155. extras={
  156. 'long_description': 'Deletes recent messages by the given user. The user ' +
  157. 'can be either an @ mention or a numeric user ID. The age is ' +
  158. 'a duration, such as "30s", "5m", "1h30m". Only the most ' +
  159. 'recent 100 messages in each channel are searched.',
  160. 'usage': '<user:id|mention> <age:timespan>',
  161. },
  162. )
  163. @guild_only()
  164. @default_permissions(manage_messages=True)
  165. async def delete_messages(self, interaction: Interaction, user: User, age: Transform[timedelta, TimeDeltaTransformer]) -> None:
  166. """Command handler"""
  167. member_id = user.id
  168. cutoff: datetime = datetime.now(timezone.utc) - age
  169. def predicate(message: Message) -> bool:
  170. return str(message.author.id) == member_id and message.created_at >= cutoff
  171. deleted_messages = []
  172. for channel in interaction.guild.text_channels:
  173. try:
  174. deleted_messages += await channel.purge(limit=100, check=predicate)
  175. except DiscordException:
  176. # XXX: Sloppily glossing over access errors instead of checking access
  177. pass
  178. await interaction.response.send_message(
  179. f'{CONFIG["success_emoji"]} Deleted {len(deleted_messages)} ' + \
  180. f'messages by {user.mention}> from the past {describe_timedelta(age)}.',
  181. ephemeral=True,
  182. )
  183. @command(name='help')
  184. @guild_only()
  185. @rename(command_name='command', subcommand_name='subcommand')
  186. @autocomplete(command_name=command_autocomplete, subcommand_name=subcommand_autocomplete)
  187. async def help_command(self, interaction: Interaction, command_name: Optional[str] = None, subcommand_name: Optional[str] = None) -> None:
  188. """
  189. Shows help for using commands and subcommands.
  190. `/help` will show a list of top-level commands.
  191. `/help /<command_name>` will show help about a specific command or
  192. list a command's subcommands.
  193. `/help /<command_name> <subcommand_name>` will show help about a
  194. specific subcommand.
  195. Parameters
  196. ----------
  197. interaction: Interaction
  198. command_name: Optional[str]
  199. Optional name of a command to get specific help for. With or without the leading slash.
  200. subcommand_name: Optional[str]
  201. Optional name of a subcommand to get specific help for.
  202. """
  203. print(f'help_command(interaction, {command_name}, {subcommand_name})')
  204. cmds: list[Command] = self.bot.tree.get_commands()
  205. if command_name is None:
  206. await self.__send_general_help(interaction)
  207. return
  208. if command_name.startswith('/'):
  209. command_name = command_name[1:]
  210. cmd = next((c for c in cmds if c.name == command_name), None)
  211. if cmd is None:
  212. interaction.response.send_message(
  213. f'Command `{command_name}` not found!',
  214. ephemeral=True,
  215. )
  216. return
  217. if subcommand_name is None:
  218. await self.__send_command_help(interaction, cmd)
  219. return
  220. if not isinstance(cmd, Group):
  221. await self.__send_command_help(interaction, cmd, addendum=f'{CONFIG["warning_emoji"]} Command does not have subcommands. Showing help for base command.')
  222. return
  223. grp: Group = cmd
  224. subcmd: Command = next((c for c in grp.commands if c.name == subcommand_name), None)
  225. if subcmd is None:
  226. await self.__send_command_help(interaction, cmd, addendum=f'{CONFIG["warning_emoji"]} Command `/{command_name}` does not have a subcommand "{subcommand_name}". Showing help for base command.')
  227. return
  228. await self.__send_subcommand_help(interaction, grp, subcmd)
  229. return
  230. def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
  231. return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
  232. def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
  233. return { subcmd.name: subcmd for subcmd in cmd.commands if can_use_command(subcmd, permissions) }
  234. async def __send_general_help(self, interaction: Interaction) -> None:
  235. user_permissions: Permissions = interaction.permissions
  236. text = f'## :information_source: Commands'
  237. for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
  238. text += f'\n- `/{cmd_name}`: {cmd.description}'
  239. await interaction.response.send_message(
  240. text,
  241. ephemeral=True,
  242. )
  243. async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
  244. text = ''
  245. if addendum is not None:
  246. text += addendum + '\n\n'
  247. text += f'## :information_source: Command Help\n`/{command_or_group.name}`\n\n{command_or_group.description}'
  248. if isinstance(command_or_group, Group):
  249. subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
  250. if len(subcmds) > 0:
  251. text += '\n\n### Subcommands:'
  252. for subcmd_name, subcmd in sorted(subcmds.items()):
  253. text += f'\n- `{subcmd_name}`: {subcmd.description}'
  254. else:
  255. params = command_or_group.parameters
  256. if len(params) > 0:
  257. text += '\n\n### Parameters:'
  258. for param in params:
  259. text += f'\n- `{param.name}`: {param.description}'
  260. await interaction.response.send_message(text, ephemeral=True)
  261. async def __send_subcommand_help(self, interaction: Interaction, group: Group, subcommand: Command) -> None:
  262. text = f'## :information_source: Subcommand Help\n`/{group.name} {subcommand.name}`\n\n{subcommand.description}\n\n{subcommand.description}'
  263. params = subcommand.parameters
  264. if len(params) > 0:
  265. text += '\n\n### Parameters:'
  266. for param in params:
  267. text += f'\n- `{param.name}`: {param.description}'
  268. await interaction.response.send_message(text, ephemeral=True)