diff --git a/docs/assets/examples/help-command/default-cog-help.png b/docs/assets/examples/help-command/default-cog-help.png new file mode 100644 index 0000000..05c1c9e Binary files /dev/null and b/docs/assets/examples/help-command/default-cog-help.png differ diff --git a/docs/assets/examples/help-command/default-command-help.png b/docs/assets/examples/help-command/default-command-help.png new file mode 100644 index 0000000..d992209 Binary files /dev/null and b/docs/assets/examples/help-command/default-command-help.png differ diff --git a/docs/assets/examples/help-command/default-group-help.png b/docs/assets/examples/help-command/default-group-help.png new file mode 100644 index 0000000..dd7804d Binary files /dev/null and b/docs/assets/examples/help-command/default-group-help.png differ diff --git a/docs/assets/examples/help-command/default-help-command.png b/docs/assets/examples/help-command/default-help-command.png new file mode 100644 index 0000000..caa1a85 Binary files /dev/null and b/docs/assets/examples/help-command/default-help-command.png differ diff --git a/docs/assets/examples/help-command/flowchart.png b/docs/assets/examples/help-command/flowchart.png new file mode 100644 index 0000000..d92f3cc Binary files /dev/null and b/docs/assets/examples/help-command/flowchart.png differ diff --git a/docs/assets/examples/help-command/help-command-in-cog.png b/docs/assets/examples/help-command/help-command-in-cog.png new file mode 100644 index 0000000..9841c60 Binary files /dev/null and b/docs/assets/examples/help-command/help-command-in-cog.png differ diff --git a/docs/assets/examples/help-command/minimal-cog-help.png b/docs/assets/examples/help-command/minimal-cog-help.png new file mode 100644 index 0000000..72d2060 Binary files /dev/null and b/docs/assets/examples/help-command/minimal-cog-help.png differ diff --git a/docs/assets/examples/help-command/minimal-command-help.png b/docs/assets/examples/help-command/minimal-command-help.png new file mode 100644 index 0000000..404cac1 Binary files /dev/null and b/docs/assets/examples/help-command/minimal-command-help.png differ diff --git a/docs/assets/examples/help-command/minimal-group-help.png b/docs/assets/examples/help-command/minimal-group-help.png new file mode 100644 index 0000000..49d186b Binary files /dev/null and b/docs/assets/examples/help-command/minimal-group-help.png differ diff --git a/docs/assets/examples/help-command/minimal-help-command.png b/docs/assets/examples/help-command/minimal-help-command.png new file mode 100644 index 0000000..0efbd66 Binary files /dev/null and b/docs/assets/examples/help-command/minimal-help-command.png differ diff --git a/docs/assets/examples/help-command/prefix-bot-help-command.gif b/docs/assets/examples/help-command/prefix-bot-help-command.gif new file mode 100644 index 0000000..3304fda Binary files /dev/null and b/docs/assets/examples/help-command/prefix-bot-help-command.gif differ diff --git a/docs/assets/examples/help-command/prefix-cog-help-command.gif b/docs/assets/examples/help-command/prefix-cog-help-command.gif new file mode 100644 index 0000000..f337d47 Binary files /dev/null and b/docs/assets/examples/help-command/prefix-cog-help-command.gif differ diff --git a/docs/assets/examples/help-command/prefix-command-help-command.png b/docs/assets/examples/help-command/prefix-command-help-command.png new file mode 100644 index 0000000..88dfb4b Binary files /dev/null and b/docs/assets/examples/help-command/prefix-command-help-command.png differ diff --git a/docs/assets/examples/help-command/prefix-error-message.png b/docs/assets/examples/help-command/prefix-error-message.png new file mode 100644 index 0000000..c40c0c5 Binary files /dev/null and b/docs/assets/examples/help-command/prefix-error-message.png differ diff --git a/docs/assets/examples/help-command/prefix-group-help-command.png b/docs/assets/examples/help-command/prefix-group-help-command.png new file mode 100644 index 0000000..38549d7 Binary files /dev/null and b/docs/assets/examples/help-command/prefix-group-help-command.png differ diff --git a/docs/help-command.md b/docs/help-command.md new file mode 100644 index 0000000..cd946e7 --- /dev/null +++ b/docs/help-command.md @@ -0,0 +1,371 @@ +# Help Command + +The help command is used to display information about the bot and its commands, cogs/extensions, and groups. There are two types of commands: + +1. **Prefix/Message Commands**: These are commands that are used by sending a message to the bot. They are of the form ` [arguments]`. For example, `!help` is a prefix command. Your application uses the underlying `on_message` event to handle these commands and discord holds no information about them. +2. **Slash Commands**: These are commands that are registered with discord. They are of the form `/command [arguments]`. Discord holds information about these commands and can provide information about them to users. This means discord can display to users the commands that your application has registered, required arguments, and its description. + +`discord.py` by default provides a minimal help command for prefix commands. The help command can be customized to display information about the bot's commands, cogs/extensions, and groups. + +In this guide, we will cover how to customize the help command to display information about the bot's commands, cogs/extensions, and groups for prefix commands first and then extend it to display information about slash commands. + +## Custom Help Command + +`discord.py` has a set of [base classes](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html?highlight=helpcommand#help-commands) that can be used to create a custom help command. + +- [`commands.DefaultHelpCommand`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html?highlight=helpcommand#defaulthelpcommand): The default help command that comes with `discord.py`. It is a subclass of `commands.HelpCommand` and acts as a basic help command for prefix and hybrid commands. + +=== "Default Help Command" + ![Default Help Command](./assets/examples/help-command/default-help-command.png) + +=== "Command Help" + ![Command Help](./assets/examples/help-command/default-command-help.png) + +=== "Group Help" + ![Group Help](./assets/examples/help-command/default-group-help.png) + +=== "Cog Help" + ![Cog Help](./assets/examples/help-command/default-cog-help.png) + +- [`commands.MinimalHelpCommand`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html?highlight=helpcommand#minimalhelpcommand): Similar to `DefaultHelpCommand` but with a minimalistic design, using plain text instead of code blocks and simplified formatting. + +=== "Minimal Help Command" + ![Minimal Help Command](./assets/examples/help-command/minimal-help-command.png) + +=== "Command Help" + ![Command Help](./assets/examples/help-command/minimal-command-help.png) + +=== "Group Help" + ![Group Help](./assets/examples/help-command/minimal-group-help.png) + +=== "Cog Help" + ![Cog Help](./assets/examples/help-command/minimal-cog-help.png) + +- [`commands.HelpCommand`](https://discordpy.readthedocs.io/en/stable/ext/commands/api.html?highlight=helpcommand#helpcommand): The base class for creating custom help commands. It provides methods that can be overridden to customize the help command's behavior. By default, it does nothing and is meant to be subclassed. + +### Methods of HelpCommand + +The `commands.HelpCommand` class provides the following methods that can be overridden to customize the help command's behavior and utility methods to construct the help message. + +| Method | Description | +|------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `get_bot_mapping()` | Returns a mapping of command and command group objects to their respective cogs/extensions. If a command is not part of any cog/extension, it is mapped to `None`. | +| `get_command_signature(command)` | Returns the signature of the command. Example: `!command [arg1] [arg2]`. | +| `command_not_found(string)` | Returns a string to display when a command is not found. | +| `subcommand_not_found(command, string)` | Returns a string to display when a subcommand under a command group is not found. | +| `filter_commands(commands, *, sort=False, key=None)` | Filters the commands based on the provided parameters. It runs all associated checks with the command and takes into account weather the command is hidden. | +| `get_destination()` | Returns the destination where the help command information should be sent. | +| `send_error_message(error)` | Sends an error message to the destination. For example, the output of `command_not_found()` would be passed here. | +| `on_help_command_error(ctx, error)` | Handles the error that occurs during the execution of the help command. | +| `send_bot_help(mapping)` | Sends the help message for the bot. Invoked when no other argument was passed when calling the help command. Example: `!help`. | +| `send_cog_help(cog)` | Sends the help message for a cog/extension. Invoked when a cog/extension name was passed when calling the help command. Example: `!help cog_name`. | +| `send_group_help(group)` | Sends the help message for a command group. Invoked when a command group name was passed when calling the help command. Example: `!help group_name`. | +| `send_command_help(command)` | Sends the help message for a command. Invoked when a command name was passed when calling the help command. Example: `!help command_name`. | + +### Customizing the Help Command + +To create a custom help command, you need to subclass `commands.HelpCommand` and override the methods you want to customize. These customizations can include formatting the help message, altering the help message's appearance, paginating the help message, and handling interactions with the user. + +We can divide the customization into two parts: + +1. **Formatting the Help Message**: This involves formatting the help message to display relevant information and also to make it visually appealing. +2. **Sending the Help Message**: This involves sending the formatted help message to the user, and handling interactions with the user. + +!!! note "Note" + In this guide, we will be utilizing the pagination classes from the [Pagination](./pagination.md) guide to paginate the help message. + +#### Formatter Class + +The `Formatter` class is responsible for formatting the help message. It contains methods to format the command signature, help, aliases, cooldown, enabled status, and description. It also contains methods to format the command, cog/extension, and group help messages. + +```python title="help_command.py" +from typing import Optional, List + +import discord +import humanfriendly +from discord.ext import commands + + +class Formatter: + def __init__(self, help_command: commands.HelpCommand) -> None: + self.ctx = help_command.context + self.help_command = help_command + + def __format_command_signature(self, command: commands.Command) -> tuple[str, str]: + params = self.__format_param(command) + return f"{command.qualified_name}\n", f"```yaml\n{params}```" + + def __format_param(self, param: commands.Command) -> str: + signature = self.help_command.get_command_signature(param) + return signature + + @staticmethod + def __format_command_help(command: commands.Command) -> str: + # command.help is the docstring of the command, might be None. + return command.help or "No help provided." + + @staticmethod + def __format_command_aliases(command: commands.Command) -> str: + # Join the aliases with a comma and space. + return f"```yaml\nAliases: {', '.join(command.aliases)}```" if command.aliases else "No aliases." + + @staticmethod + def __format_command_cooldown(command: commands.Command) -> str: + # Humanfriendly is used to format the cooldown time in a human-readable format. + # Source: https://github.com/xolox/python-humanfriendly + return ( + f"Cooldown: {humanfriendly.format_timespan(command.cooldown.per, max_units=2)} per user." + if command.cooldown + else "No cooldown set." + ) + + @staticmethod + def __format_command_enabled(command: commands.Command) -> str: + return f"Enabled: {command.enabled}" if command.enabled else "Command is disabled." + + def format_command(self, command: commands.Command) -> discord.Embed: + signature = self.__format_command_signature(command) + embed = discord.Embed( + title=signature[0], + description=signature[1] + self.__format_command_help(command), + color=discord.Color.blue(), + ) + embed.add_field(name="Aliases", value=self.__format_command_aliases(command), inline=True) + embed.add_field(name="Cooldown", value=self.__format_command_cooldown(command), inline=True) + embed.add_field(name="Enabled", value=self.__format_command_enabled(command), inline=True) + embed.set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + return embed + + async def format_cog_or_group( + self, cog_or_group: Optional[commands.Cog | commands.Group], commands_: List[commands.Command | commands.Group] + ) -> List[discord.Embed]: + # Commands or command groups may be created standalone or outside a cog, in which case cog_or_group is None. + category_name = cog_or_group.qualified_name if cog_or_group else "No Category" + # Get the description of the cog or group. + if isinstance(cog_or_group, commands.Group): + category_desc = cog_or_group.help or "No description provided." + else: + # cog_or_group is a Cog object, might be None. + category_desc = cog_or_group.description if cog_or_group and cog_or_group.description else "No description provided." + cog_embed = ( + discord.Embed( + title=f"{category_name} Commands", + description=f"*{category_desc}*" or "*No description provided.*", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.ctx.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + ) + embeds: List[discord.Embed] = [] + + # Create multiple embeds if the number of commands exceeds 5, with 5 commands per embed. + for i in range(0, len(commands_), 5): + embed = cog_embed.copy() + # Create chunks of 5 commands and add them to the embed. + for command in commands_[i : i + 5]: + signature = self.__format_command_signature(command) + embed.add_field( + name=signature[0], + value=signature[1] + self.__format_command_help(command), + inline=False, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + embeds.append(embed) + return embeds if embeds else [cog_embed] +``` + +| Method | Description | +|------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `__format_command_signature` | Formats the command signature. It returns the command name and its signature. For example, `command [arg1] [arg2]`. | +| `__format_param` | Formats the command parameters, arguments, and usage. It returns the command signature. For example, ``, `[arg2=default value]`. | +| `__format_command_help` | Returns the command help/description or a default message if no help is provided. | +| `__format_command_aliases` | Returns the command aliases or a default message if no aliases are provided. For example, `Aliases: alias1, alias2`. | +| `__format_command_cooldown` | Returns the command cooldown or a default message if no cooldown is set. For example, `Cooldown: 5 seconds per user`. | +| `__format_command_enabled` | Returns the command enabled status or a default message if the command is disabled. For example, `Enabled: True`. | +| `format_command` | Formats the command help message, utilizing the above methods. | +| `format_cog_or_group` | Formats the cog/extension or group help message, utilizing the above methods. Creates multiple embeds if the number of commands exceeds 5, with 5 commands per embed. | + +#### Sending the Help Message + +The `CustomHelpCommand` class is responsible for sending the help message. It contains methods we need to override as per our requirements. + +Here is a flowchart which visualizes the flow of the help command, and how the methods are called: + +![Flowchart](./assets/examples/help-command/flowchart.png)
Flowchart of the Help Command. Image credit: [InterStella0](https://gist.github.com/InterStella0/b78488fb28cadf279dfd3164b9f0cf96)
+ +```python title="help_command.py" +from typing import Mapping, Optional, List, Any, Iterable + +import discord +from discord.ext import commands +from discord.ext.commands import Cog, Command, Group + +from paginators.advanced_paginator import EmbedCategoryPaginator, CategoryEntry +from paginators.button_paginator import EmbedButtonPaginator + + +class CustomHelpCommand(commands.HelpCommand): + @staticmethod + def flatten_commands(commands_: Iterable[commands.Command | commands.Group]) -> List[commands.Command]: + flattened = [] + for command in commands_: + if isinstance(command, commands.Group): + flattened.extend(CustomHelpCommand.flatten_commands(command.commands)) + else: + flattened.append(command) + return flattened + + async def send_bot_help(self, mapping: Mapping[Optional[Cog], List[Command[Any, ..., Any]]], /) -> None: + home_embed = ( + discord.Embed( + title="Home", + description="Documentation Bot Home Page - Custom Help Command", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.context.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + ) + + home_pages: List[discord.Embed] = [] + + for i in range(0, len(mapping), 5): + embed = home_embed.copy() + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + embed.add_field( + name=cog.qualified_name if cog else "No Category", + value=f"*{cog.description if cog and cog.description else 'No description provided.'}* `[Commands: {len(filtered_cmds)}]`", + inline=False, + ) + home_pages.append(embed) + + categories: List[CategoryEntry[discord.Embed]] = [ + CategoryEntry( + category_title="Home", + category_description="Documentation Bot Home Page", + pages=home_pages, + ) + ] + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + + # mapping includes a None key for commands that are not part of any cog, we need to check for it. + cog_name = cog.qualified_name if cog else "No Category" + cog_desc = cog.description if cog and cog.description else "No description provided." + + categories.append( + CategoryEntry( + category_title=cog_name, + category_description=cog_desc, + pages=await Formatter(self).format_cog_or_group(cog, filtered_cmds), + ) + ) + + paginator = EmbedCategoryPaginator(self.context.author, pages=categories) + await paginator.start_paginator(self.context) + + async def send_cog_help(self, cog: Cog, /) -> None: + commands_ = await self.filter_commands(self.flatten_commands(cog.get_commands()), sort=True) + embeds = await Formatter(self).format_cog_or_group(cog, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_group_help(self, group: Group[Any, ..., Any], /) -> None: + commands_ = await self.filter_commands(self.flatten_commands(group.commands), sort=True) + embeds = await Formatter(self).format_cog_or_group(group, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_command_help(self, command: Command[Any, ..., Any], /) -> None: + command_ = await self.filter_commands([command], sort=True) + embed = Formatter(self).format_command(command_[0]) + await self.context.send(embed=embed) + + async def send_error_message(self, error: str, /) -> None: + embed = discord.Embed( + title="Error", + description=error, + color=discord.Color.red(), + ).set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + await self.context.send(embed=embed) +``` + +Now let's take a look at how the `CustomHelpCommand` class works: + +- `flatten_commands`: For prefix commands, you can build command groups with subcommands. A command group can also have another command group registered under it, hence forming a hierarchy. This method flattens the command groups and returns a list of commands. +- `send_bot_help`: This function receives a mapping or in more simple terms, a dictionary where the key is a `commands.Cog` object and the value is a list of `commands.Command` and `commands.Group` objects. There is also a singular `None` key that holds all the commands that are not part of any cog. + - The function creates a home page that displays each cog and the number of commands it has, and the `No Category` section for commands that are not part of any cog. + - It then creates a list of `CategoryEntry` objects, where each object represents a category (cog or `No Category`) and a corresponding list of pages which include a short description for the category and the commands it contains. + - Finally, it creates an `EmbedCategoryPaginator` object and starts the paginator. +- `send_cog_help`: This function receives a `commands.Cog` object and sends the help message for the cog. It filters the commands of the cog, formats the cog help message, and starts the paginator. Each page contains a maximum of 5 commands. +- `send_group_help`: This function receives a `commands.Group` object and sends the help message for the group. It filters the commands of the group, formats the group help message, and starts the paginator. Each page contains a maximum of 5 commands. +- `send_command_help`: This function receives a `commands.Command` object and sends the help message for the command. It filters the command, formats the command help message, and sends the embed. +- `send_error_message`: This function receives an error message and creates an embed with the error message. It then sends the embed to the user. + +=== "Bot Help" + ![Bot Help Command](./assets/examples/help-command/prefix-bot-help-command.gif) + +=== "Cog Help" + ![Cog Help Command](./assets/examples/help-command/prefix-cog-help-command.gif) + +=== "Group Help" + ![Group Help Command](./assets/examples/help-command/prefix-group-help-command.png) + +=== "Command Help" + ![Command Help Command](./assets/examples/help-command/prefix-command-help-command.png) + +=== "Error Message" + ![Error Message](./assets/examples/help-command/prefix-error-message.png) + +## Registering the Custom Help Command + +To use the custom help command, you need to register it with the bot. You can do this by passing an instance of the `CustomHelpCommand` class to the `help_command` parameter of the `commands.Bot` constructor. + +```python +from discord.ext import commands + +from help_command import CustomHelpCommand + +bot = commands.Bot(command_prefix="!", help_command=CustomHelpCommand()) +``` + +Now, when you run the bot and use the help command, you will see the custom help message. This will also register your help command under ungrouped or under the `None` key in the mapping. + +Alternatively, to avoid this you can register the help command under a cog. This will group the help command under the cog name in the mapping. + +```python +from discord.ext import commands + +from help_command import CustomHelpCommand + + +class HelpCog(commands.Cog): + def __init__(self, bot: commands.Bot): + self.bot = bot + self._help_command = CustomHelpCommand() + self._help_command.cog = self + self._original_help_command = bot.help_command + bot.help_command = self._help_command + + def cog_unload(self): + self.bot.help_command = self._original_help_command +``` + +![Help Command in Cog](./assets/examples/help-command/help-command-in-cog.png) + +## Slash Help Command + +The custom help command we created so far only works for prefix commands. This is because the `mapping` diff --git a/examples/hybrid-commands/cogs/general.py b/examples/hybrid-commands/cogs/general.py index 1a6037e..b53e7b0 100644 --- a/examples/hybrid-commands/cogs/general.py +++ b/examples/hybrid-commands/cogs/general.py @@ -1,23 +1,31 @@ from __future__ import annotations -import asyncio import typing from discord.ext import commands +from utils.help import CustomHelpCommand if typing.TYPE_CHECKING: from .. import CustomBot class General(commands.Cog): + """General commands""" + def __init__(self, bot: CustomBot) -> None: self.bot = bot + self._help_command = CustomHelpCommand() + self._help_command.cog = self + self._original_help_command = bot.help_command + bot.help_command = self._help_command + + def cog_unload(self) -> None: + self.bot.help_command = self._original_help_command @commands.hybrid_command(name="ping") async def ping(self, ctx: commands.Context[CustomBot]) -> None: """Pong!""" await ctx.defer(ephemeral=True) - await asyncio.sleep(5) await ctx.send(f"Pong! ({self.bot.latency * 1000:.2f}ms)") @commands.hybrid_group(name="math") @@ -31,7 +39,7 @@ async def add(self, ctx: commands.Context[CustomBot], a: int, b: int) -> None: await ctx.send(f"{a} + {b} = {a + b}") @math.command(name="subtract") - async def subtract(self, ctx: commands.Context[CustomBot], a: int, b: int) -> None: + async def subtract(self, ctx: commands.Context[CustomBot], a: int, b: int = 0) -> None: """Subtract two numbers""" await ctx.send(f"{a} - {b} = {a - b}") diff --git a/examples/hybrid-commands/main.py b/examples/hybrid-commands/main.py index 30333f6..eb10d9e 100644 --- a/examples/hybrid-commands/main.py +++ b/examples/hybrid-commands/main.py @@ -18,10 +18,16 @@ def __init__(self, prefix: str, ext_dir: str, *args: typing.Any, **kwargs: typin intents = discord.Intents.default() intents.members = True intents.message_content = True - super().__init__(*args, **kwargs, command_prefix=commands.when_mentioned_or(prefix), intents=intents) + super().__init__( + *args, + **kwargs, + command_prefix=commands.when_mentioned_or(prefix), + intents=intents, + owner_ids={656838010532265994}, + ) self.logger = logging.getLogger(self.__class__.__name__) self.ext_dir = ext_dir - self.synced = False + self.synced = True async def _load_extensions(self) -> None: if not os.path.isdir(self.ext_dir): @@ -44,6 +50,7 @@ async def on_ready(self) -> None: async def setup_hook(self) -> None: self.client = aiohttp.ClientSession() await self._load_extensions() + await self.load_extension("jishaku") if not self.synced: await self.tree.sync() self.synced = not self.synced @@ -75,9 +82,130 @@ def main() -> None: logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s") bot = CustomBot(prefix="!", ext_dir="cogs") - @bot.tree.command() - async def mnrg_add(interaction: discord.Interaction, snowflake: typing.Union[discord.User, discord.Role]): - await interaction.response.send_message(f"Added {snowflake} to the list of managers.") + @bot.group(name="factory") + async def factory(ctx: commands.Context) -> None: + """ + Factory commands. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + + Returns + ------- + None + """ + if ctx.invoked_subcommand is None: + await ctx.send("Invalid subcommand.") + + @factory.command(name="create") + async def create(ctx: commands.Context, name: str) -> None: + """ + Create a factory. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + name: str + The name of the factory. + + Returns + ------- + None + """ + await ctx.send(f"Created factory {name}.") + + @factory.command(name="delete") + async def delete(ctx: commands.Context, name: str) -> None: + """ + Delete a factory. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + name: str + The name of the factory. + + Returns + ------- + None + """ + await ctx.send(f"Deleted factory {name}.") + + @factory.group(name="product") + async def _product(ctx: commands.Context) -> None: + """ + Product commands. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + + Returns + ------- + None + """ + if ctx.invoked_subcommand is None: + await ctx.send("Invalid subcommand.") + bot.get_command() + + @_product.command(name="manufacture") + async def manufacture(ctx: commands.Context, name: str, product: str) -> None: + """ + Manufacture a product in a factory. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + name: str + The name of the factory. + product: str + The name of the product. + + Returns + ------- + None + """ + await ctx.send(f"Manufactured {product} in factory {name}.") + + @_product.command(name="sell") + async def sell(ctx: commands.Context, name: str, product: str) -> None: + """ + Sell a product from a factory. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + name: str + The name of the factory. + product: str + The name of the product. + + Returns + ------- + None + """ + await ctx.send(f"Sold {product} from factory {name}.") + + @bot.hybrid_command(name="echo") + async def echo(ctx: commands.Context, *, message: str) -> None: + """ + Repeat a message. + + Parameters + ---------- + ctx: commands.Context + The context of the command. + message: str + The message to repeat. + """ + await ctx.send(message) bot.run() diff --git a/examples/hybrid-commands/paginators/__init__.py b/examples/hybrid-commands/paginators/__init__.py new file mode 100644 index 0000000..92e49a1 --- /dev/null +++ b/examples/hybrid-commands/paginators/__init__.py @@ -0,0 +1,93 @@ +"""Base class for paginators.""" + +from __future__ import annotations + +from io import BufferedIOBase +from os import PathLike +from typing import TYPE_CHECKING, Any, Generic, List, TypeVar, Union + +import discord +from discord.ext import commands +from views import BaseView + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +PageLike: TypeAlias = Union[discord.Embed, str, bytes, PathLike[Any], BufferedIOBase, discord.File] +FileLike: TypeAlias = Union[str, bytes, PathLike[Any], BufferedIOBase] + +T = TypeVar("T", bound=PageLike) + + +class BasePaginator(Generic[T], BaseView): + pages: List[T] + current_page: int + + def __init__( + self, user: Union[discord.User, discord.Member], pages: List[T], *, attachments: List[discord.File] = None + ) -> None: + super().__init__(user=user, timeout=180) + self.pages = pages + self.current_page: int = 0 + self.attachments = attachments or [] + + async def _send(self, ctx_or_inter: commands.Context | discord.Interaction, *args: Any, **kwargs: Any) -> None: + if isinstance(ctx_or_inter, commands.Context): + if self.message is None: + self.message = await ctx_or_inter.send(*args, **kwargs) + return + self.message = await ctx_or_inter.send(*args, **kwargs) + return + if self.message is None: + await ctx_or_inter.response.send_message(*args, **kwargs) + self.message = await ctx_or_inter.original_response() + return + self.message = await ctx_or_inter.edit_original_response(*args, **kwargs) + + async def send_page(self, ctx_or_inter: commands.Context | discord.Interaction, page: T) -> None: + if isinstance(page, discord.Embed): # Embed + # Check if the embed has an associated attachment and send it along with the embed + attachment = None + if (page.image.url or "").startswith("attachment://") and len(self.attachments) > self.current_page: + attachment = discord.File(self.attachments[self.current_page].fp.name) + attachments = [attachment] if attachment else [] + if self.message is None: + return await self._send(ctx_or_inter, embed=page, view=self, files=attachments) + return await self._send(ctx_or_inter, embed=page, view=self, attachments=attachments) + + if isinstance(page, str): # String + # Check if the string has an associated attachment and send it along with the string + attachment = None + if len(self.attachments) > self.current_page: + attachment = discord.File(self.attachments[self.current_page].fp.name) + attachments = [attachment] if attachment else [] + if self.message is None: + return await self._send(ctx_or_inter, content=page, view=self, files=attachments) + return await self._send(ctx_or_inter, content=page, view=self, attachments=attachments) + + # File + file = discord.File(page) if not isinstance(page, discord.File) else discord.File(page.fp.name) + if self.message is None: + return await self._send(ctx_or_inter, file=file, view=self) + return await self._send(ctx_or_inter, file=file, view=self) + + async def start_paginator( + self, ctx_or_inter: commands.Context | discord.Interaction, *, starting_page: int = 0 + ) -> None: + self.current_page = starting_page + page = self.pages[starting_page] + await self.send_page(ctx_or_inter, page) + + async def stop_paginator(self) -> None: + self._disable_all() + await self._edit(view=self) + + async def next_page(self, inter: discord.Interaction) -> None: + self.current_page = (self.current_page + 1) % len(self.pages) + page = self.pages[self.current_page] + await self.send_page(inter, page) + + async def previous_page(self, inter: discord.Interaction) -> None: + self.current_page = (self.current_page - 1) % len(self.pages) + page = self.pages[self.current_page] + await self.send_page(inter, page) diff --git a/examples/hybrid-commands/paginators/advanced_paginator.py b/examples/hybrid-commands/paginators/advanced_paginator.py new file mode 100644 index 0000000..3f137d9 --- /dev/null +++ b/examples/hybrid-commands/paginators/advanced_paginator.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Generic, List, Optional, TypeVar, Union + +import discord +from discord import File, Member, User +from paginators import FileLike, PageLike +from paginators.button_paginator import ButtonBasedPaginator + +T = TypeVar("T", bound=PageLike) + + +class CategoryEntry(Generic[T]): + def __init__( + self, + *, + category_title: str, + category_description: Optional[str] = None, + pages: Optional[List[T]] = None, + attachments: Optional[List[File]] = None, + ) -> None: + self.category_title = category_title + self.category_description = category_description + self.pages = pages or [] + self.attachments = attachments or [] + + def add_page(self, page: T) -> None: + self.pages.append(page) + + +class CategoryBasedPaginator(Generic[T], ButtonBasedPaginator[T]): + def __init__( + self, + user: Union[User, Member], + *, + pages: List[CategoryEntry[T]], + ) -> None: + self.categories = pages + self.current_category: int = 0 + + super().__init__(user, pages[self.current_category].pages, attachments=pages[self.current_category].attachments) + + self.select = CategoryPaginatorSelect() + for i, page in enumerate(pages): + self.select.add_option( + label=page.category_title, + value=str(i), + description=page.category_description, + ) + self.add_item(self.select) + + +class CategoryPaginatorSelect(discord.ui.Select[CategoryBasedPaginator[PageLike]]): + def __init__(self) -> None: + super().__init__(min_values=1, max_values=1) + + async def callback(self, interaction: discord.Interaction) -> None: + # the user can only select one value and shoud at least select it + # so this is always fine + await interaction.response.defer() + self.view.current_category = int(self.values[0]) + view: CategoryBasedPaginator[PageLike] = self.view + view.pages = view.categories[self.view.current_category].pages + view.attachments = view.categories[self.view.current_category].attachments + view.current_page = 0 + page = view.pages[view.current_page] + await view.send_page(interaction, page) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return await self.view.interaction_check(interaction) + + +class EmbedCategoryPaginator(CategoryBasedPaginator[discord.Embed]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[discord.Embed]]) -> None: + super().__init__(user, pages=pages) + + +class FileCategoryPaginator(CategoryBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[FileLike]]) -> None: + super().__init__(user, pages=pages) + + +class StringCategoryPaginator(CategoryBasedPaginator[str]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[str]]) -> None: + super().__init__(user, pages=pages) diff --git a/examples/hybrid-commands/paginators/button_paginator.py b/examples/hybrid-commands/paginators/button_paginator.py new file mode 100644 index 0000000..46412eb --- /dev/null +++ b/examples/hybrid-commands/paginators/button_paginator.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, List, TypeVar, Union + +import discord +from discord import PartialEmoji +from paginators import BasePaginator, FileLike, PageLike + +if TYPE_CHECKING: + from views import BaseView + + +T = TypeVar("T", bound=PageLike) + + +class ButtonBasedPaginator(Generic[T], BasePaginator[T]): + @discord.ui.button(emoji=PartialEmoji.from_str("⏪")) + async def goto_first_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + self.current_page = 0 + page = self.pages[self.current_page] + await self.send_page(inter, page) + + @discord.ui.button(emoji=PartialEmoji.from_str("◀️")) + async def previous_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.previous_page(inter) + + @discord.ui.button(emoji=PartialEmoji.from_str("▶️")) + async def next_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.next_page(inter) + + @discord.ui.button(emoji=PartialEmoji.from_str("⏩")) + async def goto_last_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + self.current_page = len(self.pages) - 1 + page = self.pages[self.current_page] + await self.send_page(inter, page) + + @discord.ui.button(emoji=PartialEmoji.from_str("🗑️")) + async def stop_paginator_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.stop_paginator() + + +class EmbedButtonPaginator(ButtonBasedPaginator[discord.Embed]): + def __init__( + self, + user: Union[discord.User, discord.Member], + pages: List[discord.Embed], + *, + attachments: List[discord.File] = None, + ) -> None: + super().__init__(user, pages, attachments=attachments) + + +class FileButtonPaginator(ButtonBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], pages: List[FileLike]) -> None: + super().__init__(user, pages) + + +class StringButtonPaginator(ButtonBasedPaginator[str]): + def __init__( + self, + user: Union[discord.User, discord.Member], + pages: List[str], + *, + attachments: List[discord.File] = None, + ) -> None: + super().__init__(user, pages, attachments=attachments) diff --git a/examples/hybrid-commands/paginators/select_paginator.py b/examples/hybrid-commands/paginators/select_paginator.py new file mode 100644 index 0000000..3f0b614 --- /dev/null +++ b/examples/hybrid-commands/paginators/select_paginator.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Generic, List, Optional, TypeVar, Union + +import discord +from paginators import BasePaginator, FileLike, PageLike + +T = TypeVar("T", bound=PageLike) + + +class PageEntry(Generic[T]): + def __init__( + self, + value: T, + *, + page_title: str, + page_description: Optional[str] = None, + attachment: discord.File = None, + ) -> None: + self.page_title = page_title + self.page_description = page_description + self.value = value + self.attachment = attachment + + +class SelectMenuBasedPaginator(Generic[T], BasePaginator[T]): + def __init__( + self, + user: Union[discord.User, discord.Member], + *, + pages: List[PageEntry[T]], + ) -> None: + self.select = PaginatorSelect(view=self) + pages_: List[T] = [] + attachments_: List[discord.File] = [] + for i, page in enumerate(pages): + pages_.append(page.value) + if page.attachment: + attachments_.append(page.attachment) + self.select.add_option( + label=page.page_title, + value=str(i), + description=page.page_description, + ) + super().__init__(user, pages=pages_, attachments=attachments_) + self.add_item(self.select) + + +class PaginatorSelect(discord.ui.Select[SelectMenuBasedPaginator[PageLike]]): + def __init__(self, view: SelectMenuBasedPaginator[PageLike]) -> None: + super().__init__(min_values=1, max_values=1) + self.base_view = view + + async def callback(self, interaction: discord.Interaction) -> None: + # the user can only select one value and shoud at least select it + # so this is always fine + await interaction.response.defer() + self.base_view.current_page = int(self.values[0]) + page = self.base_view.pages[self.base_view.current_page] + await self.base_view.send_page(interaction, page) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return await self.base_view.interaction_check(interaction) + + +class EmbedSelectPaginator(SelectMenuBasedPaginator[discord.Embed]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[discord.Embed]]) -> None: + super().__init__(user, pages=pages) + + +class FileSelectPaginator(SelectMenuBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[FileLike]]) -> None: + super().__init__(user, pages=pages) + + +class StringSelectPaginator(SelectMenuBasedPaginator[str]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[str]]) -> None: + super().__init__(user, pages=pages) diff --git a/examples/hybrid-commands/utils/help.py b/examples/hybrid-commands/utils/help.py new file mode 100644 index 0000000..308b1ba --- /dev/null +++ b/examples/hybrid-commands/utils/help.py @@ -0,0 +1,186 @@ +from typing import Any, Iterable, List, Mapping, Optional + +import discord +import humanfriendly +from discord.ext import commands +from discord.ext.commands import Cog, Command, Group +from paginators.advanced_paginator import CategoryEntry, EmbedCategoryPaginator +from paginators.button_paginator import EmbedButtonPaginator + + +class Formatter: + def __init__(self, help_command: commands.HelpCommand) -> None: + self.ctx = help_command.context + self.help_command = help_command + + def __format_command_signature(self, command: commands.Command) -> tuple[str, str]: + params = self.__format_param(command) + return f"{command.qualified_name}\n", f"```yaml\n{params}```" + + def __format_param(self, param: commands.Command) -> str: + signature = self.help_command.get_command_signature(param) + return signature + + @staticmethod + def __format_command_help(command: commands.Command) -> str: + return command.help or "No help provided." + + @staticmethod + def __format_command_aliases(command: commands.Command) -> str: + return f"```yaml\nAliases: {', '.join(command.aliases)}```" if command.aliases else "No aliases." + + @staticmethod + def __format_command_cooldown(command: commands.Command) -> str: + return ( + f"Cooldown: {humanfriendly.format_timespan(command.cooldown.per, max_units=2)} per user." + if command.cooldown + else "No cooldown set." + ) + + @staticmethod + def __format_command_enabled(command: commands.Command) -> str: + return f"Enabled: {command.enabled}" if command.enabled else "Command is disabled." + + def format_command(self, command: commands.Command) -> discord.Embed: + signature = self.__format_command_signature(command) + embed = discord.Embed( + title=signature[0], + description=signature[1] + self.__format_command_help(command), + color=discord.Color.blue(), + ) + embed.add_field(name="Aliases", value=self.__format_command_aliases(command), inline=True) + embed.add_field(name="Cooldown", value=self.__format_command_cooldown(command), inline=True) + embed.add_field(name="Enabled", value=self.__format_command_enabled(command), inline=True) + embed.set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + return embed + + async def format_cog_or_group( + self, cog_or_group: Optional[commands.Cog | commands.Group], commands_: List[commands.Command | commands.Group] + ) -> List[discord.Embed]: + category_name = cog_or_group.qualified_name if cog_or_group else "No Category" + if isinstance(cog_or_group, commands.Group): + category_desc = cog_or_group.help or "No description provided." + else: + category_desc = ( + cog_or_group.description if cog_or_group and cog_or_group.description else "No description provided." + ) + cog_embed = ( + discord.Embed( + title=f"{category_name} Commands", + description=f"*{category_desc}*" or "*No description provided.*", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.ctx.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + ) + embeds: List[discord.Embed] = [] + for i in range(0, len(commands_), 5): + embed = cog_embed.copy() + for command in commands_[i : i + 5]: + signature = self.__format_command_signature(command) + embed.add_field( + name=signature[0], + value=signature[1] + self.__format_command_help(command), + inline=False, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + embeds.append(embed) + return embeds if embeds else [cog_embed] + + +class CustomHelpCommand(commands.HelpCommand): + @staticmethod + def flatten_commands(commands_: Iterable[commands.Command | commands.Group]) -> List[commands.Command]: + flattened = [] + for command in commands_: + if isinstance(command, commands.Group): + flattened.extend(CustomHelpCommand.flatten_commands(command.commands)) + else: + flattened.append(command) + return flattened + + async def send_bot_help(self, mapping: Mapping[Optional[Cog], List[Command[Any, ..., Any]]], /) -> None: + home_embed = ( + discord.Embed( + title="Home", + description="Documentation Bot Home Page - Custom Help Command", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.context.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + ) + + home_pages: List[discord.Embed] = [] + + for i in range(0, len(mapping), 5): + embed = home_embed.copy() + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + embed.add_field( + name=cog.qualified_name if cog else "No Category", + value=f"*{cog.description if cog and cog.description else 'No description provided.'}* `[Commands: {len(filtered_cmds)}]`", + inline=False, + ) + home_pages.append(embed) + + categories: List[CategoryEntry[discord.Embed]] = [ + CategoryEntry( + category_title="Home", + category_description="Documentation Bot Home Page", + pages=home_pages, + ) + ] + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + + cog_name = cog.qualified_name if cog else "No Category" + cog_desc = cog.description if cog and cog.description else "No description provided." + + categories.append( + CategoryEntry( + category_title=cog_name, + category_description=cog_desc, + pages=await Formatter(self).format_cog_or_group(cog, filtered_cmds), + ) + ) + + paginator = EmbedCategoryPaginator(self.context.author, pages=categories) + await paginator.start_paginator(self.context) + + async def send_cog_help(self, cog: Cog, /) -> None: + commands_ = await self.filter_commands(self.flatten_commands(cog.get_commands()), sort=True) + embeds = await Formatter(self).format_cog_or_group(cog, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_group_help(self, group: Group[Any, ..., Any], /) -> None: + commands_ = await self.filter_commands(self.flatten_commands(group.commands), sort=True) + embeds = await Formatter(self).format_cog_or_group(group, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_command_help(self, command: Command[Any, ..., Any], /) -> None: + command_ = await self.filter_commands([command], sort=True) + embed = Formatter(self).format_command(command_[0]) + await self.context.send(embed=embed) + + async def send_error_message(self, error: str, /) -> None: + embed = discord.Embed( + title="Error", + description=error, + color=discord.Color.red(), + ).set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + await self.context.send(embed=embed) diff --git a/examples/hybrid-commands/views/__init__.py b/examples/hybrid-commands/views/__init__.py new file mode 100644 index 0000000..4a0afe6 --- /dev/null +++ b/examples/hybrid-commands/views/__init__.py @@ -0,0 +1,97 @@ +# Our objectives: +# - Create a view that handles errors +# - Create a view that disables all components after timeout +# - Make sure that the view only processes interactions from the user who invoked the command + +from __future__ import annotations + +import traceback +import typing + +import discord +from discord.ui.select import Select + + +class BaseView(discord.ui.View): + interaction: discord.Interaction | None = None + message: discord.Message | None = None + + def __init__(self, user: discord.User | discord.Member, timeout: float = 60.0): + super().__init__(timeout=timeout) + # We set the user who invoked the command as the user who can interact with the view + self.user = user + + # make sure that the view only processes interactions from the user who invoked the command + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user.id: + await interaction.response.send_message("You cannot interact with this view.", ephemeral=True) + return False + # update the interaction attribute when a valid interaction is received + self.interaction = interaction + return True + + # to handle errors we first notify the user that an error has occurred and then disable all components + + def _disable_all(self) -> None: + # disable all components + # so components that can be disabled are buttons and select menus + for item in self.children: + if isinstance(item, discord.ui.Button) or isinstance(item, Select): + item.disabled = True + + # after disabling all components we need to edit the message with the new view + # now when editing the message there are two scenarios: + # 1. the view was never interacted with i.e in case of plain timeout here message attribute will come in handy + # 2. the view was interacted with and the interaction was processed and we have the latest interaction stored in the interaction attribute + async def _edit(self, **kwargs: typing.Any) -> None: + if self.interaction is None and self.message is not None: + # if the view was never interacted with and the message attribute is not None, edit the message + await self.message.edit(**kwargs) + elif self.interaction is not None: + try: + # if not already responded to, respond to the interaction + await self.interaction.response.edit_message(**kwargs) + except discord.InteractionResponded: + # if already responded to, edit the response + await self.interaction.edit_original_response(**kwargs) + + async def on_error( + self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item[BaseView] + ) -> None: + tb = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + message = f"An error occurred while processing the interaction for {str(item)}:\n```py\n{tb}\n```" + # disable all components + self._disable_all() + # edit the message with the error message + await self._edit(content=message, view=self) + # stop the view + self.stop() + + async def on_timeout(self) -> None: + # disable all components + self._disable_all() + # edit the message with the new view + await self._edit(view=self) + + +class BaseModal(discord.ui.Modal): + _interaction: discord.Interaction | None = None + + async def on_submit(self, interaction: discord.Interaction) -> None: + if not interaction.response.is_done(): + await interaction.response.defer() + self._interaction = interaction + self.stop() + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + tb = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + message = f"An error occurred while processing the interaction:\n```py\n{tb}\n```" + try: + await interaction.response.send_message(message, ephemeral=True) + except discord.InteractionResponded: + await interaction.edit_original_response(content=message, view=None) + self.stop() + + @property + def interaction(self) -> discord.Interaction | None: + return self._interaction diff --git a/examples/slash-commands/cogs/cog.py b/examples/slash-commands/cogs/cog.py index eb749a2..f4ae10f 100644 --- a/examples/slash-commands/cogs/cog.py +++ b/examples/slash-commands/cogs/cog.py @@ -56,6 +56,16 @@ async def ping(self, inter: discord.Interaction) -> None: @app_commands.command(name="echo", description="Echo a message") async def echo(self, inter: discord.Interaction, message: str) -> None: + """ + Echo a message back to the user. + + Parameters + ---------- + inter : discord.Interaction + The interaction + message : str + The message to echo + """ await inter.response.send_message(message) diff --git a/examples/slash-commands/cogs/groupcog.py b/examples/slash-commands/cogs/groupcog.py index 9181b9a..d8b9d6e 100644 --- a/examples/slash-commands/cogs/groupcog.py +++ b/examples/slash-commands/cogs/groupcog.py @@ -19,22 +19,102 @@ def __init__(self, bot: CustomBot) -> None: @group.command(name="pow", description="Raise a number to a power") async def power(self, inter: discord.Interaction, a: int, b: int) -> None: + """ + Raise a number to a power. + + Parameters + ---------- + inter : discord.Interaction + The interaction. + a : int + The number. + b : int + The power. + + Returns + ------- + None + """ await inter.response.send_message(f"{a} ^ {b} = {a ** b}") @app_commands.command(name="add", description="Add two numbers") async def add(self, inter: discord.Interaction, a: int, b: int) -> None: + """ + Add two numbers. + + Parameters + ---------- + inter : discord.Interaction + The interaction. + a : int + The first number. + b : int + The second number. + + Returns + ------- + None + """ await inter.response.send_message(f"{a} + {b} = {a + b}") @app_commands.command(name="subtract", description="Subtract two numbers") async def subtract(self, inter: discord.Interaction, a: int, b: int) -> None: + """ + Subtract two numbers. + + Parameters + ---------- + inter : discord.Interaction + The interaction. + a : int + The first number. + b : int + The second number. + + Returns + ------- + None + """ await inter.response.send_message(f"{a} - {b} = {a - b}") @commands.hybrid_command(name="multiply", description="Multiply two numbers") async def multiply(self, ctx: commands.Context[CustomBot], a: int, b: int) -> None: + """ + Multiply two numbers. + + Parameters + ---------- + ctx : commands.Context + The context. + a : int + The first number. + b : int + The second number. + + Returns + ------- + None + """ await ctx.send(f"{a} * {b} = {a * b}") @commands.command(name="divide", description="Divide two numbers") async def divide(self, ctx: commands.Context[CustomBot], a: int, b: int) -> None: + """ + Divide two numbers. + + Parameters + ---------- + ctx : commands.Context + The context. + a : int + The first number. + b : int + The second number. + + Returns + ------- + None + """ await ctx.send(f"{a} / {b} = {a / b}") diff --git a/examples/slash-commands/main.py b/examples/slash-commands/main.py index 7de25b8..225a42b 100644 --- a/examples/slash-commands/main.py +++ b/examples/slash-commands/main.py @@ -10,7 +10,7 @@ from discord.app_commands import locale_str as _ from discord.ext import commands from dotenv import load_dotenv -from utils.translator import Translator +from utils.help import CustomHelpCommand from utils.tree import SlashCommandTree @@ -22,10 +22,17 @@ def __init__(self, prefix: str, ext_dir: str, *args: typing.Any, **kwargs: typin intents = discord.Intents.default() intents.members = True intents.message_content = True - super().__init__(*args, **kwargs, command_prefix=commands.when_mentioned_or(prefix), intents=intents) + super().__init__( + *args, + **kwargs, + command_prefix=commands.when_mentioned_or(prefix), + intents=intents, + help_command=CustomHelpCommand(with_app_command=True, description="Show help for a command"), + owner_ids={656838010532265994}, + ) self.logger = logging.getLogger(self.__class__.__name__) self.ext_dir = ext_dir - self.synced = False + self.synced = True async def _load_extensions(self) -> None: if not os.path.isdir(self.ext_dir): @@ -46,9 +53,10 @@ async def on_ready(self) -> None: self.logger.info(f"Logged in as {self.user} ({self.user.id})") async def setup_hook(self) -> None: - await self.tree.set_translator(Translator()) + # await self.tree.set_translator(Translator()) self.client = aiohttp.ClientSession() await self._load_extensions() + await self.load_extension("jishaku") if not self.synced: await self.tree.sync() self.synced = not self.synced @@ -90,8 +98,38 @@ def main() -> None: ) @app_commands.describe(test=_("A test choice")) async def translations(interaction: discord.Interaction, test: app_commands.Choice[int]) -> None: + """ + A test command + + Parameters + ---------- + interaction : discord.Interaction + The interaction + test : app_commands.Choice[int] + A test choice + """ await interaction.response.send_message(repr(test)) + @bot.command(name="idk") + async def idk(ctx: commands.Context, some_arg: str) -> None: + """ + I don't know what to do + + Parameters + ---------- + ctx : commands.Context + The context + some_arg : str + Some argument + """ + await ctx.send("I don't know what to do") + + # @bot.hybrid_command(name="help", description="Show help for a command") + # async def help_(ctx: commands.Context, command: str = None) -> None: + # help_command = CustomHelpCommand(include_app_commands=True) + # help_command.context = ctx + # await help_command.command_callback(ctx, command=command) + bot.run() diff --git a/examples/slash-commands/paginators/__init__.py b/examples/slash-commands/paginators/__init__.py new file mode 100644 index 0000000..92e49a1 --- /dev/null +++ b/examples/slash-commands/paginators/__init__.py @@ -0,0 +1,93 @@ +"""Base class for paginators.""" + +from __future__ import annotations + +from io import BufferedIOBase +from os import PathLike +from typing import TYPE_CHECKING, Any, Generic, List, TypeVar, Union + +import discord +from discord.ext import commands +from views import BaseView + +if TYPE_CHECKING: + from typing_extensions import TypeAlias + +PageLike: TypeAlias = Union[discord.Embed, str, bytes, PathLike[Any], BufferedIOBase, discord.File] +FileLike: TypeAlias = Union[str, bytes, PathLike[Any], BufferedIOBase] + +T = TypeVar("T", bound=PageLike) + + +class BasePaginator(Generic[T], BaseView): + pages: List[T] + current_page: int + + def __init__( + self, user: Union[discord.User, discord.Member], pages: List[T], *, attachments: List[discord.File] = None + ) -> None: + super().__init__(user=user, timeout=180) + self.pages = pages + self.current_page: int = 0 + self.attachments = attachments or [] + + async def _send(self, ctx_or_inter: commands.Context | discord.Interaction, *args: Any, **kwargs: Any) -> None: + if isinstance(ctx_or_inter, commands.Context): + if self.message is None: + self.message = await ctx_or_inter.send(*args, **kwargs) + return + self.message = await ctx_or_inter.send(*args, **kwargs) + return + if self.message is None: + await ctx_or_inter.response.send_message(*args, **kwargs) + self.message = await ctx_or_inter.original_response() + return + self.message = await ctx_or_inter.edit_original_response(*args, **kwargs) + + async def send_page(self, ctx_or_inter: commands.Context | discord.Interaction, page: T) -> None: + if isinstance(page, discord.Embed): # Embed + # Check if the embed has an associated attachment and send it along with the embed + attachment = None + if (page.image.url or "").startswith("attachment://") and len(self.attachments) > self.current_page: + attachment = discord.File(self.attachments[self.current_page].fp.name) + attachments = [attachment] if attachment else [] + if self.message is None: + return await self._send(ctx_or_inter, embed=page, view=self, files=attachments) + return await self._send(ctx_or_inter, embed=page, view=self, attachments=attachments) + + if isinstance(page, str): # String + # Check if the string has an associated attachment and send it along with the string + attachment = None + if len(self.attachments) > self.current_page: + attachment = discord.File(self.attachments[self.current_page].fp.name) + attachments = [attachment] if attachment else [] + if self.message is None: + return await self._send(ctx_or_inter, content=page, view=self, files=attachments) + return await self._send(ctx_or_inter, content=page, view=self, attachments=attachments) + + # File + file = discord.File(page) if not isinstance(page, discord.File) else discord.File(page.fp.name) + if self.message is None: + return await self._send(ctx_or_inter, file=file, view=self) + return await self._send(ctx_or_inter, file=file, view=self) + + async def start_paginator( + self, ctx_or_inter: commands.Context | discord.Interaction, *, starting_page: int = 0 + ) -> None: + self.current_page = starting_page + page = self.pages[starting_page] + await self.send_page(ctx_or_inter, page) + + async def stop_paginator(self) -> None: + self._disable_all() + await self._edit(view=self) + + async def next_page(self, inter: discord.Interaction) -> None: + self.current_page = (self.current_page + 1) % len(self.pages) + page = self.pages[self.current_page] + await self.send_page(inter, page) + + async def previous_page(self, inter: discord.Interaction) -> None: + self.current_page = (self.current_page - 1) % len(self.pages) + page = self.pages[self.current_page] + await self.send_page(inter, page) diff --git a/examples/slash-commands/paginators/advanced_paginator.py b/examples/slash-commands/paginators/advanced_paginator.py new file mode 100644 index 0000000..3f137d9 --- /dev/null +++ b/examples/slash-commands/paginators/advanced_paginator.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Generic, List, Optional, TypeVar, Union + +import discord +from discord import File, Member, User +from paginators import FileLike, PageLike +from paginators.button_paginator import ButtonBasedPaginator + +T = TypeVar("T", bound=PageLike) + + +class CategoryEntry(Generic[T]): + def __init__( + self, + *, + category_title: str, + category_description: Optional[str] = None, + pages: Optional[List[T]] = None, + attachments: Optional[List[File]] = None, + ) -> None: + self.category_title = category_title + self.category_description = category_description + self.pages = pages or [] + self.attachments = attachments or [] + + def add_page(self, page: T) -> None: + self.pages.append(page) + + +class CategoryBasedPaginator(Generic[T], ButtonBasedPaginator[T]): + def __init__( + self, + user: Union[User, Member], + *, + pages: List[CategoryEntry[T]], + ) -> None: + self.categories = pages + self.current_category: int = 0 + + super().__init__(user, pages[self.current_category].pages, attachments=pages[self.current_category].attachments) + + self.select = CategoryPaginatorSelect() + for i, page in enumerate(pages): + self.select.add_option( + label=page.category_title, + value=str(i), + description=page.category_description, + ) + self.add_item(self.select) + + +class CategoryPaginatorSelect(discord.ui.Select[CategoryBasedPaginator[PageLike]]): + def __init__(self) -> None: + super().__init__(min_values=1, max_values=1) + + async def callback(self, interaction: discord.Interaction) -> None: + # the user can only select one value and shoud at least select it + # so this is always fine + await interaction.response.defer() + self.view.current_category = int(self.values[0]) + view: CategoryBasedPaginator[PageLike] = self.view + view.pages = view.categories[self.view.current_category].pages + view.attachments = view.categories[self.view.current_category].attachments + view.current_page = 0 + page = view.pages[view.current_page] + await view.send_page(interaction, page) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return await self.view.interaction_check(interaction) + + +class EmbedCategoryPaginator(CategoryBasedPaginator[discord.Embed]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[discord.Embed]]) -> None: + super().__init__(user, pages=pages) + + +class FileCategoryPaginator(CategoryBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[FileLike]]) -> None: + super().__init__(user, pages=pages) + + +class StringCategoryPaginator(CategoryBasedPaginator[str]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[CategoryEntry[str]]) -> None: + super().__init__(user, pages=pages) diff --git a/examples/slash-commands/paginators/button_paginator.py b/examples/slash-commands/paginators/button_paginator.py new file mode 100644 index 0000000..46412eb --- /dev/null +++ b/examples/slash-commands/paginators/button_paginator.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Generic, List, TypeVar, Union + +import discord +from discord import PartialEmoji +from paginators import BasePaginator, FileLike, PageLike + +if TYPE_CHECKING: + from views import BaseView + + +T = TypeVar("T", bound=PageLike) + + +class ButtonBasedPaginator(Generic[T], BasePaginator[T]): + @discord.ui.button(emoji=PartialEmoji.from_str("⏪")) + async def goto_first_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + self.current_page = 0 + page = self.pages[self.current_page] + await self.send_page(inter, page) + + @discord.ui.button(emoji=PartialEmoji.from_str("◀️")) + async def previous_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.previous_page(inter) + + @discord.ui.button(emoji=PartialEmoji.from_str("▶️")) + async def next_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.next_page(inter) + + @discord.ui.button(emoji=PartialEmoji.from_str("⏩")) + async def goto_last_page_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + self.current_page = len(self.pages) - 1 + page = self.pages[self.current_page] + await self.send_page(inter, page) + + @discord.ui.button(emoji=PartialEmoji.from_str("🗑️")) + async def stop_paginator_callback(self, inter: discord.Interaction, _: discord.ui.Button[BaseView]) -> None: + await inter.response.defer() + await self.stop_paginator() + + +class EmbedButtonPaginator(ButtonBasedPaginator[discord.Embed]): + def __init__( + self, + user: Union[discord.User, discord.Member], + pages: List[discord.Embed], + *, + attachments: List[discord.File] = None, + ) -> None: + super().__init__(user, pages, attachments=attachments) + + +class FileButtonPaginator(ButtonBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], pages: List[FileLike]) -> None: + super().__init__(user, pages) + + +class StringButtonPaginator(ButtonBasedPaginator[str]): + def __init__( + self, + user: Union[discord.User, discord.Member], + pages: List[str], + *, + attachments: List[discord.File] = None, + ) -> None: + super().__init__(user, pages, attachments=attachments) diff --git a/examples/slash-commands/paginators/select_paginator.py b/examples/slash-commands/paginators/select_paginator.py new file mode 100644 index 0000000..3f0b614 --- /dev/null +++ b/examples/slash-commands/paginators/select_paginator.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from typing import Generic, List, Optional, TypeVar, Union + +import discord +from paginators import BasePaginator, FileLike, PageLike + +T = TypeVar("T", bound=PageLike) + + +class PageEntry(Generic[T]): + def __init__( + self, + value: T, + *, + page_title: str, + page_description: Optional[str] = None, + attachment: discord.File = None, + ) -> None: + self.page_title = page_title + self.page_description = page_description + self.value = value + self.attachment = attachment + + +class SelectMenuBasedPaginator(Generic[T], BasePaginator[T]): + def __init__( + self, + user: Union[discord.User, discord.Member], + *, + pages: List[PageEntry[T]], + ) -> None: + self.select = PaginatorSelect(view=self) + pages_: List[T] = [] + attachments_: List[discord.File] = [] + for i, page in enumerate(pages): + pages_.append(page.value) + if page.attachment: + attachments_.append(page.attachment) + self.select.add_option( + label=page.page_title, + value=str(i), + description=page.page_description, + ) + super().__init__(user, pages=pages_, attachments=attachments_) + self.add_item(self.select) + + +class PaginatorSelect(discord.ui.Select[SelectMenuBasedPaginator[PageLike]]): + def __init__(self, view: SelectMenuBasedPaginator[PageLike]) -> None: + super().__init__(min_values=1, max_values=1) + self.base_view = view + + async def callback(self, interaction: discord.Interaction) -> None: + # the user can only select one value and shoud at least select it + # so this is always fine + await interaction.response.defer() + self.base_view.current_page = int(self.values[0]) + page = self.base_view.pages[self.base_view.current_page] + await self.base_view.send_page(interaction, page) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + return await self.base_view.interaction_check(interaction) + + +class EmbedSelectPaginator(SelectMenuBasedPaginator[discord.Embed]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[discord.Embed]]) -> None: + super().__init__(user, pages=pages) + + +class FileSelectPaginator(SelectMenuBasedPaginator[FileLike]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[FileLike]]) -> None: + super().__init__(user, pages=pages) + + +class StringSelectPaginator(SelectMenuBasedPaginator[str]): + def __init__(self, user: Union[discord.User, discord.Member], *, pages: List[PageEntry[str]]) -> None: + super().__init__(user, pages=pages) diff --git a/examples/slash-commands/utils/help.py b/examples/slash-commands/utils/help.py new file mode 100644 index 0000000..df7a85a --- /dev/null +++ b/examples/slash-commands/utils/help.py @@ -0,0 +1,633 @@ +import copy +import difflib +import functools +import inspect +import re +from typing import Any, Callable, Dict, Generator, Iterable, List, Mapping, Optional + +import discord +import humanfriendly +from discord import app_commands +from discord.ext import commands +from discord.ext.commands import Cog, Command, CommandError, Context, Group +from discord.ext.commands.core import get_signature_parameters +from discord.ext.commands.parameters import Parameter, Signature +from paginators.advanced_paginator import CategoryEntry, EmbedCategoryPaginator +from paginators.button_paginator import EmbedButtonPaginator + + +class _InjectorCallback: + # This class is to ensure that the help command instance gets passed properly + # The final level of invocation will always leads back to the _original instance + # hence bind needed to be modified before invoke is called. + def __init__(self, original_callback: Any, bind: "CustomHelpCommand") -> None: + self.callback = original_callback + self.bind = bind + + async def invoke(self, *args: Any, **kwargs: Any) -> Any: + # don't put cog in command_callback + # it used to be that i could do this in parse_arguments, but appcommand extracts __self__ directly from callback + if self.bind.cog is not None: + cog, *args = args + + return await self.callback.__func__(self.bind, *args, **kwargs) + + +def _inject_callback(inject): + try: + inject.__original_callback__ + except AttributeError: + inject.__original_callback__ = _InjectorCallback(inject.command_callback, inject) + + caller = inject.__original_callback__ + original_callback = caller.callback + + async def wrapper(*args, **kwargs): + return await caller.invoke(*args, **kwargs) + + callback = copy.copy(wrapper) + signature = list(Signature.from_callable(original_callback).parameters.values()) + callback.__signature__ = Signature.from_callable(callback).replace(parameters=signature) + inject.command_callback = callback + + +def _parse_params_docstring(func: Callable[..., Any]) -> dict[str, str]: + doc = inspect.getdoc(func) + if doc is None: + return {} + + param_docs = {} + sphinx_pattern = re.compile(r"^\s*:param\s+(\S+).*?:\s*(.+)", re.MULTILINE) + google_pattern = re.compile(r"^\s*(\S+)\s*\(.*?\):\s*(.+)", re.MULTILINE) + numpy_pattern = re.compile(r"^\s*(\S+)\s*:\s*.*?\n\s*(.+?)(?=\n\S|\Z)", re.DOTALL | re.MULTILINE) + + for pattern in (sphinx_pattern, google_pattern, numpy_pattern): + for match in pattern.finditer(doc): + param_name, desc = match.groups() + param_docs[param_name] = desc + return param_docs + + +def _construct_full_name(command: commands.Command | app_commands.Command) -> str: + parent: Optional[app_commands.Group] = command.parent + entries = [] + while parent is not None: + entries.append(parent.name) + parent = parent.parent + entries.reverse() + entries.append(command.name) + return " ".join(entries) + + +class _HelpHybridCommandImpl(commands.HybridCommand): + def __init__(self, inject: "CustomHelpCommand", *args: Any, **kwargs: Any) -> None: + _inject_callback(inject) + super().__init__(inject.command_callback, *args, **kwargs) + self._original: "CustomHelpCommand" = inject + self._injected: "CustomHelpCommand" = inject + self.params: Dict[str, Parameter] = get_signature_parameters( + inject.__original_callback__.callback, globals(), skip_parameters=1 + ) + + # get function params descriptions, from the original callback docstring + param_descs = _parse_params_docstring(inject.__original_callback__.callback) + if self.app_command: + app_params = [p for p in self.app_command.parameters if p.name in param_descs] + app_commands.describe(**{p.name: param_descs[p.name] for p in app_params})(self.app_command) + + self.params.update( + (name, param.replace(description=desc)) + for name, desc in param_descs.items() + if (param := self.params.get(name)) + ) + + self.__inject_callback_metadata(inject) + + def __inject_callback_metadata(self, inject: "CustomHelpCommand") -> None: + if not self.with_app_command: + return + autocomplete = inject.help_command_autocomplete + self.autocomplete(list(dict.fromkeys(self.params))[-1])(autocomplete) + + async def prepare(self, ctx: Context[Any]) -> None: + self._injected = injected = self._original.copy() + injected.context = ctx + self._original.__original_callback__.bind = injected # type: ignore + self.params = get_signature_parameters(injected.__original_callback__.callback, globals(), skip_parameters=1) + + # get function params descriptions, from the original callback docstring + param_descs = _parse_params_docstring(injected.__original_callback__.callback) + self.params.update( + (name, param.replace(description=desc)) + for name, desc in param_descs.items() + if (param := self.params.get(name)) + ) + + on_error = injected.on_help_command_error + if not hasattr(on_error, "__help_command_not_overridden__"): + if self.cog is not None: + self.on_error = self._on_error_cog_implementation + else: + self.on_error = on_error + + await super().prepare(ctx) + + async def _on_error_cog_implementation(self, _, ctx: Context[commands.Bot], error: CommandError) -> None: + await self._injected.on_help_command_error(ctx, error) + + def _inject_into_cog(self, cog: Cog) -> None: + # Warning: hacky + + # Make the cog think that get_commands returns this command + # as well if we inject it without modifying __cog_commands__ + # since that's used for the injection and ejection of cogs. + def wrapped_get_commands( + *, _original: Callable[[], List[Command[Any, ..., Any]]] = cog.get_commands + ) -> List[Command[Any, ..., Any]]: + ret = _original() + ret.append(self) + return ret + + # Ditto here + def wrapped_walk_commands( + *, _original: Callable[[], Generator[Command[Any, ..., Any], None, None]] = cog.walk_commands + ): + yield from _original() + yield self + + functools.update_wrapper(wrapped_get_commands, cog.get_commands) + functools.update_wrapper(wrapped_walk_commands, cog.walk_commands) + cog.get_commands = wrapped_get_commands + cog.walk_commands = wrapped_walk_commands + self.cog = cog + + def _eject_cog(self) -> None: + if self.cog is None: + return + + # revert back into their original methods + cog = self.cog + cog.get_commands = cog.get_commands.__wrapped__ + cog.walk_commands = cog.walk_commands.__wrapped__ + self.cog = None + + +class Formatter: + def __init__(self, help_command: commands.HelpCommand) -> None: + self.ctx = help_command.context + self.help_command = help_command + + def __format_command_signature(self, command: commands.Command | app_commands.Command) -> tuple[str, str]: + params = self.help_command.get_command_signature(command) + return f"{command.qualified_name}\n", f"```yaml\n{params}```" + + @staticmethod + def __format_param(param: app_commands.Parameter | commands.Parameter) -> str: + result = ( + f"{param.name}={param.default}" + if not param.required and param.default is not discord.utils.MISSING and param.default not in (None, "") + else f"{param.name}" + ) + result = f"[{result}]" if not param.required else f"<{result}>" + if isinstance(param, commands.Parameter): + return f"```yaml\n{param.name} ({param.annotation.__name__}) - {result}:\n\t{param.description}```" + choices = ( + ", ".join(f"'{choice.value}'" if isinstance(choice.value, str) else choice.name for choice in param.choices) + if param.choices + else "" + ) + result = f"{param.name} ({param.type.name}) - {result}:" + result += f"\n\t{param.description}" if param.description else "" + result += f"\n\tChoices: {choices}" if choices else "" + return f"```yaml\n{result}```" + + @staticmethod + def __format_command_help(command: commands.Command | app_commands.Command) -> str: + return command.description or "No help provided." + + @staticmethod + def __format_command_aliases(command: commands.Command | app_commands.Command) -> str: + if isinstance(command, app_commands.Command): + return "No aliases." + return f"```yaml\nAliases: {', '.join(command.aliases)}```" if command.aliases else "No aliases." + + @staticmethod + def __format_command_cooldown(command: commands.Command | app_commands.Command) -> str: + if isinstance(command, app_commands.Command): + return "No cooldown set." + return ( + f"Cooldown: {humanfriendly.format_timespan(command.cooldown.per, max_units=2)} per user." + if command.cooldown + else "No cooldown set." + ) + + @staticmethod + def __format_command_enabled(command: commands.Command | app_commands.Command) -> str: + if isinstance(command, app_commands.Command): + return "Command is enabled." + return f"Enabled: {command.enabled}" if command.enabled else "Command is disabled." + + def format_command(self, command: commands.Command | app_commands.Command) -> discord.Embed: + signature = self.__format_command_signature(command) + embed = discord.Embed( + title=signature[0], + description=signature[1] + self.__format_command_help(command), + color=discord.Color.blue(), + ) + + params = command.parameters if isinstance(command, app_commands.Command) else command.params.values() + # format each parameter of the command + for param in params: + embed.add_field( + name=param.name, + value=self.__format_param(param), + inline=False, + ) + embed.add_field(name="Aliases", value=self.__format_command_aliases(command), inline=True) + embed.add_field(name="Cooldown", value=self.__format_command_cooldown(command), inline=True) + embed.add_field(name="Enabled", value=self.__format_command_enabled(command), inline=True) + embed.set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + return embed + + async def format_cog_or_group( + self, + cog_or_group: Optional[commands.GroupCog | app_commands.Group | commands.Cog | commands.Group], + commands_: List[commands.Command | commands.Group | app_commands.Command | app_commands.Group], + ) -> List[discord.Embed]: + category_name = cog_or_group.qualified_name if cog_or_group else "No Category" + if isinstance(cog_or_group, commands.Group): + category_desc = cog_or_group.help or "No description provided." + else: + category_desc = ( + cog_or_group.description if cog_or_group and cog_or_group.description else "No description provided." + ) + cog_embed = ( + discord.Embed( + title=f"{category_name} Commands", + description=f"*{category_desc}*" or "*No description provided.*", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.ctx.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.ctx.author}", + icon_url=self.ctx.author.display_avatar, + ) + ) + embeds: List[discord.Embed] = [] + for i in range(0, len(commands_), 5): + embed = cog_embed.copy() + for command in commands_[i : i + 5]: + signature = self.__format_command_signature(command) + embed.add_field( + name=signature[0], + value=signature[1] + self.__format_command_help(command), + inline=False, + ) + embed.set_thumbnail(url=self.ctx.bot.user.display_avatar) + embeds.append(embed) + return embeds if embeds else [cog_embed] + + +class CustomHelpCommand(commands.HelpCommand): + __original_callback__: _InjectorCallback + + def __init__( + self, + *, + name: str = "help", + description: str = "Shows this message", + with_app_command: bool = False, + **kwargs: Any, + ) -> None: + super().__init__(**kwargs) + self.with_app_command = with_app_command + self.command_attrs["with_app_command"] = with_app_command + self.command_attrs["name"] = name + self.command_attrs["description"] = description + self._command_impl = _HelpHybridCommandImpl(self, **self.command_attrs) + + def _add_to_bot(self, bot: commands.bot.BotBase) -> None: + command = _HelpHybridCommandImpl(self, **self.command_attrs) + self._command_impl = command + bot.add_command(command) + + def _remove_from_bot(self, bot: commands.bot.BotBase) -> None: + impl = self._command_impl + bot.remove_command(impl.name) + app = impl.app_command + for snowflake in getattr(app, "_guild_ids", None) or []: + bot.tree.remove_command(app.name, guild=discord.Object(snowflake)) + impl._eject_cog() + + def get_command_signature( + self, command: commands.Command[Any, ..., Any] | app_commands.Command[Any, ..., Any] + ) -> str: + if isinstance(command, commands.Command): + return super().get_command_signature(command) + + command_path = _construct_full_name(command) + + def _format_param(data: str, *, required: bool = False) -> str: + return f"<{data}>" if required else f"[{data}]" + + params = [] + for param in command.parameters: + # check for attachment type + if param.type == discord.AppCommandOptionType.attachment: + params.append(_format_param(f"{param.name} (upload a file)", required=param.required)) + continue + + choices = ( + "|".join( + f"'{choice.value}'" if isinstance(choice.value, str) else choice.name for choice in param.choices + ) + if param.choices + else "" + ) + default = ( + f"={param.default}" + if not param.required and param.default is not discord.utils.MISSING and param.default not in (None, "") + else "" + ) + + # format name, choices, and default + formatted = f"{param.name}{default} ({choices})" if choices else f"{param.name}{default}" + params.append(_format_param(formatted, required=param.required)) + + return f"/{command_path} {' '.join(params)}" + + @staticmethod + def flatten_commands( + commands_: Iterable[commands.Command | commands.Group | app_commands.Command | app_commands.Group], + ) -> List[app_commands.Command | commands.Command]: + flattened = [] + for command in commands_: + if isinstance(command, commands.Group | app_commands.Group): + flattened.extend(CustomHelpCommand.flatten_commands(command.commands)) + else: + flattened.append(command) + return flattened + + async def send_bot_help( + self, mapping: Mapping[Optional[Cog], List[Command[Any, ..., Any] | app_commands.Command[Any, ..., Any]]], / + ) -> None: + home_embed = ( + discord.Embed( + title="Home", + description="Documentation Bot Home Page - Custom Help Command", + color=discord.Color.blue(), + ) + .set_thumbnail(url=self.context.bot.user.display_avatar) + .set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + ) + + home_pages: List[discord.Embed] = [] + + for i in range(0, len(mapping), 5): + embed = home_embed.copy() + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + embed.add_field( + name=cog.qualified_name if cog else "No Category", + value=f"*{cog.description if cog and cog.description else 'No description provided.'}* `[Commands: {len(filtered_cmds)}]`", + inline=False, + ) + home_pages.append(embed) + + categories: List[CategoryEntry[discord.Embed]] = [ + CategoryEntry( + category_title="Home", + category_description="Documentation Bot Home Page", + pages=home_pages, + ) + ] + for cog, cmds in mapping.items(): + filtered_cmds = await self.filter_commands(self.flatten_commands(cmds), sort=True) + + cog_name = cog.qualified_name if cog else "No Category" + cog_desc = cog.description if cog and cog.description else "No description provided." + + categories.append( + CategoryEntry( + category_title=cog_name, + category_description=cog_desc, + pages=await Formatter(self).format_cog_or_group(cog, filtered_cmds), + ) + ) + + paginator = EmbedCategoryPaginator(self.context.author, pages=categories) + await paginator.start_paginator(self.context) + + async def send_cog_help(self, cog: commands.GroupCog | commands.Cog, /) -> None: + cmds = cog.get_commands() + (cog.get_app_commands() if self.with_app_command else []) + if isinstance(cog, commands.GroupCog): + cmds.extend(cog.app_command.commands) + commands_ = await self.filter_commands(self.flatten_commands(cmds), sort=True) + embeds = await Formatter(self).format_cog_or_group(cog, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_group_help(self, group: Group[Any, ..., Any] | app_commands.Group, /) -> None: + commands_ = await self.filter_commands(self.flatten_commands(group.commands), sort=True) + embeds = await Formatter(self).format_cog_or_group(group, commands_) + paginator = EmbedButtonPaginator(self.context.author, pages=embeds) + await paginator.start_paginator(self.context) + + async def send_command_help(self, command: Command[Any, ..., Any] | app_commands.Command[Any, ..., Any], /) -> None: + embed = Formatter(self).format_command(command) + await self.context.send(embed=embed) + + async def send_error_message(self, error: str, /) -> None: + embed = discord.Embed( + title="Error", + description=error, + color=discord.Color.red(), + ).set_footer( + text=f"Requested by {self.context.author}", + icon_url=self.context.author.display_avatar, + ) + await self.context.send(embed=embed) + + async def command_callback(self, ctx: commands.Context[commands.Bot], /, *, query: Optional[str] = None) -> None: + """ + This is the entry point of the help command. + + Parameters + ---------- + ctx: commands.Context + The context of the command invocation. + query: Optional[str] + The command, group, or cog to get help for. + """ + command = query + await self.prepare_help_command(ctx, command) + + bot = ctx.bot + + if command is None: + mapping = self.get_all_commands() + return await self.send_bot_help(mapping) + + cog = bot.get_cog(command) + if cog: + return await self.send_cog_help(cog) + + maybe_coro = discord.utils.maybe_coroutine + + keys = command.split() + cmd = bot.all_commands.get(keys[0]) + + if self.with_app_command: + guild_id = ctx.guild.id if ctx.guild else None + + if cmd is None: + cmd = bot.tree.get_command(keys[0], guild=discord.Object(id=guild_id)) + + if cmd is None: + cmd = bot.tree.get_command(keys[0]) + + if cmd is None: + string = await maybe_coro(self.command_not_found, self.remove_mentions(command)) + return await self.send_error_message(string) + + for key in keys[1:]: + try: + cmds = getattr(cmd, "all_commands", None) or cmd._children + found = cmds.get(key) if cmds else None + except AttributeError: + string = await maybe_coro(self.subcommand_not_found, cmd, self.remove_mentions(key)) # type: ignore + return await self.send_error_message(string) + else: + if found is None: + string = await maybe_coro(self.subcommand_not_found, cmd, self.remove_mentions(key)) # type: ignore + return await self.send_error_message(string) + cmd = found + + if isinstance(cmd, commands.Group | app_commands.Group): + return await self.send_group_help(cmd) + return await self.send_command_help(cmd) + + def get_all_commands( + self, + ) -> Mapping[Optional[Cog], List[Command[Any, ..., Any] | app_commands.Command[Any, ..., Any]]]: + mapping = self.get_bot_mapping() + if self.with_app_command: + for cog, cmds in self.get_app_command_mapping().items(): + for cmd in cmds: + if cmd.name not in (c.name for c in mapping.get(cog, [])): + mapping.setdefault(cog, []).append(cmd) + return mapping + + def get_app_command_mapping( + self, + ) -> Mapping[Optional[Cog], List[app_commands.Command[Any, ..., Any] | app_commands.Group]]: + mapping = {} + for cog in self.context.bot.cogs.values(): + if isinstance(cog, commands.GroupCog): + mapping.setdefault(cog, [*cog.get_commands()]).extend(cog.app_command.commands) + continue + mapping.setdefault(cog, []).extend(cog.get_app_commands()) + + # Get unbound commands + def get_unbound_cmds(with_guild=None): + return [ + c + for c in self.context.bot.tree.get_commands(guild=with_guild) + if isinstance(c, app_commands.Command) and c.binding is None + ] + + if self.context.guild: + mapping.setdefault(None, []).extend(get_unbound_cmds(self.context.guild)) + + mapping.setdefault(None, []).extend(get_unbound_cmds()) + + return mapping + + async def on_help_command_error(self, ctx: Context[commands.Bot], error: CommandError, /) -> None: + await self.send_error_message(str(error)) + raise error + + async def help_command_autocomplete( + self, inter: discord.Interaction[commands.Bot], current: str + ) -> list[app_commands.Choice[str]]: + help_command = self.copy() + help_command.context = await inter.client.get_context(inter) + + all_cmds: dict[str, list[commands.Command | app_commands.Command]] = { + cog.qualified_name if cog else "No Category": help_command.flatten_commands(cmds) + for cog, cmds in help_command.get_all_commands().items() + } + choices = list(all_cmds.keys()) + [_construct_full_name(cmd) for cmd in sum(all_cmds.values(), [])] + matches = difflib.get_close_matches(current, choices, n=25, cutoff=0.4) or sorted( + choices, key=lambda x: x.lower() + ) + return [app_commands.Choice(name=match, value=match) for match in matches][:25] + + async def filter_commands( + self, + commands: Iterable[Command[Any, ..., Any] | app_commands.Command[Any, ..., Any]], + /, + *, + sort: bool = False, + key: Optional[Callable[[Command[Any, ..., Any] | app_commands.Command[Any, ..., Any]], Any] | None] = None, + ) -> List[Command[Any, ..., Any] | app_commands.Command[Any, ..., Any]]: + if sort and key is None: + key = lambda c: c.name # noqa: E731 + + iterator = commands if self.show_hidden else filter(lambda c: not getattr(c, "hidden", None), commands) + + if self.verify_checks is False: + # if we do not need to verify the checks then we can just + # run it straight through normally without using await. + return sorted(iterator, key=key) if sort else list(iterator) # type: ignore # the key shouldn't be None + + if self.verify_checks is None and not self.context.guild: + # if verify_checks is None and we're in a DM, don't verify + return sorted(iterator, key=key) if sort else list(iterator) # type: ignore + + # if we're here then we need to check every command if it can run + async def predicate(cmd: Command[Any, ..., Any] | app_commands.Command) -> bool: + ctx = self.context + if isinstance(cmd, Command): + try: + return await cmd.can_run(ctx) + except discord.ext.commands.CommandError: + return False + + no_interaction = ctx.interaction is None + if not cmd.checks and no_interaction: + binding = cmd.binding + if cmd.parent is not None and cmd.parent is not binding: + return False # it has group command interaction check + + if binding is not None: + check = getattr(binding, "interaction_check", None) + if check: + return False # it has cog interaction check + + return True + + if no_interaction: + return False + + try: + return await cmd._check_can_run(ctx.interaction) + except app_commands.AppCommandError: + return False + + ret = [] + for cmd in iterator: + valid = await predicate(cmd) + if valid: + ret.append(cmd) + + if sort: + ret.sort(key=key) + return ret diff --git a/examples/slash-commands/views/__init__.py b/examples/slash-commands/views/__init__.py new file mode 100644 index 0000000..4a0afe6 --- /dev/null +++ b/examples/slash-commands/views/__init__.py @@ -0,0 +1,97 @@ +# Our objectives: +# - Create a view that handles errors +# - Create a view that disables all components after timeout +# - Make sure that the view only processes interactions from the user who invoked the command + +from __future__ import annotations + +import traceback +import typing + +import discord +from discord.ui.select import Select + + +class BaseView(discord.ui.View): + interaction: discord.Interaction | None = None + message: discord.Message | None = None + + def __init__(self, user: discord.User | discord.Member, timeout: float = 60.0): + super().__init__(timeout=timeout) + # We set the user who invoked the command as the user who can interact with the view + self.user = user + + # make sure that the view only processes interactions from the user who invoked the command + async def interaction_check(self, interaction: discord.Interaction) -> bool: + if interaction.user.id != self.user.id: + await interaction.response.send_message("You cannot interact with this view.", ephemeral=True) + return False + # update the interaction attribute when a valid interaction is received + self.interaction = interaction + return True + + # to handle errors we first notify the user that an error has occurred and then disable all components + + def _disable_all(self) -> None: + # disable all components + # so components that can be disabled are buttons and select menus + for item in self.children: + if isinstance(item, discord.ui.Button) or isinstance(item, Select): + item.disabled = True + + # after disabling all components we need to edit the message with the new view + # now when editing the message there are two scenarios: + # 1. the view was never interacted with i.e in case of plain timeout here message attribute will come in handy + # 2. the view was interacted with and the interaction was processed and we have the latest interaction stored in the interaction attribute + async def _edit(self, **kwargs: typing.Any) -> None: + if self.interaction is None and self.message is not None: + # if the view was never interacted with and the message attribute is not None, edit the message + await self.message.edit(**kwargs) + elif self.interaction is not None: + try: + # if not already responded to, respond to the interaction + await self.interaction.response.edit_message(**kwargs) + except discord.InteractionResponded: + # if already responded to, edit the response + await self.interaction.edit_original_response(**kwargs) + + async def on_error( + self, interaction: discord.Interaction, error: Exception, item: discord.ui.Item[BaseView] + ) -> None: + tb = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + message = f"An error occurred while processing the interaction for {str(item)}:\n```py\n{tb}\n```" + # disable all components + self._disable_all() + # edit the message with the error message + await self._edit(content=message, view=self) + # stop the view + self.stop() + + async def on_timeout(self) -> None: + # disable all components + self._disable_all() + # edit the message with the new view + await self._edit(view=self) + + +class BaseModal(discord.ui.Modal): + _interaction: discord.Interaction | None = None + + async def on_submit(self, interaction: discord.Interaction) -> None: + if not interaction.response.is_done(): + await interaction.response.defer() + self._interaction = interaction + self.stop() + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + tb = "".join(traceback.format_exception(type(error), error, error.__traceback__)) + message = f"An error occurred while processing the interaction:\n```py\n{tb}\n```" + try: + await interaction.response.send_message(message, ephemeral=True) + except discord.InteractionResponded: + await interaction.edit_original_response(content=message, view=None) + self.stop() + + @property + def interaction(self) -> discord.Interaction | None: + return self._interaction diff --git a/mkdocs.yml b/mkdocs.yml index 543ac8d..055ad99 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -81,6 +81,7 @@ nav: - Audio Playback: audio-playback.md - Examples: - Pagination: pagination.md + - Help Command: help-command.md markdown_extensions: - tables diff --git a/pyproject.toml b/pyproject.toml index 1cf13bf..d6b0284 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,7 @@ tabulate = "^0.9.0" jishaku = "^2.5.2" polib = "^1.2.0" deep-translator = "^1.11.4" +humanfriendly = "^10.0" [tool.poetry.group.dev.dependencies]