| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521 |
- """Provides help commands for getting info on using other commands and configuration."""
- import re
- import time
- from typing import Union, Optional, TypedDict
-
- from discord import Interaction, Permissions, AppCommandType, ButtonStyle, InteractionResponse
- from discord.app_commands import Group, Command, autocomplete, guild_only, command, Choice
- from discord.ui import ActionRow, Button, LayoutView, TextDisplay
-
- from config import CONFIG
- from rocketbot.bot import Rocketbot
- from rocketbot.cogs.basecog import BaseCog
- from rocketbot.utils import MOD_PERMISSIONS, dump_stacktrace
-
- HelpTopic = Union[Command, Group, BaseCog]
- class HelpMeta(TypedDict):
- id: str
- text: str
- topic: HelpTopic
-
- # Potential place to break text neatly in large help content
- PAGE_BREAK = '\f'
-
- def choice_from_topic(topic: HelpTopic, include_full_command: bool = False) -> Choice:
- if isinstance(topic, BaseCog):
- return Choice(name=f'⚙ {topic.qualified_name}', value=f'cog:{topic.qualified_name}')
- if isinstance(topic, Group):
- return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
- if isinstance(topic, Command):
- if topic.parent:
- if include_full_command:
- return Choice(name=f'/{topic.parent.name} {topic.name}', value=f'subcmd:{topic.parent.name}.{topic.name}')
- return Choice(name=f'{topic.name}', value=f'subcmd:{topic.name}')
- return Choice(name=f'/{topic.name}', value=f'cmd:{topic.name}')
- return Choice(name='', value='')
-
- async def search_autocomplete(interaction: Interaction, current: str) -> list[Choice[str]]:
- try:
- if len(current) == 0:
- return [
- choice_from_topic(topic, include_full_command=True)
- for topic in HelpCog.shared.all_accessible_topics(interaction.permissions)
- ]
- return [
- choice_from_topic(topic, include_full_command=True)
- for topic in HelpCog.shared.topics_for_keywords(current, interaction.permissions)
- ][:25]
- except BaseException as e:
- dump_stacktrace(e)
- return []
-
- class HelpCog(BaseCog, name='Help'):
- shared: Optional['HelpCog'] = None
-
- def __init__(self, bot: Rocketbot):
- super().__init__(
- bot,
- config_prefix='help',
- short_description='Provides help on using this bot.'
- )
- HelpCog.shared = self
-
- def __create_help_index(self) -> None:
- """
- Populates self.id_to_topic and self.keyword_index. Bails if already
- populated. Intended to be run on demand so all cogs and commands have
- had time to get set up and synced.
- """
- if getattr(self, 'id_to_topic', None) is not None:
- return
- self.id_to_topic: dict[str, HelpTopic] = {}
- self.topics: list[HelpMeta] = []
-
- def process_text(t: str) -> str:
- return ' '.join([
- word
- for word in re.split(r"[^a-z']+", t.lower())
- if word not in trivial_words
- ]).strip()
-
- cmds = self.all_commands()
- for cmd in cmds:
- key = f'cmd:{cmd.name}'
- self.id_to_topic[key] = cmd
- self.id_to_topic[f'/{cmd.name}'] = cmd
- text = cmd.name
- if cmd.description:
- text += f' {cmd.description}'
- if cmd.extras.get('long_description', None):
- text += f' {cmd.extras["long_description"]}'
- self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cmd })
- if isinstance(cmd, Group):
- for subcmd in cmd.commands:
- key = f'subcmd:{cmd.name}.{subcmd.name}'
- self.id_to_topic[key] = subcmd
- self.id_to_topic[f'/{cmd.name} {subcmd.name}'] = subcmd
- text = cmd.name
- text += f' {subcmd.name}'
- if subcmd.description:
- text += f' {subcmd.description}'
- if subcmd.extras.get('long_description', None):
- text += f' {subcmd.extras["long_description"]}'
- self.topics.append({ 'id': key, 'text': process_text(text), 'topic': subcmd })
- for cog_qname, cog in self.bot.cogs.items():
- if not isinstance(cog, BaseCog):
- continue
- key = f'cog:{cog_qname}'
- self.id_to_topic[key] = cog
- text = cog.qualified_name
- if cog.short_description:
- text += f' {cog.short_description}'
- if cog.long_description:
- text += f' {cog.long_description}'
- self.topics.append({ 'id': key, 'text': process_text(text), 'topic': cog })
-
- def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]:
- self.__create_help_index()
- return self.id_to_topic.get(symbol, None)
-
- def all_commands(self) -> list[Union[Command, Group]]:
- # PyCharm not interpreting conditional return type correctly.
- # noinspection PyTypeChecker
- cmds: list[Union[Command, Group]] = self.bot.tree.get_commands(type=AppCommandType.chat_input)
- return sorted(cmds, key=lambda cmd: cmd.name)
-
- def all_accessible_commands(self, permissions: Optional[Permissions]) -> list[Union[Command, Group]]:
- return [
- cmd
- for cmd in self.all_commands()
- if can_use_command(cmd, permissions)
- ]
-
- def all_accessible_subcommands(self, permissions: Optional[Permissions]) -> list[Command]:
- cmds = self.all_accessible_commands(permissions)
- subcmds: list[Command] = []
- for cmd in cmds:
- if isinstance(cmd, Group):
- for subcmd in sorted(cmd.commands, key=lambda cmd: cmd.name):
- if can_use_command(subcmd, permissions):
- subcmds.append(subcmd)
- return subcmds
-
- def all_accessible_cogs(self, permissions: Optional[Permissions]) -> list[BaseCog]:
- return [
- cog
- for cog in self.basecogs
- if can_use_cog(cog, permissions)
- ]
-
- def all_accessible_topics(self, permissions: Optional[Permissions], *,
- include_cogs: bool = True,
- include_commands: bool = True,
- include_subcommands: bool = True) -> list[HelpTopic]:
- topics = []
- if include_cogs:
- topics += self.all_accessible_cogs(permissions)
- if include_commands:
- topics += self.all_accessible_commands(permissions)
- if include_subcommands:
- topics += self.all_accessible_subcommands(permissions)
- return topics
-
- def topics_for_keywords(self, search: str, permissions: Optional[Permissions]) -> list[HelpTopic]:
- start_time = time.perf_counter()
- self.__create_help_index()
-
- # Break into words (or word fragments)
- words: list[str] = [
- word
- for word in re.split(r"[^a-z']+", search.lower())
- ]
-
- # Find matches
- def topic_matches(meta: HelpMeta) -> bool:
- for word in words:
- if word not in meta['text']:
- return False
- return True
- matching_topics: list[HelpTopic] = [ topic['topic'] for topic in self.topics if topic_matches(topic) ]
-
- # Filter by accessibility
- accessible_topics = [
- topic
- for topic in matching_topics
- if ((isinstance(topic, Command) or isinstance(topic, Group)) and can_use_command(topic, permissions)) or \
- (isinstance(topic, BaseCog) and can_use_cog(topic, permissions))
- ]
-
- # Sort and return
- result = sorted(accessible_topics, key=lambda topic: (
- isinstance(topic, Command),
- isinstance(topic, BaseCog),
- topic.qualified_name if isinstance(topic, BaseCog) else topic.name
- ))
- duration = time.perf_counter() - start_time
- if duration > 0.01:
- self.log(None, f'search "{search}" took {duration} seconds')
- return result
-
- @command(
- name='help',
- description='Shows help for using commands and module configuration.',
- extras={
- 'long_description': '`/help` will show a list of top-level topics.\n'
- '\n'
- "`/help /<command_name>` will show help about a specific command or list a command's subcommands.\n"
- '\n'
- '`/help /<command_name> <subcommand_name>` will show help about a specific subcommand.\n'
- '\n'
- '`/help <module_name>` will show help about configuring a module.\n'
- '\n'
- '`/help <keywords>` will do a text search for topics.',
- }
- )
- @guild_only()
- @autocomplete(search=search_autocomplete)
- async def help_command(self, interaction: Interaction, search: Optional[str]) -> None:
- """
- Shows help for using commands and subcommands and configuring modules.
-
- Parameters
- ----------
- interaction: Interaction
- search: Optional[str]
- search terms
- """
- if search is None:
- await self.__send_general_help(interaction)
- return
- topic = self.topic_for_help_symbol(search)
- if topic:
- await self.__send_topic_help(interaction, topic)
- return
- matches = self.topics_for_keywords(search, interaction.permissions)
- await self.__send_keyword_help(interaction, matches)
-
- async def __send_topic_help(self, interaction: Interaction, topic: HelpTopic) -> None:
- if isinstance(topic, Command):
- await self.__send_command_help(interaction, topic)
- return
- if isinstance(topic, Group):
- await self.__send_command_help(interaction, topic)
- return
- if isinstance(topic, BaseCog):
- await self.__send_cog_help(interaction, topic)
- return
- self.log(interaction.guild, f'No help for topic object {topic}')
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} Failed to get help info.',
- ephemeral=True,
- delete_after=10,
- )
-
- def get_command_list(self, permissions: Optional[Permissions] = None) -> dict[str, Union[Command, Group]]:
- return { cmd.name: cmd for cmd in self.bot.tree.get_commands() if can_use_command(cmd, permissions) }
-
- def get_subcommand_list(self, cmd: Group, permissions: Optional[Permissions] = None) -> dict[str, Command]:
- return {
- subcmd.name: subcmd
- for subcmd in cmd.commands
- if can_use_command(subcmd, permissions)
- } if can_use_command(cmd, permissions) else {}
-
- async def __send_general_help(self, interaction: Interaction) -> None:
- user_permissions: Permissions = interaction.permissions
- all_commands = sorted(self.get_command_list(user_permissions).items())
- all_cog_tuples: list[tuple[str, BaseCog]] = [
- cog_tuple
- for cog_tuple in sorted(self.basecog_map.items())
- if can_use_cog(cog_tuple[1], user_permissions) and \
- (len(cog_tuple[1].settings) > 0)
- ]
-
- text = f'## :information_source: Help'
- if len(all_commands) + len(all_cog_tuples) == 0:
- text = 'Nothing available for your permissions!'
-
- if len(all_commands) > 0:
- text += '\n### Commands'
- text += '\nType `/help /commandname` for more information.'
- for cmd_name, cmd in sorted(self.get_command_list(user_permissions).items()):
- text += f'\n- `/{cmd_name}`: {cmd.description}'
- if isinstance(cmd, Group):
- subcommand_count = len(cmd.commands)
- text += f' ({subcommand_count} subcommands)'
- text += PAGE_BREAK
-
- if len(all_cog_tuples) > 0:
- text += '\n### Module Configuration'
- for cog_name, cog in all_cog_tuples:
- has_enabled = next((s for s in cog.settings if s.name == 'enabled'), None) is not None
- text += f'\n- **{cog_name}**: {cog.short_description}'
- if has_enabled:
- text += f'\n - `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
- for setting in cog.settings:
- if setting.name == 'enabled':
- continue
- text += f'\n - `/get` or `/set {cog.config_prefix}_{setting.name}`'
- text += PAGE_BREAK
-
- await self.__send_paged_help(interaction, text)
-
- async def __send_keyword_help(self, interaction: Interaction, matching_topics: Optional[list[HelpTopic]]) -> None:
- matching_commands = [
- cmd
- for cmd in matching_topics or []
- if isinstance(cmd, Command) or isinstance(cmd, Group)
- ]
- matching_cogs = [
- cog
- for cog in matching_topics or []
- if isinstance(cog, BaseCog)
- ]
- if len(matching_commands) + len(matching_cogs) == 0:
- await interaction.response.send_message(
- f'{CONFIG["failure_emoji"]} No available help topics found.',
- ephemeral=True,
- delete_after=10,
- )
- return
- if len(matching_topics) == 1:
- topic = matching_topics[0]
- await self.__send_topic_help(interaction, topic)
- return
-
- text = '## :information_source: Matching Help Topics'
- if len(matching_commands) > 0:
- text += '\n### Commands'
- for cmd in matching_commands:
- if cmd.parent:
- text += f'\n- `/{cmd.parent.name} {cmd.name}`'
- else:
- text += f'\n- `/{cmd.name}`'
- if len(matching_cogs) > 0:
- text += '\n### Modules'
- for cog in matching_cogs:
- text += f'\n- {cog.qualified_name}'
-
- await self.__send_paged_help(interaction, text)
-
- async def __send_command_help(self, interaction: Interaction, command_or_group: Union[Command, Group], addendum: Optional[str] = None) -> None:
- text = ''
- if addendum is not None:
- text += addendum + '\n\n'
- if command_or_group.parent:
- text += f'## :information_source: Subcommand Help'
- text += f'\n`/{command_or_group.parent.name} {command_or_group.name}`'
- else:
- text += f'## :information_source: Command Help'
- if isinstance(command_or_group, Group):
- text += f'\n`/{command_or_group.name} subcommand_name`'
- else:
- text += f'\n`/{command_or_group.name}`'
- if isinstance(command_or_group, Command):
- optional_nesting = 0
- for param in command_or_group.parameters:
- text += ' '
- if not param.required:
- text += '['
- optional_nesting += 1
- text += f'_{param.name}_'
- if optional_nesting > 0:
- text += ']' * optional_nesting
- text += f'\n\n{command_or_group.description}'
- if command_or_group.extras.get('long_description'):
- text += f'\n\n{command_or_group.extras["long_description"]}'
- if isinstance(command_or_group, Group):
- subcmds: dict[str, Command] = self.get_subcommand_list(command_or_group, permissions=interaction.permissions)
- if len(subcmds) > 0:
- text += '\n### Subcommands:'
- for subcmd_name, subcmd in sorted(subcmds.items()):
- text += f'\n- `{subcmd_name}`: {subcmd.description}'
- text += f'\n-# To use a subcommand, type it after the command. e.g. `/{command_or_group.name} subcommand_name`'
- text += f'\n-# Get help on a subcommand by typing `/help /{command_or_group.name} subcommand_name`'
- else:
- params = command_or_group.parameters
- if len(params) > 0:
- text += '\n### Parameters:'
- for param in params:
- text += f'\n- `{param.name}`: {param.description}'
- if not param.required:
- text += ' (optional)'
-
- await self.__send_paged_help(interaction, text)
-
- async def __send_cog_help(self, interaction: Interaction, cog: BaseCog) -> None:
- text = f'## :information_source: Module Help'
- text += f'\n**{cog.qualified_name}** module'
- if cog.short_description is not None:
- text += f'\n\n{cog.short_description}'
- if cog.long_description is not None:
- text += f'\n\n{cog.long_description}'
-
- cmds = [
- cmd
- for cmd in sorted(cog.get_app_commands(), key=lambda c: c.name)
- if can_use_command(cmd, interaction.permissions)
- ]
- if len(cmds) > 0:
- text += '\n### Commands:'
- for cmd in cmds:
- text += f'\n- `/{cmd.name}` - {cmd.description}'
- if isinstance(cmd, Group):
- subcmds = [ subcmd for subcmd in cmd.commands if can_use_command(subcmd, interaction.permissions) ]
- if len(subcmds) > 0:
- text += f' ({len(subcmds)} subcommands)'
-
- settings = cog.settings
- if len(settings) > 0:
- text += '\n### Configuration'
- enabled_setting = next((s for s in settings if s.name == 'enabled'), None)
- if enabled_setting is not None:
- text += f'\n- `/enable {cog.config_prefix}` or `/disable {cog.config_prefix}`'
- for setting in sorted(settings, key=lambda s: s.name):
- if setting.name == 'enabled':
- continue
- text += f'\n- `/get` or `/set {cog.config_prefix}_{setting.name}` - {setting.brief}'
-
- await self.__send_paged_help(interaction, text)
-
- async def __send_paged_help(self, interaction: Interaction, text: str) -> None:
- pages = _paginate(text)
- if len(pages) == 1:
- await interaction.response.send_message(pages[0], ephemeral=True)
- else:
- await _update_paged_help(interaction, None, 0, pages)
-
- def _paginate(text: str) -> list[str]:
- max_page_size = 2000
- chunks = text.split(PAGE_BREAK)
- pages = [ '' ]
- for chunk in chunks:
- if len(chunk) > max_page_size:
- raise ValueError('Help content needs more page breaks! One chunk is too big for message.')
- if len(pages[-1] + chunk) < max_page_size:
- pages[-1] += chunk
- else:
- pages.append(chunk)
- page_count = len(pages)
- if page_count == 1:
- return pages
-
- # Do another pass and try to even out the page lengths
- indices = [ i * len(chunks) // page_count for i in range(page_count + 1) ]
- even_pages = [
- ''.join(chunks[indices[i]:indices[i + 1]])
- for i in range(page_count)
- ]
- for page in even_pages:
- if len(page) > max_page_size:
- # We made a page too big. Give up.
- return pages
- return even_pages
-
- async def _update_paged_help(interaction: Interaction, original_interaction: Optional[Interaction], current_page: int, pages: list[str]) -> None:
- try:
- view = _PagingLayoutView(current_page, pages, original_interaction or interaction)
- resolved = interaction
- if original_interaction is not None:
- # We have an original interaction from the initial command and a
- # new one from the button press. Use the original to swap in the
- # new page in place, then acknowledge the new one to satisfy the
- # API that we didn't fail.
- await original_interaction.edit_original_response(
- view=view,
- )
- if interaction is not original_interaction:
- await interaction.response.defer(ephemeral=True, thinking=False)
- else:
- # Initial send
- await resolved.response.send_message(
- view=view,
- ephemeral=True,
- delete_message_after=60,
- )
- except BaseException as e:
- dump_stacktrace(e)
-
- class _PagingLayoutView(LayoutView):
- def __init__(self, current_page: int, pages: list[str], original_interaction: Optional[Interaction]):
- super().__init__()
- self.current_page: int = current_page
- self.pages: list[str] = pages
- self.text.content = self.pages[self.current_page]
- self.original_interaction = original_interaction
- if current_page <= 0:
- self.handle_prev_button.disabled = True
- if current_page >= len(self.pages) - 1:
- self.handle_next_button.disabled = True
-
- text = TextDisplay('')
-
- row = ActionRow()
-
- @row.button(label='< Prev')
- async def handle_prev_button(self, interaction: Interaction, button: Button) -> None:
- new_page = max(0, self.current_page - 1)
- await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
-
- @row.button(label='Next >')
- async def handle_next_button(self, interaction: Interaction, button: Button) -> None:
- new_page = min(len(self.pages) - 1, self.current_page + 1)
- await _update_paged_help(interaction, self.original_interaction, new_page, self.pages)
-
- # Exclusions from keyword indexing
- trivial_words = {
- 'a', 'an', 'and', 'are', "aren't", 'as', 'by', 'can', 'for', 'have', 'if', 'in',
- 'is', 'it', 'its', "it's", 'not', 'of', 'on', 'or', 'than', 'that', 'the', 'then',
- 'there', 'them', 'they', "they're", 'this', 'to', 'when', 'with',
- }
-
- def can_use_command(cmd: Union[Group, Command], user_permissions: Optional[Permissions]) -> bool:
- if user_permissions is None:
- return False
- if cmd.parent and not can_use_command(cmd.parent, user_permissions):
- return False
- return cmd.default_permissions is None or cmd.default_permissions.is_subset(user_permissions)
-
- def can_use_cog(cog: BaseCog, user_permissions: Optional[Permissions]) -> bool:
- # "Using" a cog for now means configuring it, and only mods can configure cogs.
- return user_permissions is not None and MOD_PERMISSIONS.is_subset(user_permissions)
|