Experimental Discord bot written in Python
選択できるのは25トピックまでです。 トピックは、先頭が英数字で、英数字とダッシュ('-')を使用した35文字以内のものにしてください。

helpcog.py 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  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, ButtonStyle, InteractionResponse
  6. from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
  7. from discord.ui import ActionRow, Button, LayoutView, TextDisplay
  8. from config import CONFIG
  9. from rocketbot.bot import Rocketbot
  10. from rocketbot.cogs.basecog import BaseCog
  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. if search is None:
  204. await self.__send_general_help(interaction)
  205. return
  206. topic = self.topic_for_help_symbol(search)
  207. if topic:
  208. await self.__send_topic_help(interaction, topic)
  209. return
  210. matches = self.topics_for_keywords(search, interaction.permissions)
  211. await self.__send_keyword_help(interaction, matches)
  212. async def __send_topic_help(self, interaction: Interaction, topic: HelpTopic) -> None:
  213. if isinstance(topic, Command):
  214. await self.__send_command_help(interaction, topic)
  215. return
  216. if isinstance(topic, Group):
  217. await self.__send_command_help(interaction, topic)
  218. return
  219. if isinstance(topic, BaseCog):
  220. await self.__send_cog_help(interaction, topic)
  221. return
  222. self.log(interaction.guild, f'No help for topic object {topic}')
  223. await interaction.response.send_message(
  224. f'{CONFIG["failure_emoji"]} Failed to get help info.',
  225. ephemeral=True,
  226. delete_after=10,
  227. )
  228. def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
  229. return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
  230. def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
  231. return {
  232. subcmd.name: subcmd
  233. for subcmd in cmd.commands
  234. if can_use_command(subcmd, permissions)
  235. } if can_use_command(cmd, permissions) else {}
  236. async def __send_general_help(self, interaction: Interaction) -> None:
  237. user_permissions: Permissions = interaction.permissions
  238. all_commands = sorted(self.get_command_list(user_permissions).items())
  239. all_cog_tuples: list[tuple[str, BaseCog]] = [
  240. cog_tuple
  241. for cog_tuple in sorted(self.basecog_map.items())
  242. if can_use_cog(cog_tuple[1], user_permissions) and \
  243. (len(cog_tuple[1].settings) > 0)
  244. ]
  245. text = f'## :information_source: Help'
  246. if len(all_commands) + len(all_cog_tuples) == 0:
  247. text = 'Nothing available for your permissions!'
  248. if len(all_commands) > 0:
  249. text += '\n### Commands'
  250. text += '\nType `/help /commandname` for more information.'
  251. for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
  252. text += f'\n- `/{cmd_name}`: {cmd.description}'
  253. if isinstance(cmd, Group):
  254. subcommand_count = len(cmd.commands)
  255. text += f' ({subcommand_count} subcommands)'
  256. text += PAGE_BREAK
  257. if len(all_cog_tuples) > 0:
  258. text += '\n### Module Configuration'
  259. for cog_name, cog in all_cog_tuples:
  260. has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
  261. text += f'\n- **{cog_name}**: {cog.short_description}'
  262. if has_enabled:
  263. text += f'\n - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  264. for setting in cog.settings:
  265. if setting.name == 'enabled':
  266. continue
  267. text += f'\n - `/get` or `/set {cog.config_prefix}_{setting.name}`'
  268. text += PAGE_BREAK
  269. await self.__send_paged_help(interaction, text)
  270. async def __send_keyword_help(self, interaction: Interaction, matching_topics: Optional[list[HelpTopic]]) -> None:
  271. matching_commands = [
  272. cmd
  273. for cmd in matching_topics or []
  274. if isinstance(cmd, Command) or isinstance(cmd, Group)
  275. ]
  276. matching_cogs = [
  277. cog
  278. for cog in matching_topics or []
  279. if isinstance(cog, BaseCog)
  280. ]
  281. if len(matching_commands) + len(matching_cogs) == 0:
  282. await interaction.response.send_message(
  283. f'{CONFIG["failure_emoji"]} No available help topics found.',
  284. ephemeral=True,
  285. delete_after=10,
  286. )
  287. return
  288. if len(matching_topics) == 1:
  289. topic = matching_topics[0]
  290. await self.__send_topic_help(interaction, topic)
  291. return
  292. text = '## :information_source: Matching Help Topics'
  293. if len(matching_commands) > 0:
  294. text += '\n### Commands'
  295. for cmd in matching_commands:
  296. if cmd.parent:
  297. text += f'\n- `/{cmd.parent.name} {cmd.name}`'
  298. else:
  299. text += f'\n- `/{cmd.name}`'
  300. if len(matching_cogs) > 0:
  301. text += '\n### Modules'
  302. for cog in matching_cogs:
  303. text += f'\n- {cog.qualified_name}'
  304. await self.__send_paged_help(interaction, text)
  305. async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
  306. text = ''
  307. if addendum is not None:
  308. text += addendum + '\n\n'
  309. if command_or_group.parent:
  310. text += f'## :information_source: Subcommand Help'
  311. text += f'\n`/{command_or_group.parent.name} {command_or_group.name}`'
  312. else:
  313. text += f'## :information_source: Command Help'
  314. if isinstance(command_or_group, Group):
  315. text += f'\n`/{command_or_group.name} subcommand_name`'
  316. else:
  317. text += f'\n`/{command_or_group.name}`'
  318. if isinstance(command_or_group, Command):
  319. optional_nesting = 0
  320. for param in command_or_group.parameters:
  321. text += ' '
  322. if not param.required:
  323. text += '['
  324. optional_nesting += 1
  325. text += f'_{param.name}_'
  326. if optional_nesting > 0:
  327. text += ']' * optional_nesting
  328. text += f'\n\n{command_or_group.description}'
  329. if command_or_group.extras.get('long_description'):
  330. text += f'\n\n{command_or_group.extras["long_description"]}'
  331. if isinstance(command_or_group, Group):
  332. subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
  333. if len(subcmds) > 0:
  334. text += '\n### Subcommands:'
  335. for subcmd_name, subcmd in sorted(subcmds.items()):
  336. text += f'\n- `{subcmd_name}`: {subcmd.description}'
  337. text += f'\n-# To use a subcommand, type it after the command. e.g. `/{command_or_group.name} subcommand_name`'
  338. text += f'\n-# Get help on a subcommand by typing `/help /{command_or_group.name} subcommand_name`'
  339. else:
  340. params = command_or_group.parameters
  341. if len(params) > 0:
  342. text += '\n### Parameters:'
  343. for param in params:
  344. text += f'\n- `{param.name}`: {param.description}'
  345. if not param.required:
  346. text += ' (optional)'
  347. await self.__send_paged_help(interaction, text)
  348. async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
  349. text = f'## :information_source: Module Help'
  350. text += f'\n**{cog.qualified_name}** module'
  351. if cog.short_description is not None:
  352. text += f'\n\n{cog.short_description}'
  353. if cog.long_description is not None:
  354. text += f'\n\n{cog.long_description}'
  355. cmds = [
  356. cmd
  357. for cmd in sorted(cog.get_app_commands(), key=lambda c: c.name)
  358. if can_use_command(cmd, interaction.permissions)
  359. ]
  360. if len(cmds) > 0:
  361. text += '\n### Commands:'
  362. for cmd in cmds:
  363. text += f'\n- `/{cmd.name}` - {cmd.description}'
  364. if isinstance(cmd, Group):
  365. subcmds = [ subcmd for subcmd in cmd.commands if can_use_command(subcmd, interaction.permissions) ]
  366. if len(subcmds) > 0:
  367. text += f' ({len(subcmds)} subcommands)'
  368. settings = cog.settings
  369. if len(settings) > 0:
  370. text += '\n### Configuration'
  371. enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
  372. if enabled_setting is not None:
  373. text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
  374. for setting in sorted(settings, key=lambda s: s.name):
  375. if setting.name == 'enabled':
  376. continue
  377. text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.brief}'
  378. await self.__send_paged_help(interaction, text)
  379. async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
  380. pages = _paginate(text)
  381. if len(pages) == 1:
  382. await interaction.response.send_message(pages[0], ephemeral=True)
  383. else:
  384. await _update_paged_help(interaction, None, 0, pages)
  385. def _paginate(text: str) -> list[str]:
  386. max_page_size = 2000
  387. chunks = text.split(PAGE_BREAK)
  388. pages = [ '' ]
  389. for chunk in chunks:
  390. if len(chunk) > max_page_size:
  391. raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
  392. if len(pages[-1] + chunk) < max_page_size:
  393. pages[-1] += chunk
  394. else:
  395. pages.append(chunk)
  396. page_count = len(pages)
  397. if page_count == 1:
  398. return pages
  399. # Do another pass and try to even out the page lengths
  400. indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
  401. even_pages = [
  402. ''.join(chunks[indices[i]:indices[i + 1]])
  403. for i in range(page_count)
  404. ]
  405. for page in even_pages:
  406. if len(page) > max_page_size:
  407. # We made a page too big. Give up.
  408. return pages
  409. return even_pages
  410. async def _update_paged_help(interaction: Interaction, original_interaction: Optional[Interaction], current_page: int, pages: list[str]) -> None:
  411. try:
  412. view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
  413. resolved = interaction
  414. if original_interaction is not None:
  415. # We have an original interaction from the initial command and a
  416. # new one from the button press. Use the original to swap in the
  417. # new page in place, then acknowledge the new one to satisfy the
  418. # API that we didn't fail.
  419. await original_interaction.edit_original_response(
  420. view=view,
  421. )
  422. if interaction is not original_interaction:
  423. await interaction.response.defer(ephemeral=True, thinking=False)
  424. else:
  425. # Initial send
  426. await resolved.response.send_message(
  427. view=view,
  428. ephemeral=True,
  429. delete_message_after=60,
  430. )
  431. except BaseException as e:
  432. dump_stacktrace(e)
  433. class _PagingLayoutView(LayoutView):
  434. def __init__(self, current_page: int, pages: list[str], original_interaction: Optional[Interaction]):
  435. super().__init__()
  436. self.current_page: int = current_page
  437. self.pages: list[str] = pages
  438. self.text.content = self.pages[self.current_page]
  439. self.original_interaction = original_interaction
  440. if current_page <= 0:
  441. self.handle_prev_button.disabled = True
  442. if current_page >= len(self.pages) - 1:
  443. self.handle_next_button.disabled = True
  444. text = TextDisplay('')
  445. row = ActionRow()
  446. @row.button(label='< Prev')
  447. async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
  448. new_page = max(0, self.current_page - 1)
  449. await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
  450. @row.button(label='Next >')
  451. async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
  452. new_page = min(len(self.pages) - 1, self.current_page + 1)
  453. await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
  454. # Exclusions from keyword indexing
  455. trivial_words = {
  456. 'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
  457. 'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
  458. 'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
  459. }
  460. def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
  461. if user_permissions is None:
  462. return False
  463. if cmd.parent and not can_use_command(cmd.parent, user_permissions):
  464. return False
  465. return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
  466. def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
  467. # "Using" a cog for now means configuring it, and only mods can configure cogs.
  468. return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)