Last active
March 25, 2022 19:25
-
-
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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