aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--bot/exts/backend/error_handler.py13
-rw-r--r--bot/exts/info/help.py147
-rw-r--r--tests/bot/exts/backend/test_error_handler.py32
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."""