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.

helpcog.py 17KB

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