Experimental Discord bot written in Python
Вы не можете выбрать более 25 тем Темы должны начинаться с буквы или цифры, могут содержать дефисы(-) и должны содержать не более 35 символов.

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