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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381
  1. import re
  2. from typing import Union, Optional
  3. from discord import Interaction, Permissions, AppCommandType
  4. from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
  5. from config import CONFIG
  6. from rocketbot.bot import Rocketbot
  7. from rocketbot.cogs.basecog import BaseCog
  8. from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
  9. async def command_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  10. """Autocomplete handler for top-level command names."""
  11. choices: list[Choice] = []
  12. try:
  13. if current.startswith('/'):
  14. current = current[1:]
  15. current = current.lower().strip()
  16. user_permissions = interaction.permissions
  17. cmds = HelpCog.shared.get_command_list(user_permissions)
  18. return [
  19. Choice(name=f'/{cmdname} command', value=f'cmd:{cmdname}')
  20. for cmdname in sorted(cmds.keys())
  21. if len(current) == 0 or current in cmdname
  22. ][:25]
  23. except BaseException as e:
  24. dump_stacktrace(e)
  25. return choices
  26. async def subcommand_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  27. """Autocomplete handler for subcommand names. Command taken from previous command token."""
  28. try:
  29. current = current.lower().strip()
  30. cmd_name = interaction.namespace['topic']
  31. cmd = HelpCog.shared.object_for_help_symbol(cmd_name)
  32. if not isinstance(cmd, Group):
  33. return []
  34. user_permissions = interaction.permissions
  35. if cmd is None or not isinstance(cmd, Group):
  36. print(f'No command found named {cmd_name}')
  37. return []
  38. grp = cmd
  39. subcmds = HelpCog.shared.get_subcommand_list(grp, user_permissions)
  40. if subcmds is None:
  41. print(f'Subcommands for {cmd_name} was None')
  42. return []
  43. return [
  44. Choice(name=f'{subcmd_name} subcommand', value=f'subcmd:{cmd_name}.{subcmd_name}')
  45. for subcmd_name in sorted(subcmds.keys())
  46. if len(current) == 0 or current in subcmd_name
  47. ][:25]
  48. except BaseException as e:
  49. dump_stacktrace(e)
  50. return []
  51. async def cog_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  52. """Autocomplete handler for cog names."""
  53. try:
  54. current = current.lower().strip()
  55. return [
  56. Choice(name=f'⚙ {cog.qualified_name} module', value=f'cog:{cog.qualified_name}')
  57. for cog in sorted(HelpCog.shared.bot.cogs.values(), key=lambda c: c.qualified_name)
  58. if isinstance(cog, BaseCog) and
  59. can_use_cog(cog, interaction.permissions) and
  60. (len(cog.get_commands()) > 0 or len(cog.settings) > 0) and \
  61. (len(current) == 0 or current in cog.qualified_name.lower())
  62. ]
  63. except BaseException as e:
  64. dump_stacktrace(e)
  65. return []
  66. async def topic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  67. """Autocomplete handler that combines slash commands and cog names."""
  68. command_choices = await command_autocomplete(interaction, current)
  69. cog_choices = await cog_autocomplete(interaction, current)
  70. return (command_choices + cog_choices)[:25]
  71. async def subtopic_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
  72. """Autocomplete handler for subtopic names. Currently just handles subcommands."""
  73. subcommand_choices = await subcommand_autocomplete(interaction, current)
  74. return subcommand_choices[:25]
  75. class HelpCog(BaseCog, name='Help'):
  76. shared: Optional['HelpCog'] = None
  77. def __init__(self, bot: Rocketbot):
  78. super().__init__(
  79. bot,
  80. config_prefix='help',
  81. short_description='Provides help on using commands and modules.'
  82. )
  83. HelpCog.shared = self
  84. def __create_help_index(self) -> None:
  85. """
  86. Populates self.obj_index and self.keyword_index. Bails if already
  87. populated. Intended to be run on demand so all cogs and commands have
  88. had time to get set up and synced.
  89. """
  90. if getattr(self, 'obj_index', None) is not None:
  91. return
  92. self.obj_index: dict[str, Union[Command, Group, BaseCog]] = {}
  93. self.keyword_index: dict[str, set[Union[Command, Group, BaseCog]]] = {}
  94. def add_text_to_index(obj, text: str):
  95. words = [
  96. word
  97. for word in re.split(r"[^a-zA-Z']+", text.lower())
  98. if len(word) > 1 and word not in trivial_words
  99. ]
  100. for word in words:
  101. matches = self.keyword_index.get(word, set())
  102. matches.add(obj)
  103. self.keyword_index[word] = matches
  104. # PyCharm not interpreting conditional return type correctly.
  105. # noinspection PyTypeChecker
  106. cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
  107. for cmd in cmds:
  108. key = f'cmd:{cmd.name}'
  109. self.obj_index[key] = cmd
  110. add_text_to_index(cmd, cmd.name)
  111. if cmd.description:
  112. add_text_to_index(cmd, cmd.description)
  113. if isinstance(cmd, Group):
  114. for subcmd in cmd.commands:
  115. key = f'subcmd:{cmd.name}.{subcmd.name}'
  116. self.obj_index[key] = subcmd
  117. add_text_to_index(subcmd, subcmd.name)
  118. if subcmd.description:
  119. add_text_to_index(subcmd, subcmd.description)
  120. for cog_qname, cog in self.bot.cogs.items():
  121. if not isinstance(cog, BaseCog):
  122. continue
  123. key = f'cog:{cog_qname}'
  124. self.obj_index[key] = cog
  125. add_text_to_index(cog, cog.qualified_name)
  126. if cog.description:
  127. add_text_to_index(cog, cog.description)
  128. print(self.obj_index.keys())
  129. print(self.keyword_index.keys())
  130. def object_for_help_symbol(self, symbol: str) -> Optional[Union[Command, Group, BaseCog]]:
  131. self.__create_help_index()
  132. return self.obj_index.get(symbol, None)
  133. @command(name='help')
  134. @guild_only()
  135. @autocomplete(topic=topic_autocomplete, subtopic=subtopic_autocomplete)
  136. async def help_command(self, interaction: Interaction, topic: Optional[str] = None, subtopic: Optional[str] = None) -> None:
  137. """
  138. Shows help for using commands and subcommands and configuring modules.
  139. `/help` will show a list of top-level topics.
  140. `/help /<command_name>` will show help about a specific command or
  141. list a command's subcommands.
  142. `/help /<command_name> <subcommand_name>` will show help about a
  143. specific subcommand.
  144. `/help <module_name>` will show help about configuring a module.
  145. `/help <keywords>` will do a text search for topics.
  146. Parameters
  147. ----------
  148. interaction: Interaction
  149. topic: Optional[str]
  150. Optional topic to get specific help for. Getting help on a command can optionally start with a leading slash.
  151. subtopic: Optional[str]
  152. Optional subtopic to get specific help for.
  153. """
  154. print(f'help_command(interaction, {topic}, {subtopic})')
  155. # General help
  156. if topic is None:
  157. await self.__send_general_help(interaction)
  158. return
  159. # Specific object reference
  160. obj = self.object_for_help_symbol(subtopic) if subtopic else self.object_for_help_symbol(topic)
  161. if obj:
  162. await self.__send_object_help(interaction, obj)
  163. return
  164. # Text search
  165. keywords = [
  166. word
  167. for word in re.split(r"[^a-zA-Z']+", topic.lower())
  168. if len(word) > 0 and word not in trivial_words
  169. ]
  170. matching_objects_set = None
  171. for keyword in keywords:
  172. objs = self.keyword_index.get(keyword, None)
  173. if objs is not None:
  174. if matching_objects_set is None:
  175. matching_objects_set = objs
  176. else:
  177. matching_objects_set = matching_objects_set & objs
  178. accessible_objects = [
  179. obj
  180. for obj in matching_objects_set or {}
  181. if ((isinstance(obj, Command) or isinstance(obj, Group)) and can_use_command(obj, interaction.permissions)) or \
  182. (isinstance(obj, BaseCog) and can_use_cog(obj, interaction.permissions))
  183. ]
  184. await self.__send_keyword_help(interaction, accessible_objects)
  185. async def __send_object_help(self, interaction: Interaction, obj: Union[Command, Group, BaseCog]) -> None:
  186. if isinstance(obj, Command):
  187. if obj.parent:
  188. await self.__send_subcommand_help(interaction, obj.parent, obj)
  189. else:
  190. await self.__send_command_help(interaction, obj)
  191. return
  192. if isinstance(obj, Group):
  193. await self.__send_command_help(interaction, obj)
  194. return
  195. if isinstance(obj, BaseCog):
  196. await self.__send_cog_help(interaction, obj)
  197. return
  198. print(f'No help for object {obj}')
  199. await interaction.response.send_message(
  200. f'{CONFIG["failure_emoji"]} Failed to get help info.',
  201. ephemeral=True,
  202. delete_after=10,
  203. )
  204. def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
  205. return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
  206. def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
  207. return {
  208. subcmd.name: subcmd
  209. for subcmd in cmd.commands
  210. if can_use_command(subcmd, permissions)
  211. } if can_use_command(cmd, permissions) else {}
  212. async def __send_general_help(self, interaction: Interaction) -> None:
  213. user_permissions: Permissions = interaction.permissions
  214. all_commands = sorted(self.get_command_list(user_permissions).items())
  215. all_cog_tuples: list[tuple[str, BaseCog]] = [
  216. cog_tuple
  217. for cog_tuple in sorted(self.basecog_map.items())
  218. if can_use_cog(cog_tuple[1], user_permissions) and \
  219. (len(cog_tuple[1].settings) > 0)
  220. ]
  221. text = f'## :information_source: Help'
  222. if len(all_commands) + len(all_cog_tuples) == 0:
  223. text = 'Nothing available for your permissions!'
  224. if len(all_commands) > 0:
  225. text += '\n### Commands'
  226. text += '\nType `/help /commandname` for more information.'
  227. for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
  228. text += f'\n- `/{cmd_name}`: {cmd.description}'
  229. if isinstance(cmd, Group):
  230. subcommand_count = len(cmd.commands)
  231. text += f' ({subcommand_count} subcommands)'
  232. if len(all_cog_tuples) > 0:
  233. text += '\n### Module Configuration'
  234. for cog_name, cog in all_cog_tuples:
  235. has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
  236. text += f'\n- **{cog_name}**: {cog.short_description}'
  237. if has_enabled:
  238. text += f'\n - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  239. for setting in cog.settings:
  240. if setting.name == 'enabled':
  241. continue
  242. text += f'\n - `/get` or `/set {cog.config_prefix}_{setting.name}`'
  243. await interaction.response.send_message(
  244. text,
  245. ephemeral=True,
  246. )
  247. async def __send_keyword_help(self, interaction: Interaction, matching_objects: Optional[list[Union[Command, Group, BaseCog]]]) -> None:
  248. matching_commands = [
  249. cmd
  250. for cmd in matching_objects or []
  251. if isinstance(cmd, Command) or isinstance(cmd, Group)
  252. ]
  253. matching_cogs = [
  254. cog
  255. for cog in matching_objects or []
  256. if isinstance(cog, BaseCog)
  257. ]
  258. if len(matching_commands) + len(matching_cogs) == 0:
  259. await interaction.response.send_message(
  260. f'{CONFIG["failure_emoji"]} No available help topics found.',
  261. ephemeral=True,
  262. delete_after=10,
  263. )
  264. return
  265. if len(matching_objects) == 1:
  266. obj = matching_objects[0]
  267. await self.__send_object_help(interaction, obj)
  268. return
  269. text = '## :information_source: Matching Help Topics'
  270. if len(matching_commands) > 0:
  271. text += '\n### Commands'
  272. for cmd in matching_commands:
  273. if cmd.parent:
  274. text += f'\n- `/{cmd.parent.name} {cmd.name}`'
  275. else:
  276. text += f'\n- `/{cmd.name}`'
  277. if len(matching_cogs) > 0:
  278. text += '\n### Cogs'
  279. for cog in matching_cogs:
  280. text += f'\n- {cog.qualified_name}'
  281. await interaction.response.send_message(
  282. text,
  283. ephemeral=True,
  284. )
  285. async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
  286. text = ''
  287. if addendum is not None:
  288. text += addendum + '\n\n'
  289. text += f'## :information_source: Command Help\n`/{command_or_group.name}`\n\n{command_or_group.description}'
  290. if isinstance(command_or_group, Group):
  291. subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
  292. if len(subcmds) > 0:
  293. text += '\n\n### Subcommands:'
  294. for subcmd_name, subcmd in sorted(subcmds.items()):
  295. text += f'\n- `{subcmd_name}`: {subcmd.description}'
  296. else:
  297. params = command_or_group.parameters
  298. if len(params) > 0:
  299. text += '\n\n### Parameters:'
  300. for param in params:
  301. text += f'\n- `{param.name}`: {param.description}'
  302. await interaction.response.send_message(text, ephemeral=True)
  303. async def __send_subcommand_help(self, interaction: Interaction, group: Group, subcommand: Command) -> None:
  304. text = f'## :information_source: Subcommand Help\n`/{group.name} {subcommand.name}`\n\n{subcommand.description}\n\n{subcommand.description}'
  305. params = subcommand.parameters
  306. if len(params) > 0:
  307. text += '\n\n### Parameters:'
  308. for param in params:
  309. text += f'\n- `{param.name}`: {param.description}'
  310. await interaction.response.send_message(text, ephemeral=True)
  311. async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
  312. text = f'## :information_source: Module Help\n{cog.qualified_name}'
  313. if cog.description is not None:
  314. text += f'\n{cog.description}'
  315. settings = cog.settings
  316. if len(settings) > 0:
  317. text += '\n### Configuration'
  318. enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
  319. if enabled_setting is not None:
  320. text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  321. for setting in sorted(settings, key=lambda s: s.name):
  322. if setting.name == 'enabled':
  323. continue
  324. text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.description}'
  325. await interaction.response.send_message(
  326. text,
  327. ephemeral=True,
  328. )
  329. # Exclusions from keyword indexing
  330. trivial_words = {
  331. 'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
  332. 'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
  333. 'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
  334. }
  335. def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
  336. if user_permissions is None:
  337. return False
  338. if cmd.parent and not can_use_command(cmd.parent, user_permissions):
  339. return False
  340. return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
  341. def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
  342. # "Using" a cog for now means configuring it, and only mods can configure cogs.
  343. return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)