Experimental Discord bot written in Python
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

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