didier/didier/cogs/help.py

268 lines
10 KiB
Python

import inspect
import re
from typing import Mapping, Optional, Type
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.discord.flags import PosixFlags
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}]"
# If there are options/flags, add them
# The hardcoded name-check is done for performance reasons
if name == "flags" and inspect.isclass(param.annotation) and issubclass(param.annotation, PosixFlags):
signature_list.append("[--OPTIONS]")
else:
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_doc(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
if description is None:
return ""
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)
# Add flag help in
flags_class = self._get_flags_class(command)
if flags_class is not None:
description += f"\n\n{self.get_flags_help(flags_class)}"
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_doc(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(sorted(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[Optional[commands.Cog]]) -> list[commands.Cog]:
"""Filter the list of cogs down to all those that the user can see"""
async def _predicate(cog: commands.Cog) -> bool:
# Remove cogs that we never want to see in the help page because they
# don't contain commands, or shouldn't be visible at all
if not cog.get_commands():
return False
if cog.qualified_name.lower() in ("tasks", "debugcog"):
return False
# Hide owner-only cogs if you're not the owner
if not await self.context.bot.is_owner(self.context.author):
return cog.qualified_name.lower() not in ("owner",)
return True
# Filter list of cogs down
filtered_cogs = [cog for cog in cogs if cog is not None and await _predicate(cog)]
return list(sorted(filtered_cogs, key=lambda cog: cog.qualified_name))
def _get_flags_class(self, command: commands.Command) -> Optional[Type[PosixFlags]]:
"""Check if a command has flags"""
flag_param = command.params.get("flags")
if flag_param is None:
return None
if issubclass(flag_param.annotation, PosixFlags):
return flag_param.annotation
return None
def get_flags_help(self, flags_class: Type[PosixFlags]) -> str:
"""Get the description for flag arguments"""
help_data = []
# Present flags in alphabetical order, as dicts have no set ordering
flag_mapping = flags_class.__commands_flags__
flags = list(flag_mapping.items())
flags.sort(key=lambda f: f[0])
for name, flag in flags:
flag_names = [name, *flag.aliases]
# Add the --prefix in front of all flags
flag_names = list(map(lambda n: f"--{n}", flag_names))
help_data.append(f"{', '.join(flag_names)} [default `{flag.default}`]")
return "Options:\n" + "\n".join(help_data)
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)