"""Provides help commands for getting info on using other commands and configuration.""" import re from typing import Union, Optional 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] # 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) ] 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.topic_index 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, 'topic_index', None) is not None: return self.topic_index: dict[str, HelpTopic] = {} self.keyword_index: dict[str, set[HelpTopic]] = {} def add_text_to_index(topic: HelpTopic, text: str): words = [ word for word in re.split(r"[^a-zA-Z']+", text.lower()) if len(word) > 1 and word not in trivial_words ] for word in words: matches = self.keyword_index.get(word, set()) matches.add(topic) self.keyword_index[word] = matches cmds = self.all_commands() for cmd in cmds: self.topic_index[f'cmd:{cmd.name}'] = cmd self.topic_index[f'/{cmd.name}'] = cmd add_text_to_index(cmd, cmd.name) if cmd.description: add_text_to_index(cmd, cmd.description) if isinstance(cmd, Group): for subcmd in cmd.commands: self.topic_index[f'subcmd:{cmd.name}.{subcmd.name}'] = subcmd self.topic_index[f'/{cmd.name} {subcmd.name}'] = subcmd add_text_to_index(subcmd, cmd.name) add_text_to_index(subcmd, subcmd.name) if subcmd.description: add_text_to_index(subcmd, subcmd.description) for cog_qname, cog in self.bot.cogs.items(): if not isinstance(cog, BaseCog): continue key = f'cog:{cog_qname}' self.topic_index[key] = cog add_text_to_index(cog, cog.qualified_name) if cog.description: add_text_to_index(cog, cog.description) def topic_for_help_symbol(self, symbol: str) -> Optional[HelpTopic]: self.__create_help_index() return self.topic_index.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]: self.__create_help_index() # Break into words (or word fragments) words: list[str] = [ word.lower() for word in re.split(r"[^a-zA-Z']+", search) # if len(word) > 1 and word not in trivial_words ] # FIXME: This is a super weird way of doing this. Converting word fragments # to known indexed keywords, then collecting those associated results. Should # just keep corpuses of searchable, normalized text for each topic and do a # direct `in` test. matching_topics_set = None for word in words: word_matches = set() for k in self.keyword_index.keys(): if word in k: topics = self.keyword_index.get(k, None) if topics is not None: word_matches.update(topics) if matching_topics_set is None: matching_topics_set = word_matches else: matching_topics_set = matching_topics_set & word_matches # Filter by accessibility accessible_topics = [ topic for topic in matching_topics_set or {} 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 return sorted(accessible_topics, key=lambda topic: ( isinstance(topic, Command), isinstance(topic, BaseCog), topic.qualified_name if isinstance(topic, BaseCog) else topic.name )) @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 /` will show help about a specific command or list a command's subcommands.\n" '\n' '`/help / ` will show help about a specific subcommand.\n' '\n' '`/help ` will show help about configuring a module.\n' '\n' '`/help ` 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)