Red-DiscordBot/redbot/core/help_formatter.py
Toby Harradine f7dbaca340
Refactor fuzzy help and clean up help command (#2122)
What's changed:
- Fixed issues mentioned on #2031
- Fuzzy help displays more like help manual
- Fuzzy help is easier and more flexible to use
- Fuzzy help string-matching ratio lowered to 80
- Help formatter is more extendable
- Help command has been optimized, cleaned up and better incorporates fuzzy help
- Added async_filter and async_enumerate utility functions because I was using them for this PR, then no longer needed them, but decided they might be useful anyway.
- Added `Context.me` property which is a shortcut to `Context.guild.me` or `Context.bot.user`, depending on the channel type.
2018-09-24 10:34:39 +10:00

386 lines
13 KiB
Python

"""Overrides the built-in help formatter.
All help messages will be embed and pretty.
Most of the code stolen from
discord.ext.commands.formatter.py and
converted into embeds instead of codeblocks.
Docstr on cog class becomes category.
Docstr on command definition becomes command
summary and usage.
Use [p] in command docstr for bot prefix.
See [p]help here for example.
await bot.formatter.format_help_for(ctx, command)
to send help page for command. Optionally pass a
string as third arg to add a more descriptive
message to help page.
e.g. format_help_for(ctx, ctx.command, "Missing required arguments")
discord.py 1.0.0a
This help formatter contains work by Rapptz (Danny) and SirThane#1780.
"""
from collections import namedtuple
from typing import List, Optional, Union
import discord
from discord.ext.commands import formatter as dpy_formatter
import inspect
import itertools
import re
from . import commands
from .i18n import Translator
from .utils.chat_formatting import pagify
from .utils import fuzzy_command_search, format_fuzzy_results
_ = Translator("Help", __file__)
EMPTY_STRING = "\u200b"
_mentions_transforms = {"@everyone": "@\u200beveryone", "@here": "@\u200bhere"}
_mention_pattern = re.compile("|".join(_mentions_transforms.keys()))
EmbedField = namedtuple("EmbedField", "name value inline")
class Help(dpy_formatter.HelpFormatter):
"""Formats help for commands."""
def __init__(self, *args, **kwargs):
self.context = None
self.command = None
super().__init__(*args, **kwargs)
@staticmethod
def pm_check(ctx):
return isinstance(ctx.channel, discord.DMChannel)
@property
def me(self):
return self.context.me
@property
def bot_all_commands(self):
return self.context.bot.all_commands
@property
def avatar(self):
return self.context.bot.user.avatar_url_as(format="png")
async def color(self):
if self.pm_check(self.context):
return self.context.bot.color
else:
return await self.context.embed_colour()
colour = color
@property
def destination(self):
if self.context.bot.pm_help:
return self.context.author
return self.context
# All the other shit
@property
def author(self):
# Get author dict with username if PM and display name in guild
if self.pm_check(self.context):
name = self.context.bot.user.name
else:
name = self.me.display_name if not "" else self.context.bot.user.name
author = {"name": "{0} Help Manual".format(name), "icon_url": self.avatar}
return author
def _add_subcommands(self, cmds):
entries = ""
for name, command in cmds:
if name in command.aliases:
# skip aliases
continue
if self.is_cog() or self.is_bot():
name = "{0}{1}".format(self.context.clean_prefix, name)
entries += "**{0}** {1}\n".format(name, command.short_doc)
return entries
def get_ending_note(self):
# command_name = self.context.invoked_with
return (
"Type {0}help <command> for more info on a command.\n"
"You can also type {0}help <category> for more info on a category.".format(
self.context.clean_prefix
)
)
async def format(self) -> dict:
"""Formats command for output.
Returns a dict used to build embed"""
emb = {"embed": {"title": "", "description": ""}, "footer": {"text": ""}, "fields": []}
if self.is_cog():
translator = getattr(self.command, "__translator__", lambda s: s)
description = (
inspect.cleandoc(translator(self.command.__doc__))
if self.command.__doc__
else EMPTY_STRING
)
else:
description = self.command.description
if not description == "" and description is not None:
description = "*{0}*".format(description)
if description:
# <description> portion
emb["embed"]["description"] = description[:2046]
tagline = await self.context.bot.db.help.tagline()
if tagline:
footer = tagline
else:
footer = self.get_ending_note()
emb["footer"]["text"] = footer
if isinstance(self.command, discord.ext.commands.core.Command):
# <signature portion>
emb["embed"]["title"] = emb["embed"]["description"]
emb["embed"]["description"] = "`Syntax: {0}`".format(self.get_command_signature())
# <long doc> section
if self.command.help:
splitted = self.command.help.split("\n\n")
name = "__{0}__".format(splitted[0])
value = "\n\n".join(splitted[1:]).replace("[p]", self.context.clean_prefix)
if value == "":
value = EMPTY_STRING
field = EmbedField(name[:252], value[:1024], False)
emb["fields"].append(field)
# end it here if it's just a regular command
if not self.has_subcommands():
return emb
def category(tup):
# Turn get cog (Category) name from cog/list tuples
cog = tup[1].cog_name
return "**__{0}:__**".format(cog) if cog is not None else "**__\u200bNo Category:__**"
# Get subcommands for bot or category
filtered = await self.filter_command_list()
if self.is_bot():
# Get list of non-hidden commands for bot.
data = sorted(filtered, key=category)
for category, commands_ in itertools.groupby(data, key=category):
commands_ = sorted(commands_)
if len(commands_) > 0:
for i, page in enumerate(
pagify(self._add_subcommands(commands_), page_length=1000)
):
title = category if i < 1 else f"{category} (continued)"
field = EmbedField(title, page, False)
emb["fields"].append(field)
else:
# Get list of commands for category
filtered = sorted(filtered)
if filtered:
for i, page in enumerate(
pagify(self._add_subcommands(filtered), page_length=1000)
):
title = (
"**__Commands:__**"
if not self.is_bot() and self.is_cog()
else "**__Subcommands:__**"
)
if i > 0:
title += " (continued)"
field = EmbedField(title, page, False)
emb["fields"].append(field)
return emb
@staticmethod
def group_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 format_help_for(self, ctx, command_or_bot, reason: str = None):
"""Formats the help page and handles the actual heavy lifting of how ### WTF HAPPENED?
the help command looks like. To change the behaviour, override the
:meth:`~.HelpFormatter.format` method.
Parameters
-----------
ctx: :class:`.Context`
The context of the invoked help command.
command_or_bot: :class:`.Command` or :class:`.Bot`
The bot or command that we are getting the help of.
reason : str
Returns
--------
list
A paginated output of the help command.
"""
self.context = ctx
self.command = command_or_bot
emb = await self.format()
if reason:
emb["embed"]["title"] = "{0}".format(reason)
ret = []
page_char_limit = await ctx.bot.db.help.page_char_limit()
field_groups = self.group_fields(emb["fields"], page_char_limit)
for i, group in enumerate(field_groups, 1):
embed = discord.Embed(color=await self.color(), **emb["embed"])
if len(field_groups) > 1:
description = "{} *- Page {} of {}*".format(
embed.description, i, len(field_groups)
)
embed.description = description
embed.set_author(**self.author)
for field in group:
embed.add_field(**field._asdict())
embed.set_footer(**emb["footer"])
ret.append(embed)
return ret
async def format_command_not_found(
self, ctx: commands.Context, command_name: str
) -> Optional[Union[str, discord.Message]]:
"""Get the response for a user calling help on a missing command."""
self.context = ctx
return await default_command_not_found(
ctx,
command_name,
use_embeds=True,
colour=await self.colour(),
author=self.author,
footer={"text": self.get_ending_note()},
)
@commands.command(hidden=True)
async def help(ctx: commands.Context, *, command_name: str = ""):
"""Show help documentation.
- `[p]help`: Show the help manual.
- `[p]help command`: Show help for a command.
- `[p]help Category`: Show commands and description for a category,
"""
bot = ctx.bot
if bot.pm_help:
destination = ctx.author
else:
destination = ctx.channel
use_embeds = await ctx.embed_requested()
if use_embeds:
formatter = bot.formatter
else:
formatter = dpy_formatter.HelpFormatter()
if not command_name:
# help by itself just lists our own commands.
pages = await formatter.format_help_for(ctx, bot)
else:
command: commands.Command = bot.get_command(command_name)
if command is None:
if hasattr(formatter, "format_command_not_found"):
msg = await formatter.format_command_not_found(ctx, command_name)
else:
msg = await default_command_not_found(ctx, command_name, use_embeds=use_embeds)
pages = [msg]
else:
pages = await formatter.format_help_for(ctx, command)
max_pages_in_guild = await ctx.bot.db.help.max_pages_in_guild()
if len(pages) > max_pages_in_guild:
destination = ctx.author
if ctx.guild and not ctx.guild.me.permissions_in(ctx.channel).send_messages:
destination = ctx.author
try:
for page in pages:
if isinstance(page, discord.Embed):
await destination.send(embed=page)
else:
await destination.send(page)
except discord.Forbidden:
await ctx.channel.send(
_(
"I couldn't send the help message to you in DM. Either you blocked me or you "
"disabled DMs in this server."
)
)
async def default_command_not_found(
ctx: commands.Context, command_name: str, *, use_embeds: bool, **embed_options
) -> Optional[Union[str, discord.Embed]]:
"""Default function for formatting the response to a missing command."""
ret = None
cmds = command_name.split()
prev_command = None
for invoked in itertools.accumulate(cmds, lambda *args: " ".join(args)):
command = ctx.bot.get_command(invoked)
if command is None:
if prev_command is not None and not isinstance(prev_command, commands.Group):
ret = _("Command *{command_name}* has no subcommands.").format(
command_name=prev_command.qualified_name
)
break
elif not await command.can_see(ctx):
return
prev_command = command
if ret is None:
fuzzy_commands = await fuzzy_command_search(ctx, command_name, min_score=75)
if fuzzy_commands:
ret = await format_fuzzy_results(ctx, fuzzy_commands, embed=use_embeds)
else:
ret = _("Command *{command_name}* not found.").format(command_name=command_name)
if use_embeds:
if isinstance(ret, str):
ret = discord.Embed(title=ret)
if "colour" in embed_options:
ret.colour = embed_options.pop("colour")
elif "color" in embed_options:
ret.colour = embed_options.pop("color")
if "author" in embed_options:
ret.set_author(**embed_options.pop("author"))
if "footer" in embed_options:
ret.set_footer(**embed_options.pop("footer"))
return ret