"""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 /` 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)