diff options
| -rw-r--r-- | bot/exts/backend/error_handler.py | 13 | ||||
| -rw-r--r-- | bot/exts/info/help.py | 147 | ||||
| -rw-r--r-- | tests/bot/exts/backend/test_error_handler.py | 32 | 
3 files changed, 141 insertions, 51 deletions
| diff --git a/bot/exts/backend/error_handler.py b/bot/exts/backend/error_handler.py index 6ab6634a6..5bef72808 100644 --- a/bot/exts/backend/error_handler.py +++ b/bot/exts/backend/error_handler.py @@ -1,5 +1,4 @@  import difflib -import typing as t  from discord import Embed  from discord.ext.commands import ChannelNotFound, Cog, Context, TextChannelConverter, VoiceChannelConverter, errors @@ -97,13 +96,14 @@ class ErrorHandler(Cog):              # MaxConcurrencyReached, ExtensionError              await self.handle_unexpected_error(ctx, e) -    @staticmethod -    def get_help_command(ctx: Context) -> t.Coroutine: +    async def send_command_help(self, ctx: Context) -> None:          """Return a prepared `help` command invocation coroutine."""          if ctx.command: -            return ctx.send_help(ctx.command) +            self.bot.help_command.context = ctx +            await ctx.send_help(ctx.command) +            return -        return ctx.send_help() +        await ctx.send_help()      async def try_silence(self, ctx: Context) -> bool:          """ @@ -245,7 +245,6 @@ class ErrorHandler(Cog):          elif isinstance(e, errors.ArgumentParsingError):              embed = self._get_error_embed("Argument parsing error", str(e))              await ctx.send(embed=embed) -            self.get_help_command(ctx).close()              self.bot.stats.incr("errors.argument_parsing_error")              return          else: @@ -256,7 +255,7 @@ class ErrorHandler(Cog):              self.bot.stats.incr("errors.other_user_input_error")          await ctx.send(embed=embed) -        await self.get_help_command(ctx) +        await self.send_command_help(ctx)      @staticmethod      async def handle_check_failure(ctx: Context, e: errors.CheckFailure) -> None: diff --git a/bot/exts/info/help.py b/bot/exts/info/help.py index 743dfdd3f..06799fb71 100644 --- a/bot/exts/info/help.py +++ b/bot/exts/info/help.py @@ -1,10 +1,12 @@ +from __future__ import annotations +  import itertools  import re  from collections import namedtuple  from contextlib import suppress -from typing import List, Union +from typing import List, Optional, Union -from discord import Colour, Embed +from discord import ButtonStyle, Colour, Embed, Emoji, Interaction, PartialEmoji, ui  from discord.ext.commands import Bot, Cog, Command, CommandError, Context, DisabledCommand, Group, HelpCommand  from rapidfuzz import fuzz, process  from rapidfuzz.utils import default_process @@ -26,6 +28,119 @@ NOT_ALLOWED_TO_RUN_MESSAGE = "***You cannot run this command.***\n\n"  Category = namedtuple("Category", ["name", "description", "cogs"]) +class SubcommandButton(ui.Button): +    """ +    A button shown in a group's help embed. + +    The button represents a subcommand, and pressing it will edit the help embed to that of the subcommand. +    """ + +    def __init__( +        self, +        help_command: CustomHelpCommand, +        command: Command, +        *, +        style: ButtonStyle = ButtonStyle.primary, +        label: Optional[str] = None, +        disabled: bool = False, +        custom_id: Optional[str] = None, +        url: Optional[str] = None, +        emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, +        row: Optional[int] = None +    ): +        super().__init__( +            style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row +        ) + +        self.help_command = help_command +        self.command = command + +    async def callback(self, interaction: Interaction) -> None: +        """Edits the help embed to that of the subcommand.""" +        message = interaction.message +        if not message: +            return + +        subcommand = self.command +        if isinstance(subcommand, Group): +            embed, subcommand_view = await self.help_command.format_group_help(subcommand) +        else: +            embed, subcommand_view = await self.help_command.command_formatting(subcommand) +        await message.edit(embed=embed, view=subcommand_view) + + +class GroupButton(ui.Button): +    """ +    A button shown in a subcommand's help embed. + +    The button represents the parent command, and pressing it will edit the help embed to that of the parent. +    """ + +    def __init__( +        self, +        help_command: CustomHelpCommand, +        command: Command, +        *, +        style: ButtonStyle = ButtonStyle.secondary, +        label: Optional[str] = None, +        disabled: bool = False, +        custom_id: Optional[str] = None, +        url: Optional[str] = None, +        emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, +        row: Optional[int] = None +    ): +        super().__init__( +            style=style, label=label, disabled=disabled, custom_id=custom_id, url=url, emoji=emoji, row=row +        ) + +        self.help_command = help_command +        self.command = command + +    async def callback(self, interaction: Interaction) -> None: +        """Edits the help embed to that of the parent.""" +        message = interaction.message +        if not message: +            return + +        embed, group_view = await self.help_command.format_group_help(self.command.parent) +        await message.edit(embed=embed, view=group_view) + + +class CommandView(ui.View): +    """ +    The view added to any command's help embed. + +    If the command has a parent, a button is added to the view to show that parent's help embed. +    """ + +    def __init__(self, help_command: CustomHelpCommand, command: Command): +        super().__init__() + +        if command.parent: +            self.children.append(GroupButton(help_command, command, emoji="↩️")) + + +class GroupView(CommandView): +    """ +    The view added to a group's help embed. + +    The view generates a SubcommandButton for every subcommand the group has. +    """ + +    MAX_BUTTONS_IN_ROW = 5 +    MAX_ROWS = 5 + +    def __init__(self, help_command: CustomHelpCommand, group: Group, subcommands: list[Command]): +        super().__init__(help_command, group) +        # Don't add buttons if only a portion of the subcommands can be shown. +        if len(subcommands) + len(self.children) > self.MAX_ROWS * self.MAX_BUTTONS_IN_ROW: +            log.trace(f"Attempted to add navigation buttons for `{group.qualified_name}`, but there was no space.") +            return + +        for subcommand in subcommands: +            self.add_item(SubcommandButton(help_command, subcommand, label=subcommand.name)) + +  class HelpQueryNotFound(ValueError):      """      Raised when a HelpSession Query doesn't match a command or cog. @@ -148,7 +263,7 @@ class CustomHelpCommand(HelpCommand):          await self.context.send(embed=embed) -    async def command_formatting(self, command: Command) -> Embed: +    async def command_formatting(self, command: Command) -> tuple[Embed, Optional[CommandView]]:          """          Takes a command and turns it into an embed. @@ -186,12 +301,14 @@ class CustomHelpCommand(HelpCommand):          command_details += f"*{formatted_doc or 'No details provided.'}*\n"          embed.description = command_details -        return embed +        # If the help is invoked in the context of an error, don't show subcommand navigation. +        view = CommandView(self, command) if not self.context.command_failed else None +        return embed, view      async def send_command_help(self, command: Command) -> None:          """Send help for a single command.""" -        embed = await self.command_formatting(command) -        message = await self.context.send(embed=embed) +        embed, view = await self.command_formatting(command) +        message = await self.context.send(embed=embed, view=view)          await wait_for_deletion(message, (self.context.author.id,))      @staticmethod @@ -212,25 +329,31 @@ class CustomHelpCommand(HelpCommand):          else:              return "".join(details) -    async def send_group_help(self, group: Group) -> None: -        """Sends help for a group command.""" +    async def format_group_help(self, group: Group) -> tuple[Embed, Optional[CommandView]]: +        """Formats help for a group command."""          subcommands = group.commands          if len(subcommands) == 0:              # no subcommands, just treat it like a regular command -            await self.send_command_help(group) -            return +            return await self.command_formatting(group)          # remove commands that the user can't run and are hidden, and sort by name          commands_ = await self.filter_commands(subcommands, sort=True) -        embed = await self.command_formatting(group) +        embed, _ = await self.command_formatting(group)          command_details = self.get_commands_brief_details(commands_)          if command_details:              embed.description += f"\n**Subcommands:**\n{command_details}" -        message = await self.context.send(embed=embed) +        # If the help is invoked in the context of an error, don't show subcommand navigation. +        view = GroupView(self, group, commands_) if not self.context.command_failed else None +        return embed, view + +    async def send_group_help(self, group: Group) -> None: +        """Sends help for a group command.""" +        embed, view = await self.format_group_help(group) +        message = await self.context.send(embed=embed, view=view)          await wait_for_deletion(message, (self.context.author.id,))      async def send_cog_help(self, cog: Cog) -> None: diff --git a/tests/bot/exts/backend/test_error_handler.py b/tests/bot/exts/backend/test_error_handler.py index 462f718e6..d12329b1f 100644 --- a/tests/bot/exts/backend/test_error_handler.py +++ b/tests/bot/exts/backend/test_error_handler.py @@ -572,38 +572,6 @@ class IndividualErrorHandlerTests(unittest.IsolatedAsyncioTestCase):                  push_scope_mock.set_extra.has_calls(set_extra_calls) -class OtherErrorHandlerTests(unittest.IsolatedAsyncioTestCase): -    """Other `ErrorHandler` tests.""" - -    def setUp(self): -        self.bot = MockBot() -        self.ctx = MockContext() - -    async def test_get_help_command_command_specified(self): -        """Should return coroutine of help command of specified command.""" -        self.ctx.command = "foo" -        result = ErrorHandler.get_help_command(self.ctx) -        expected = self.ctx.send_help("foo") -        self.assertEqual(result.__qualname__, expected.__qualname__) -        self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - -        # Await coroutines to avoid warnings -        await result -        await expected - -    async def test_get_help_command_no_command_specified(self): -        """Should return coroutine of help command.""" -        self.ctx.command = None -        result = ErrorHandler.get_help_command(self.ctx) -        expected = self.ctx.send_help() -        self.assertEqual(result.__qualname__, expected.__qualname__) -        self.assertEqual(result.cr_frame.f_locals, expected.cr_frame.f_locals) - -        # Await coroutines to avoid warnings -        await result -        await expected - -  class ErrorHandlerSetupTests(unittest.TestCase):      """Tests for `ErrorHandler` `setup` function.""" | 
