diff --git a/docs/changelog_3_1_0.rst b/docs/changelog_3_1_0.rst index d08b89311..f3e5bf660 100644 --- a/docs/changelog_3_1_0.rst +++ b/docs/changelog_3_1_0.rst @@ -73,6 +73,7 @@ Core * ``[p]set locale`` now only accepts actual locales (`#2553`_) * ``[p]listlocales`` now displays ``en-US`` (`#2553`_) * ``redbot --version`` will now give you current version of Red (`#2567`_) + * Redesign help and related formatter (`#2628`_) * Default locale changed from ``en`` to ``en-US`` (`#2642`_) * New command ``[p]datapath`` that prints the bot's datapath (`#2652`_) @@ -223,6 +224,7 @@ Utility Functions .. _#2605: https://github.com/Cog-Creators/Red-DiscordBot/pull/2605 .. _#2606: https://github.com/Cog-Creators/Red-DiscordBot/pull/2606 .. _#2620: https://github.com/Cog-Creators/Red-DiscordBot/pull/2620 +.. _#2628: https://github.com/Cog-Creators/Red-DiscordBot/pull/2628 .. _#2639: https://github.com/Cog-Creators/Red-DiscordBot/pull/2639 .. _#2642: https://github.com/Cog-Creators/Red-DiscordBot/pull/2642 .. _#2652: https://github.com/Cog-Creators/Red-DiscordBot/pull/2652 diff --git a/redbot/core/bot.py b/redbot/core/bot.py index 1f1f9d7d0..f122ee5ff 100644 --- a/redbot/core/bot.py +++ b/redbot/core/bot.py @@ -115,10 +115,22 @@ class RedBase(commands.GroupMixin, commands.bot.BotBase, RPCMixin): self.cog_mgr = CogManager() - super().__init__(*args, help_command=commands.DefaultHelpCommand(), **kwargs) + super().__init__(*args, help_command=None, **kwargs) + # Do not manually use the help formatter attribute here, see `send_help_for`, + # for a documented API. The internals of this object are still subject to change. + self._help_formatter = commands.help.RedHelpFormatter() + self.add_command(commands.help.red_help) self._permissions_hooks: List[commands.CheckPredicate] = [] + async def send_help_for( + self, ctx: commands.Context, help_for: Union[commands.Command, commands.GroupMixin, str] + ): + """ + Invokes Red's helpformatter for a given context and object. + """ + return await self._help_formatter.send_help(ctx, help_for) + async def _dict_abuse(self, indict): """ Please blame <@269933075037814786> for this. diff --git a/redbot/core/commands/commands.py b/redbot/core/commands/commands.py index 796a7942a..17cc4b0a0 100644 --- a/redbot/core/commands/commands.py +++ b/redbot/core/commands/commands.py @@ -537,6 +537,10 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group): super().__init__(*args, **kwargs) async def invoke(self, ctx: "Context"): + # we skip prepare in some cases to avoid some things + # We still always want this part of the behavior though + ctx.command = self + # Our re-ordered behavior below. view = ctx.view previous = view.index view.skip_ws() @@ -557,6 +561,7 @@ class Group(GroupMixin, Command, CogGroupMixin, commands.Group): # how our permissions system works, we don't want it to skip the checks # as well. await self._verify_checks(ctx) + # this is actually why we don't prepare earlier. await super().invoke(ctx) @@ -565,8 +570,60 @@ class CogMixin(CogGroupMixin, CogCommandMixin): """Mixin class for a cog, intended for use with discord.py's cog class""" @property - def all_commands(self) -> Dict[str, Command]: - return {cmd.name: cmd for cmd in self.__cog_commands__} + def help(self): + doc = self.__doc__ + translator = getattr(self, "__translator__", lambda s: s) + if doc: + return inspect.cleandoc(translator(doc)) + + async def can_run(self, ctx: "Context", **kwargs) -> bool: + """ + This really just exists to allow easy use with other methods using can_run + on commands and groups such as help formatters. + + kwargs used in that won't apply here as they don't make sense to, + but will be swallowed silently for a compatible signature for ease of use. + + Parameters + ---------- + ctx : `Context` + The invocation context to check with. + + Returns + ------- + bool + ``True`` if this cog is usable in the given context. + """ + + try: + can_run = await self.requires.verify(ctx) + except commands.CommandError: + return False + + return can_run + + async def can_see(self, ctx: "Context") -> bool: + """Check if this cog is visible in the given context. + + In short, this will verify whether + the user is allowed to access the cog by permissions. + + This has an identical signature to the one used by commands, and groups, + but needs a different underlying mechanism. + + Parameters + ---------- + ctx : `Context` + The invocation context to check with. + + Returns + ------- + bool + ``True`` if this cog is visible in the given context. + + """ + + return await self.can_run(ctx) class Cog(CogMixin, commands.Cog): diff --git a/redbot/core/commands/context.py b/redbot/core/commands/context.py index 1d6f51209..958c2cdad 100644 --- a/redbot/core/commands/context.py +++ b/redbot/core/commands/context.py @@ -62,10 +62,12 @@ class Context(commands.Context): return await super().send(content=content, **kwargs) - async def send_help(self) -> List[discord.Message]: + async def send_help(self, command=None): """ Send the command help message. """ - command = self.invoked_subcommand or self.command - await super().send_help(command) + # This allows people to manually use this similarly + # to the upstream d.py version, while retaining our use. + command = command or self.command + await self.bot.send_help_for(self, command) async def tick(self) -> bool: """Add a tick reaction to the command message. diff --git a/redbot/core/commands/help.py b/redbot/core/commands/help.py index 328c6afb4..10fc576fe 100644 --- a/redbot/core/commands/help.py +++ b/redbot/core/commands/help.py @@ -1,23 +1,515 @@ -from discord.ext import commands -from .commands import Command +# This is a full replacement of discord.py's help command +# Signatures are not guaranteed to be unchanging in this file. +# At a later date when this is more set in stone, this warning will be removed. +# At said later date, there should also be things added to support extra formatter +# registration from 3rd party cogs. +# +# This exists due to deficiencies in discord.py which conflict +# with our needs for per-context help settings +# see https://github.com/Rapptz/discord.py/issues/2123 +# +# While the issue above discusses this as theoretical, merely interacting with config within +# the help command preparation was enough to cause +# demonstrable breakage in 150 help invokes in a 2 minute window. +# This is not an unreasonable volume on some already existing Red instances, +# especially since help is invoked for command groups +# automatically when subcommands are not provided correctly as user feedback. +# +# The implemented fix is in +# https://github.com/Rapptz/discord.py/commit/ad5beed8dd75c00bd87492cac17fe877033a3ea1 +# +# While this fix would handle our immediate specific issues, it's less appropriate to use +# Where we do not have a downstream consumer to consider. +# Simply modifying the design to not be susceptible to the issue, +# rather than adding copy and deepcopy use in multiple places is better for us +# +# Additionally, this gives our users a bit more customization options including by +# 3rd party cogs down the road. -__all__ = ["HelpCommand", "DefaultHelpCommand", "MinimalHelpCommand"] +from collections import namedtuple +from typing import Union, List, AsyncIterator, Iterable, cast + +import discord +from discord.ext import commands as dpy_commands + +from . import commands +from .context import Context +from ..i18n import Translator +from ..utils import menus, fuzzy_command_search, format_fuzzy_results +from ..utils.chat_formatting import box, pagify + +__all__ = ["red_help", "RedHelpFormatter"] + +T_ = Translator("Help", __file__) + +HelpTarget = Union[commands.Command, commands.Group, commands.Cog, dpy_commands.bot.BotBase, str] + +# The below could be a protocol if we pulled in typing_extensions from mypy. +SupportsCanSee = Union[commands.Command, commands.Group, dpy_commands.bot.BotBase, commands.Cog] + +EmbedField = namedtuple("EmbedField", "name value inline") +EMPTY_STRING = "\N{ZERO WIDTH SPACE}" -class _HelpCommandImpl(Command, commands.help._HelpCommandImpl): +class NoCommand(Exception): pass -class HelpCommand(commands.help.HelpCommand): - def _add_to_bot(self, bot): - command = _HelpCommandImpl(self, self.command_callback, **self.command_attrs) - bot.add_command(command) - self._command_impl = command +class NoSubCommand(Exception): + def __init__(self, *, last, not_found): + self.last = last + self.not_found = not_found -class DefaultHelpCommand(HelpCommand, commands.help.DefaultHelpCommand): - pass +class RedHelpFormatter: + """ + Red's help implementation + + This is intended to be overridable in parts to only change some behavior. + + While currently, there is a global formatter, later plans include a context specific + formatter selector as well as an API for cogs to register/un-register a formatter with the bot. + + When implementing your own formatter, at minimum you must provide an implementation of + `send_help` with identical signature. + + While this exists as a class for easy partial overriding, most implementations + should not need or want a shared state. + """ + + # Class vars for things which should be configurable at a later date but aren't now + # Technically, someone can just use a cog to switch these in real time for now. + + USE_MENU = False + CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES = False + SHOW_HIDDEN = False + VERIFY_CHECKS = True + + async def send_help(self, ctx: Context, help_for: HelpTarget = None): + """ + This delegates to other functions. + + For most cases, you should use this and only this directly. + """ + if help_for is None or isinstance(help_for, dpy_commands.bot.BotBase): + await self.format_bot_help(ctx) + return + + if isinstance(help_for, str): + try: + help_for = self.parse_command(ctx, help_for) + except NoCommand: + await self.command_not_found(ctx, help_for) + return + except NoSubCommand as exc: + if self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES: + await self.subcommand_not_found(ctx, exc.last, exc.not_found) + return + help_for = exc.last + + if isinstance(help_for, commands.Cog): + await self.format_cog_help(ctx, help_for) + else: + await self.format_command_help(ctx, help_for) + + async def get_cog_help_mapping(self, ctx: Context, obj: commands.Cog): + iterator = filter(lambda c: c.parent is None and c.cog is obj, ctx.bot.commands) + return {com.name: com async for com in self.help_filter_func(ctx, iterator)} + + async def get_group_help_mapping(self, ctx: Context, obj: commands.Group): + return { + com.name: com async for com in self.help_filter_func(ctx, obj.all_commands.values()) + } + + async def get_bot_help_mapping(self, ctx): + sorted_iterable = [] + for cogname, cog in (*sorted(ctx.bot.cogs.items()), (None, None)): + cm = await self.get_cog_help_mapping(ctx, cog) + if cm: + sorted_iterable.append((cogname, cm)) + return sorted_iterable + + @staticmethod + def get_default_tagline(ctx: Context): + return ( + f"Type {ctx.clean_prefix}help for more info on a command. " + f"You can also type {ctx.clean_prefix}help for more info on a category." + ) + + async def format_command_help(self, ctx: Context, obj: commands.Command): + + send = self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES + if not send: + async for _ in self.help_filter_func(ctx, (obj,), bypass_hidden=True): + # This is a really lazy option for not + # creating a separate single case version. + # It is efficient though + # + # We do still want to bypass the hidden requirement on + # a specific command explicitly invoked here. + send = True + + if not send: + return + + command = obj + + description = command.description or "" + tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx) + signature = f"`Syntax: {ctx.clean_prefix}{command.qualified_name} {command.signature}`" + subcommands = None + + if hasattr(command, "all_commands"): + grp = cast(commands.Group, command) + subcommands = await self.get_group_help_mapping(ctx, grp) + + if await ctx.embed_requested(): + emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []} + + if description: + emb["embed"]["title"] = f"*{description[:2044]}*" + + emb["footer"]["text"] = tagline + emb["embed"]["description"] = signature + + if command.help: + splitted = command.help.split("\n\n") + name = "__{0}__".format(splitted[0]) + value = "\n\n".join(splitted[1:]).replace("[p]", ctx.clean_prefix) + if not value: + value = EMPTY_STRING + field = EmbedField(name[:252], value[:1024], False) + emb["fields"].append(field) + + if subcommands: + subtext = "\n".join( + f"**{name}** {command.short_doc}" + for name, command in sorted(subcommands.items()) + ) + for i, page in enumerate(pagify(subtext, page_length=1000, shorten_by=0)): + if i == 0: + title = "**__Subcommands:__**" + else: + title = "**__Subcommands:__** (continued)" + field = EmbedField(title, page, False) + emb["fields"].append(field) + + await self.make_and_send_embeds(ctx, emb) + + else: # Code blocks: + + subtext = None + subtext_header = None + if subcommands: + subtext_header = "Subcommands:" + max_width = max(discord.utils._string_width(name) for name in subcommands.keys()) + + def width_maker(cmds): + doc_max_width = 80 - max_width + for nm, com in sorted(cmds): + width_gap = discord.utils._string_width(nm) - len(nm) + doc = command.short_doc + if len(doc) > doc_max_width: + doc = doc[: doc_max_width - 3] + "..." + yield nm, doc, max_width - width_gap + + subtext = "\n".join( + f" {name:<{width}} {doc}" + for name, doc, width in width_maker(subcommands.items()) + ) + + to_page = "\n\n".join( + filter(None, (description, signature[1:-1], command.help, subtext_header, subtext)) + ) + pages = [box(p) for p in pagify(to_page)] + await self.send_pages(ctx, pages, embed=False) + + @staticmethod + def group_embed_fields(fields: List[EmbedField], max_chars=1000): + curr_group = [] + ret = [] + for f in fields: + curr_group.append(f) + if sum(len(f.value) for f in curr_group) > max_chars: + ret.append(curr_group) + curr_group = [] + + if len(curr_group) > 0: + ret.append(curr_group) + + return ret + + async def make_and_send_embeds(self, ctx, embed_dict: dict): + + pages = [] + + page_char_limit = await ctx.bot.db.help.page_char_limit() + field_groups = self.group_embed_fields(embed_dict["fields"], page_char_limit) + + color = await ctx.embed_color() + page_count = len(field_groups) + + author_info = {"name": f"{ctx.me.display_name} Help Menu", "icon_url": ctx.me.avatar_url} + + for i, group in enumerate(field_groups, 1): + embed = discord.Embed(color=color, **embed_dict["embed"]) + + if page_count > 1: + description = f"{embed.description} *Page {i} of {page_count}*" + embed.description = description + + embed.set_author(**author_info) + + for field in group: + embed.add_field(**field._asdict()) + + embed.set_footer(**embed_dict["footer"]) + + pages.append(embed) + + await self.send_pages(ctx, pages, embed=True) + + async def format_cog_help(self, ctx: Context, obj: commands.Cog): + + commands = await self.get_cog_help_mapping(ctx, obj) + if not (commands or self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES): + return + + description = obj.help + tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx) + + if await ctx.embed_requested(): + emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []} + + emb["footer"]["text"] = tagline + if description: + emb["embed"]["title"] = f"*{description[:2044]}*" + + if commands: + command_text = "\n".join( + f"**{name}** {command.short_doc}" for name, command in sorted(commands.items()) + ) + for i, page in enumerate(pagify(command_text, page_length=1000, shorten_by=0)): + if i == 0: + title = "**__Commands:__**" + else: + title = "**__Commands:__** (continued)" + field = EmbedField(title, page, False) + emb["fields"].append(field) + + await self.make_and_send_embeds(ctx, emb) + + else: + commands_text = None + commands_header = None + if commands: + subtext_header = "Commands:" + max_width = max(discord.utils._string_width(name) for name in commands.keys()) + + def width_maker(cmds): + doc_max_width = 80 - max_width + for nm, com in sorted(cmds): + width_gap = discord.utils._string_width(nm) - len(nm) + doc = com.short_doc + if len(doc) > doc_max_width: + doc = doc[: doc_max_width - 3] + "..." + yield nm, doc, max_width - width_gap + + subtext = "\n".join( + f" {name:<{width}} {doc}" + for name, doc, width in width_maker(commands.items()) + ) + + to_page = "\n\n".join( + filter(None, (description, signature[1:-1], subtext_header, subtext)) + ) + pages = [box(p) for p in pagify(to_page)] + await self.send_pages(ctx, pages, embed=False) + + async def format_bot_help(self, ctx: Context): + + commands = await self.get_bot_help_mapping(ctx) + if not commands: + return + + description = ctx.bot.description or "" + tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx) + + if await ctx.embed_requested(): + + emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []} + + emb["footer"]["text"] = tagline + if description: + emb["embed"]["title"] = f"*{description[:2044]}*" + + for cog_name, data in commands: + + if cog_name: + title = f"**__{cog_name}:__**" + else: + title = f"**__No Category:__**" + + cog_text = "\n".join( + f"**{name}** {command.short_doc}" for name, command in sorted(data.items()) + ) + + for i, page in enumerate(pagify(cog_text, page_length=1000, shorten_by=0)): + title = title if i < 1 else f"{title} (continued)" + field = EmbedField(title, page, False) + emb["fields"].append(field) + + await self.make_and_send_embeds(ctx, emb) + + else: + if description: + to_join = [f"{description}\n"] + + names = [] + for k, v in commands: + names.extend(list(v.name for v in v.values())) + + max_width = max( + discord.utils._string_width((name or "No Category:")) for name in names + ) + + def width_maker(cmds): + doc_max_width = 80 - max_width + for nm, com in cmds: + width_gap = discord.utils._string_width(nm) - len(nm) + doc = com.short_doc + if len(doc) > doc_max_width: + doc = doc[: doc_max_width - 3] + "..." + yield nm, doc, max_width - width_gap + + for cog_name, data in commands: + + title = f"{cog_name}:" if cog_name else "No Category:" + to_join.append(title) + + for name, doc, width in width_maker(sorted(data.items())): + to_join.append(f" {name:<{width}} {doc}") + + to_join.append(f"\n{tagline}") + to_page = "\n".join(to_join) + pages = [box(p) for p in pagify(to_page)] + await self.send_pages(ctx, pages, embed=False) + + async def help_filter_func( + self, ctx, objects: Iterable[SupportsCanSee], bypass_hidden=False + ) -> AsyncIterator[SupportsCanSee]: + """ + This does most of actual filtering. + """ + # TODO: Settings for this in core bot db + for obj in objects: + if self.VERIFY_CHECKS and not (self.SHOW_HIDDEN or bypass_hidden): + # Default Red behavior, can_see includes a can_run check. + if await obj.can_see(ctx): + yield obj + elif self.VERIFY_CHECKS: + if await obj.can_run(ctx): + yield obj + elif not (self.SHOW_HIDDEN or bypass_hidden): + if getattr(obj, "hidden", False): # Cog compatibility + yield obj + else: + yield obj + + async def command_not_found(self, ctx, help_for): + """ + Sends an error, fuzzy help, or stays quiet based on settings + """ + coms = [c async for c in self.help_filter_func(ctx, ctx.bot.walk_commands())] + fuzzy_commands = await fuzzy_command_search(ctx, help_for, commands=coms, min_score=75) + use_embeds = await ctx.embed_requested() + if fuzzy_commands: + ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds) + if use_embeds: + ret.set_author() + tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx) + ret.set_footer(text=tagline) + await ctx.send(embed=ret) + else: + await ctx.send(ret) + elif self.CONFIRM_UNAVAILABLE_COMMAND_EXISTENCES: + ret = T_("Command *{command_name}* not found.").format(command_name=command_name) + if use_embeds: + emb = discord.Embed(color=(await ctx.embed_color()), description=ret) + emb.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url) + tagline = (await ctx.bot.db.help.tagline()) or self.get_default_tagline(ctx) + ret.set_footer(text=tagline) + await ctx.send(embed=ret) + else: + await ctx.send(ret) + + async def subcommand_not_found(self, ctx, command, not_found): + """ + Sends an error + """ + ret = T_("Command *{command_name}* has no subcommands.").format( + command_name=command.qualified_name + ) + await ctx.send(ret) + + @staticmethod + def parse_command(ctx, help_for: str): + """ + Handles parsing + """ + + maybe_cog = ctx.bot.get_cog(help_for) + if maybe_cog: + return maybe_cog + + com = ctx.bot + last = None + + clist = help_for.split() + + for index, item in enumerate(clist): + try: + com = com.all_commands[item] + # TODO: This doesn't handle valid command aliases. + # swap parsing method to use get_command. + except (KeyError, AttributeError): + if last: + raise NoSubCommand(last=last, not_found=clist[index:]) from None + else: + raise NoCommand() from None + else: + last = com + + return com + + async def send_pages( + self, ctx: Context, pages: List[Union[str, discord.Embed]], embed: bool = True + ): + """ + Sends pages based on settings. + """ + + if not self.USE_MENU: + + max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild() + destination = ctx.author if len(pages) > max_pages_in_guild else ctx + + if embed: + for page in pages: + await destination.send(embed=page) + else: + for page in pages: + await destination.send(page) + else: + await menus.menu(ctx, pages, menus.DEFAULT_CONTROLS) -class MinimalHelpCommand(HelpCommand, commands.help.MinimalHelpCommand): - pass +@commands.command(name="help", hidden=True, i18n=T_) +async def red_help(ctx: Context, *, thing_to_get_help_for: str = None): + """ + I need somebody + (Help) not just anybody + (Help) you know I need someone + (Help!) + """ + await ctx.bot.send_help_for(ctx, thing_to_get_help_for) diff --git a/redbot/core/utils/__init__.py b/redbot/core/utils/__init__.py index ec0b11bb3..eee5448f4 100644 --- a/redbot/core/utils/__init__.py +++ b/redbot/core/utils/__init__.py @@ -176,7 +176,11 @@ async def async_enumerate( async def fuzzy_command_search( - ctx: commands.Context, term: Optional[str] = None, *, min_score: int = 80 + ctx: commands.Context, + term: Optional[str] = None, + *, + commands: Optional[list] = None, + min_score: int = 80, ) -> Optional[List[commands.Command]]: """Search for commands which are similar in name to the one invoked. @@ -230,7 +234,9 @@ async def fuzzy_command_search( return # Do the scoring. `extracted` is a list of tuples in the form `(command, score)` - extracted = process.extract(term, ctx.bot.walk_commands(), limit=5, scorer=fuzz.QRatio) + extracted = process.extract( + term, (commands or ctx.bot.walk_commands()), limit=5, scorer=fuzz.QRatio + ) if not extracted: return