Experimental Discord bot written in Python
Du kannst nicht mehr als 25 Themen auswählen Themen müssen mit entweder einem Buchstaben oder einer Ziffer beginnen. Sie können Bindestriche („-“) enthalten und bis zu 35 Zeichen lang sein.

helpcog.py 16KB

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