Experimental Discord bot written in Python
Ви не можете вибрати більше 25 тем Теми мають розпочинатися з літери або цифри, можуть містити дефіси (-) і не повинні перевищувати 35 символів.

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