Skip to content

Instantly share code, notes, and snippets.

@sgtlaggy
Last active March 25, 2022 19:25
Show Gist options
  • Save sgtlaggy/dc98ba82aee57c01d713028f91a5e318 to your computer and use it in GitHub Desktop.
Save sgtlaggy/dc98ba82aee57c01d713028f91a5e318 to your computer and use it in GitHub Desktop.
A discord.py 2.0 help command implemented using select menus for selecting cogs or commands.
from __future__ import annotations
"""
Copyright 2021 sgtlaggy
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
"""
"""
This help command should be a drop-in implementation, though the methods defining the layout are rather basic.
All you need to do is rewrite the `get_X_help` methods in the `ComponentHelp` class.
Cogs can be given emojis by giving them an `emoji` attribute and modifying `HelpView.update_cogs` to use that.
"""
from itertools import zip_longest
from typing import TYPE_CHECKING
from discord import ButtonStyle, Embed, SelectOption, ui
from discord.ext.commands import Cog, Command, CommandError, DefaultHelpCommand, Group
if TYPE_CHECKING:
from collections.abc import Iterable
from typing import Coroutine, Optional, Union
from discord import Interaction, Message
from discord.ext.commands import Bot
BotMapping = dict[Optional[Cog], list[Command]]
Entity = Optional[Union[Cog, Command]]
# this bit is a hack for the current situation of `HelpCommand.context` being a ContextVar
from discord.ext.commands.help import _context as HELP_COMMAND_CONTEXT_HACK
# end hack
class TooManyCommands(CommandError):
pass
def join_lines(lines: Iterable[str | None]) -> str:
"""Turn a list of lines into a newline-joined string, ignoring any empty lines."""
return "\n".join(filter(None, lines))
def option_grouper(iterable):
"""Collect data into non-overlapping fixed-length chunks or blocks"""
args = [iter(iterable)] * 25
groups = [list(filter(None, group)) for group in zip_longest(*args, fillvalue=None)]
if len(groups) > 3:
raise TooManyCommands("Too many commands for this help command to handle.")
groups.extend([[]] * (3 - len(groups))) # need a final list of 3 groups
return (*groups,)
class ComponentHelp(DefaultHelpCommand):
_mapping = None
_original_help_command = None
def load(self, bot: Bot):
"""Helper method for backing up the original help command and adding this one."""
self._original_help_command = bot.help_command
bot.help_command = self
def unload(self, bot: Bot):
"""Helper method for restoring the original help command."""
bot.help_command = self._original_help_command
async def get_filtered_mapping(self) -> BotMapping:
if self._mapping is None:
mapping = {
cog: await self.filter_commands(cmds)
for cog, cmds in self.get_bot_mapping().items()
}
# filter out cogs with no commands post-filter
self._mapping = {cog: cmds for cog, cmds in mapping.items() if cmds}
return self._mapping
async def on_help_command_error(self, ctx, error: CommandError, /) -> None:
await self.send_error_message(f"{error}")
async def send_view(self, embed: Embed, entity: Entity):
mapping = await self.get_filtered_mapping()
view = HelpView(self, mapping, entity)
await view.update_commands() # must be async to filter subcommands
view.message = await self.get_destination().send(embed=embed, view=view)
async def send_bot_help(self, mapping: BotMapping):
mapping = await self.get_filtered_mapping()
embed = await self.get_bot_help(mapping)
await self.send_view(embed, None)
async def send_cog_help(self, cog: Cog):
mapping = await self.get_filtered_mapping()
if cog not in mapping:
return
embed = await self.get_cog_help(cog)
await self.send_view(embed, cog)
async def send_group_help(self, group: Group):
embed = await self.get_group_help(group)
await self.send_view(embed, group)
async def send_command_help(self, command: Command):
embed = await self.get_command_help(command)
await self.send_view(embed, command)
# These are just simple bare-minimum implementationss,
# you probably want to rewrite all of these.
async def get_bot_help(self, mapping: BotMapping) -> Embed:
cogs = sorted(cog.qualified_name for cog in mapping if cog)
commands = [f"`{cmd}`" for cmd in mapping.get(None, [])]
lines = []
lines.extend(["Categories:", join_lines(cogs)])
if commands:
lines.extend(
[
f"\n{self.no_category}:",
join_lines(commands),
]
)
description = join_lines(lines)
return Embed(title="Categories", description=description)
async def get_cog_help(self, cog: Cog) -> Embed:
mapping = await self.get_filtered_mapping()
commands = mapping[cog]
return Embed(
title=f"{cog.qualified_name} Commands",
description=join_lines([f"`{cmd}`" for cmd in commands]),
)
async def get_group_help(self, group: Group) -> Embed:
commands = await self.filter_commands(group.commands)
description = join_lines(
[
f"Usage: `{self.get_command_signature(group)}`",
group.help,
join_lines([f"`{cmd}`" for cmd in commands]),
]
)
return Embed(title=f"{group}", description=description)
async def get_command_help(self, command: Command) -> Embed:
description = "\n".join(
filter(
None, [f"Usage: `{self.get_command_signature(command)}`", command.help]
)
)
return Embed(title=f"{command}", description=description)
class HelpView(ui.View):
def __init__(
self,
help: ComponentHelp,
mapping: BotMapping,
entity: Entity = None,
*args,
**kwargs,
):
super().__init__(*args, **kwargs)
self.help = help
self.ctx = help.context
self.bot = help.context.bot
self.mapping = mapping
self.entity = entity
self.update_cogs()
self.message: Message
self._command_selects = [
self.command_select_1,
self.command_select_2,
self.command_select_3,
]
async def on_timeout(self):
await self.message.delete()
async def interaction_check(self, interaction: Interaction) -> bool:
if interaction.user == self.ctx.author:
HELP_COMMAND_CONTEXT_HACK.set(self.ctx)
return True
else:
await interaction.response.defer()
return False
def handle_select(self, select: ui.Select, options: list[SelectOption]):
if options:
if select not in self.children:
self.add_item(select)
select.options = options
select.disabled = False
else:
if select in self.children:
self.remove_item(select)
def update_cogs(self):
# to use emojis, you can build a list of `SelectOptions` then sort by label
names = sorted(cog.qualified_name for cog in self.mapping if cog)
# always add "No Category" at the end
names.append(self.help.no_category)
options = [SelectOption(label=name) for name in names]
self.cog_select.options = options
async def update_commands(self):
entity = self.entity
# list the parent command/cog/bot's commands instead of nothing
if isinstance(entity, Command) and not isinstance(entity, Group):
entity = entity.parent or entity.cog or None
if isinstance(entity, Group):
cmds = await self.help.filter_commands(entity.commands)
else:
cmds = self.mapping.get(entity, [])
options = [SelectOption(label=f"{cmd}") for cmd in cmds]
for select, options in zip(self._command_selects, option_grouper(options)):
self.handle_select(select, options)
def get_embed(self) -> Coroutine[None, None, Embed]:
entity = self.entity
if isinstance(entity, Cog):
return self.help.get_cog_help(entity)
elif isinstance(entity, Group):
return self.help.get_group_help(entity)
elif isinstance(entity, Command):
return self.help.get_command_help(entity)
else:
return self.help.get_bot_help(self.mapping)
async def respond_with_edit(self, interaction: Interaction):
embed = await self.get_embed()
await interaction.response.edit_message(embed=embed, view=self)
@ui.select(placeholder="Categories", row=0)
async def cog_select(self, select: ui.Select, interaction: Interaction):
name = select.values[0]
entity = self.bot.get_cog(name)
if entity == self.entity:
return
self.entity = entity
try:
await self.update_commands()
except TooManyCommands as e:
await interaction.response.send_message(f"{e}", ephemeral=True)
else:
await self.respond_with_edit(interaction)
async def actual_command_select(self, select: ui.Select, interaction: Interaction):
name = select.values[0]
entity = self.bot.get_command(name)
if entity == self.entity:
return
self.entity = entity
try:
await self.update_commands()
except TooManyCommands as e:
await interaction.response.send_message(f"{e}", ephemeral=True)
else:
await self.respond_with_edit(interaction)
@ui.select(placeholder="Commands", row=1)
async def command_select_1(self, select: ui.Select, interaction: Interaction):
await self.actual_command_select(select, interaction)
@ui.select(placeholder="Commands", row=2)
async def command_select_2(self, select: ui.Select, interaction: Interaction):
await self.actual_command_select(select, interaction)
@ui.select(placeholder="Commands", row=3)
async def command_select_3(self, select: ui.Select, interaction: Interaction):
await self.actual_command_select(select, interaction)
@ui.button(label="Up", style=ButtonStyle.blurple, row=4)
async def up_level(self, button: ui.Button, interaction: Interaction):
if isinstance(self.entity, Command):
self.entity = self.entity.parent or self.entity.cog or None
elif isinstance(self.entity, Cog):
self.entity = None
else:
return
try:
await self.update_commands()
except TooManyCommands as e:
await interaction.response.send_message(f"{e}", ephemeral=True)
else:
await self.respond_with_edit(interaction)
@ui.button(label="Close", style=ButtonStyle.danger, row=4)
async def close(self, button: ui.Button, interaction: Interaction):
self.stop()
await interaction.response.defer()
await interaction.delete_original_message()
async def setup(bot: Bot):
ComponentHelp(command_attrs=dict(hidden=True)).load(bot)
async def teardown(bot: Bot):
bot.help_command.unload(bot) # type: ignore
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment