mirror of https://github.com/stijndcl/didier
268 lines
10 KiB
Python
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)
|