# Warning: The implementation below touches several private attributes. # While this implementation will be updated, and public interfaces maintained, derived classes # should not assume these private attributes are version safe, and use the provided HelpSettings # class for these settings. # This is a full replacement of discord.py's help command # # At a later date, there should 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. # Note: 3rd party help must not remove the copyright notice import asyncio from collections import namedtuple from dataclasses import dataclass 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 from ..utils._internal_utils import fuzzy_command_search, format_fuzzy_results from ..utils.chat_formatting import box, pagify __all__ = ["red_help", "RedHelpFormatter", "HelpSettings"] 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}" @dataclass(frozen=True) class HelpSettings: """ A representation of help settings. """ page_char_limit: int = 1000 max_pages_in_guild: int = 2 use_menus: bool = False show_hidden: bool = False verify_checks: bool = True verify_exists: bool = False tagline: str = "" # Contrib Note: This is intentional to not accept the bot object # There are plans to allow guild and user specific help settings # Adding a non-context based method now would involve a breaking change later. # At a later date, more methods should be exposed for non-context based creation. # # This is also why we aren't just caching the # current state of these settings on the bot object. @classmethod async def from_context(cls, context: Context): """ Get the HelpSettings for the current context """ settings = await context.bot._config.help.all() return cls(**settings) class NoCommand(Exception): pass class NoSubCommand(Exception): def __init__(self, *, last, not_found): self.last = last self.not_found = not_found 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. """ 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 await ctx.bot._config.help.verify_exists(): 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 = await ctx.bot._config.help.verify_exists() 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._config.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 = 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: def shorten_line(a_line: str) -> str: if len(a_line) < 70: # embed max width needs to be lower return a_line return a_line[:67] + "..." subtext = "\n".join( shorten_line(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 = 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(subcommands.items()) ) to_page = "\n\n".join( filter( None, ( description, signature[1:-1], command.help.replace("[p]", ctx.clean_prefix), 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 = [] current_count = 0 for f in fields: f_len = len(f.value) + len(f.name) if curr_group and (f_len + current_count > max_chars): ret.append(curr_group) curr_group = [] current_count = 0 curr_group.append(f) current_count += f_len else: # Loop cleanup here if curr_group: ret.append(curr_group) return ret async def make_and_send_embeds(self, ctx, embed_dict: dict): pages = [] page_char_limit = await ctx.bot._config.help.page_char_limit() page_char_limit = min(page_char_limit, 5990) # Just in case someone was manually... author_info = {"name": f"{ctx.me.display_name} Help Menu", "icon_url": ctx.me.avatar_url} # Offset calculation here is for total embed size limit # 20 accounts for# *Page {i} of {page_count}* offset = len(author_info["name"]) + 20 foot_text = embed_dict["footer"]["text"] if foot_text: offset += len(foot_text) offset += len(embed_dict["embed"]["description"]) offset += len(embed_dict["embed"]["title"]) # In order to only change the size of embeds when neccessary for this rather # than change the existing behavior for people uneffected by this # we're only modifying the page char limit should they be impacted. # We could consider changing this to always just subtract the offset, # But based on when this is being handled (very end of 3.2 release) # I'd rather not stick a major visual behavior change in at the last moment. if page_char_limit + offset > 5990: # This is still neccessary with the max interaction above # While we could subtract 100% of the time the offset from page_char_limit # the intent here is to shorten again # *only* when neccessary, by the exact neccessary amount # To retain a visual match with prior behavior. page_char_limit = 5990 - offset elif page_char_limit < 250: # Prevents an edge case where a combination of long cog help and low limit # Could prevent anything from ever showing up. # This lower bound is safe based on parts of embed in use. page_char_limit = 250 field_groups = self.group_embed_fields(embed_dict["fields"], page_char_limit) color = await ctx.embed_color() page_count = len(field_groups) if not field_groups: # This can happen on single command without a docstring embed = discord.Embed(color=color, **embed_dict["embed"]) embed.set_author(**author_info) embed.set_footer(**embed_dict["footer"]) pages.append(embed) for i, group in enumerate(field_groups, 1): embed = discord.Embed(color=color, **embed_dict["embed"]) if page_count > 1: description = f"*Page {i} of {page_count}*\n{embed.description}" 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): coms = await self.get_cog_help_mapping(ctx, obj) if not (coms or await ctx.bot._config.help.verify_exists()): return description = obj.help tagline = (await ctx.bot._config.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: splitted = description.split("\n\n") name = 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 coms: def shorten_line(a_line: str) -> str: if len(a_line) < 70: # embed max width needs to be lower return a_line return a_line[:67] + "..." command_text = "\n".join( shorten_line(f"**{name}** {command.short_doc}") for name, command in sorted(coms.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: subtext = None subtext_header = None if coms: subtext_header = "Commands:" max_width = max(discord.utils._string_width(name) for name in coms.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(coms.items()) ) to_page = "\n\n".join(filter(None, (description, 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): coms = await self.get_bot_help_mapping(ctx) if not coms: return description = ctx.bot.description or "" tagline = (await ctx.bot._config.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 coms: if cog_name: title = f"**__{cog_name}:__**" else: title = f"**__No Category:__**" def shorten_line(a_line: str) -> str: if len(a_line) < 70: # embed max width needs to be lower return a_line return a_line[:67] + "..." cog_text = "\n".join( shorten_line(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: to_join = [] if description: to_join.append(f"{description}\n") names = [] for k, v in coms: 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 coms: 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) @staticmethod async def help_filter_func( ctx, objects: Iterable[SupportsCanSee], bypass_hidden=False ) -> AsyncIterator[SupportsCanSee]: """ This does most of actual filtering. """ show_hidden = bypass_hidden or await ctx.bot._config.help.show_hidden() verify_checks = await ctx.bot._config.help.verify_checks() # TODO: Settings for this in core bot db for obj in objects: if verify_checks and not show_hidden: # Default Red behavior, can_see includes a can_run check. if await obj.can_see(ctx) and getattr(obj, "enabled", True): yield obj elif verify_checks: try: can_run = await obj.can_run(ctx) except discord.DiscordException: can_run = False if can_run and getattr(obj, "enabled", True): yield obj elif not show_hidden: if not 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(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx) ret.set_footer(text=tagline) await ctx.send(embed=ret) else: await ctx.send(ret) elif await ctx.bot._config.help.verify_exists(): ret = T_("Help topic for *{command_name}* not found.").format(command_name=help_for) if use_embeds: ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url) tagline = (await ctx.bot._config.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 subcommand named *{not_found}*.").format( command_name=command.qualified_name, not_found=not_found[0] ) if await ctx.embed_requested(): ret = discord.Embed(color=(await ctx.embed_color()), description=ret) ret.set_author(name=f"{ctx.me.display_name} Help Menu", icon_url=ctx.me.avatar_url) tagline = (await ctx.bot._config.help.tagline()) or self.get_default_tagline(ctx) ret.set_footer(text=tagline) await ctx.send(embed=ret) else: 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 ( ctx.channel.permissions_for(ctx.me).add_reactions and await ctx.bot._config.help.use_menus() ): max_pages_in_guild = await ctx.bot._config.help.max_pages_in_guild() destination = ctx.author if len(pages) > max_pages_in_guild else ctx if embed: for page in pages: try: await destination.send(embed=page) except discord.Forbidden: return await ctx.send( T_( "I couldn't send the help message to you in DM. " "Either you blocked me or you disabled DMs in this server." ) ) else: for page in pages: try: await destination.send(page) except discord.Forbidden: return await ctx.send( T_( "I couldn't send the help message to you in DM. " "Either you blocked me or you disabled DMs in this server." ) ) else: # Specifically ensuring the menu's message is sent prior to returning m = await (ctx.send(embed=pages[0]) if embed else ctx.send(pages[0])) c = menus.DEFAULT_CONTROLS if len(pages) > 1 else {"\N{CROSS MARK}": menus.close_menu} # Allow other things to happen during menu timeout/interaction. asyncio.create_task(menus.menu(ctx, pages, c, message=m)) # menu needs reactions added manually since we fed it a messsage menus.start_adding_reactions(m, c.keys()) @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)