Michael H ee3be8b633 Extra info (#3008)
* Extra info

* changelog
2019-09-20 16:55:46 +02:00

589 lines
22 KiB
Python

# 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 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 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 <command> for more info on a command. "
f"You can also type {ctx.clean_prefix}help <category> 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 = []
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._config.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}
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"{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):
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)