didier/didier/cogs/help.py

217 lines
8.2 KiB
Python

import re
from typing import Mapping, Optional
import discord
from discord.ext import commands
from overrides import overrides
from didier import Didier
from didier.utils.discord.colours import error_red
from didier.utils.types.string import re_find_all, re_replace_with_list
class CustomHelpCommand(commands.MinimalHelpCommand):
"""Customised Help command that overrides the default implementation
The default is ugly as hell, so we do some fiddling with it and put everything
in fancy embeds
"""
@overrides
async def command_callback(self, ctx: commands.Context, /, *, command: Optional[str] = None):
"""Slightly modify the original command_callback to better suit my needs"""
# No argument provided: send a list of all cogs
if command is None:
mapping = self.get_bot_mapping()
return await self.send_bot_help(mapping)
command = command.lower()
# Hide cogs the user is not allowed to see
cogs = list(ctx.bot.cogs.values())
cogs = await self._filter_cogs(cogs)
# Allow fetching cogs case-insensitively
cog = self._get_cog(cogs, command)
if cog is not None:
return await self.send_cog_help(cog)
# Traverse tree of commands
keys = command.split(" ")
current_command = ctx.bot.all_commands.get(keys[0])
# No command found
if current_command is None:
return await self.send_error_message(self.command_not_found(keys[0]))
# Look for subcommands
for key in keys[1:]:
try:
found = current_command.all_commands.get(key) # type: ignore
except AttributeError:
return await self.send_error_message(self.subcommand_not_found(current_command, key))
else:
if found is None:
return await self.send_error_message(self.subcommand_not_found(current_command, key))
current_command = found
if isinstance(current_command, commands.Group):
return await self.send_group_help(current_command)
else:
return await self.send_command_help(current_command)
@overrides
def command_not_found(self, string: str, /) -> str:
return f"Found no command named `{string}`."
@overrides
def get_command_signature(self, command: commands.Command, /) -> str:
signature_list = [command.name]
# Perform renaming for hybrid commands
if hasattr(command.callback, "__discord_app_commands_param_rename__"):
renames = command.callback.__discord_app_commands_param_rename__
else:
renames = {}
sig = command.params
for name, param in sig.items():
name = renames.get(name, name)
is_optional = param.default is not param.empty
# Wrap optional arguments in square brackets
if is_optional:
name = f"[{name}]"
signature_list.append(name)
return " ".join(signature_list)
@overrides
async def send_bot_help(self, mapping: Mapping[Optional[commands.Cog], list[commands.Command]], /):
embed = self._help_embed_base("Categories")
filtered_cogs = await self._filter_cogs(list(mapping.keys()))
embed.description = "\n".join(list(map(lambda cog: cog.qualified_name, filtered_cogs)))
await self.context.reply(embed=embed, mention_author=False)
@overrides
async def send_cog_help(self, cog: commands.Cog, /):
embed = self._help_embed_base(cog.qualified_name)
embed.description = cog.description
commands_names = list(map(lambda c: c.qualified_name, cog.get_commands()))
commands_names.sort()
embed.add_field(name="Commands", value=", ".join(commands_names), inline=False)
return await self.context.reply(embed=embed, mention_author=False)
@overrides
async def send_command_help(self, command: commands.Command, /):
embed = self._help_embed_base(command.qualified_name)
self._add_command_help(embed, command)
return await self.context.reply(embed=embed, mention_author=False)
@overrides
async def send_group_help(self, group: commands.Group, /):
embed = self._help_embed_base(group.qualified_name)
if group.invoke_without_command:
self._add_command_help(embed, group)
subcommand_names = list(map(lambda c: c.name, group.commands))
subcommand_names.sort()
embed.add_field(name="Subcommands", value=", ".join(subcommand_names))
return await self.context.reply(embed=embed, mention_author=False)
@overrides
async def send_error_message(self, error: str, /):
embed = discord.Embed(colour=error_red(), title="Help", description=error)
return await self.context.reply(embed=embed, mention_author=False)
@overrides
def subcommand_not_found(self, command: commands.Command, string: str, /) -> str:
return f"Found no subcommand named `{string}` for command `{command.qualified_name}`."
def _help_embed_base(self, title: str) -> discord.Embed:
"""Create the base structure for the embeds that get sent with the Help commands"""
embed = discord.Embed(title=title.title(), colour=discord.Colour.blue())
return embed
def _clean_command_help(self, command: commands.Command) -> str:
"""Clean up a help docstring
This will strip out single newlines, because these are only there for readability and line length.
These are instead replaced with spaces.
Code in codeblocks is ignored, as it is used to create examples.
"""
description = command.help
codeblocks = re_find_all(r"\n?```.*?```", description, flags=re.DOTALL)
# Regex borrowed from https://stackoverflow.com/a/59843498/13568999
description = re.sub(
r"([^\S\n]*\n(?:[^\S\n]*\n)+[^\S\n]*)|[^\S\n]*\n[^\S\n]*", lambda x: x.group(1) or " ", description
)
# Replace codeblocks with their original form
if codeblocks:
description = re_replace_with_list(r"```.*?```", description, codeblocks)
return description
def _add_command_help(self, embed: discord.Embed, command: commands.Command):
"""Add command-related information to an embed
This allows re-using this logic for Group commands that can be invoked by themselves.
"""
embed.description = self._clean_command_help(command)
signature = self.get_command_signature(command)
embed.add_field(name="Signature", value=signature, inline=False)
if command.aliases:
embed.add_field(name="Aliases", value=", ".join(command.aliases), inline=False)
def _get_cog(self, cogs: list[commands.Cog], name: str) -> Optional[commands.Cog]:
"""Try to find a cog, case-insensitively"""
for cog in cogs:
if cog.qualified_name.lower() == name:
return cog
return None
async def _filter_cogs(self, cogs: list[commands.Cog]) -> list[commands.Cog]:
"""Filter the list of cogs down to all those that the user can see"""
# Remove cogs that we never want to see in the help page because they
# don't contain commands
filtered_cogs = list(
filter(lambda cog: cog is not None and cog.qualified_name.lower() not in ("tasks", "debugcog"), cogs)
)
# Remove owner-only cogs for people that shouldn't see them
if not await self.context.bot.is_owner(self.context.author):
filtered_cogs = list(filter(lambda cog: cog.qualified_name.lower() not in ("owner",), filtered_cogs))
return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name))
async def setup(client: Didier):
"""Load the cog"""
help_str = (
"Shows the help page for a category or command. "
"`/commands` are not included, as they already have built-in descriptions in the UI."
"\n\nThe command signatures follow the POSIX-standard format for help messages:"
"\n- `required_positional_argument`"
"\n- `[optional_positional_argument]`"
)
attributes = {"aliases": ["h", "man"], "usage": "[category or command]", "help": help_str}
client.help_command = CustomHelpCommand(command_attrs=attributes)