Experimental Discord bot written in Python
您最多选择25个主题 主题必须以字母或数字开头,可以包含连字符 (-),并且长度不得超过35个字符

helpcog.py 18KB

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