itsVale/Vale.py

View on GitHub
utils/help.py

Summary

Maintainability
A
3 hrs
Test Coverage
import copy
import inspect
import itertools
import operator
import random
import sys

import discord
from discord.ext import commands
from more_itertools import chunked, flatten, run_length, sliced, spy

from .commands import all_names, command_category, walk_parents
from .converter import BotCommand
from .colors import random_color
from .deprecated import DeprecatedCommand
from .examples import command_example
from .misc import maybe_awaitable
from .paginator import Paginator, trigger


def _padded(string, width):
    return string + ' \u200b' * (width - len(string) + 1)


def _has_subcommands(command):
    return isinstance(command, commands.GroupMixin)


def _all_checks(command):
    yield from command.checks
    if not command.parent:
        return

    for parent in walk_parents(command.parent):
        if not parent.invoke_without_command:
            yield from parent.checks


def _build_command_requirements(command):
    requirements = []

    # All commands in this cog are owner-only anyways
    if command.cog_name == 'Owner':
        requirements.append('**Bot Owner only**')

    def make_pretty(p):
        return p.replace('_', ' ').title().replace('Guild', 'Server')

    for check in _all_checks(command):
        name = getattr(check, '__qualname__', '')

        if name.startswith('is_owner'):
            requirements.insert(0, '**Bot Owner only**')
        elif name.startswith('has_permissions'):
            permissions = inspect.getclosurevars(check).nonlocals['perms']
            pretty_permissions = [make_pretty(key) if value else f'~~{make_pretty(key)}~~'
                                  for key, value in permissions.items()]

            perm_names = ', '.join(pretty_permissions)
            requirements.append(f'{perm_names} permission{"s" * (len(pretty_permissions) != 1)}')

    return '\n'.join(requirements)


def _list_subcommands_and_descriptions(command):
    name_docs = sorted((str(sub), sub.short_doc) for sub in set(_visible_subcommands(command)))
    padding = max(len(name) for name, _ in name_docs)

    return '\n'.join(f'`{_padded(name, padding)}` \N{EM DASH} {doc}' for name, doc in name_docs)


def _visible_subcommands(command):
    return (c for c in command.walk_commands() if not c.hidden and c.enabled)


def _help_command_embed(ctx, command, func):
    clean_prefix = ctx.clean_prefix

    title = f'`{clean_prefix}{command.full_parent_name} {" / ".join(all_names(command))}`'
    description = (command.help or '').format(prefix=clean_prefix)
    if isinstance(command, DeprecatedCommand):
        description = f'*{command.warning}*\n\n{description}'

    embed = discord.Embed(title=func(title), description=func(description), color=random_color())

    requirements = _build_command_requirements(command)
    if requirements:
        embed.add_field(name=func('Requirements:'), value=func(requirements))

    usage = command_example(command, ctx)
    embed.add_field(name=func('Usage:'), value=func(usage), inline=False)

    if _has_subcommands(command):
        subs = _list_subcommands_and_descriptions(command)
        embed.add_field(name='See also:', value=subs, inline=False)

    category = command_category(command, 'Other')
    footer = f'Category: {category.title()}'
    return embed.set_footer(text=func(footer))


async def _can_run(command, ctx):
    try:
        return await command.can_run(ctx)
    except commands.CommandError:
        return False


async def _command_formatters(commands, ctx):
    for command in commands:
        yield command.name, await _can_run(command, ctx)


COMMAND_COLUMNS = 2


def _command_lines(command_can_run_pairs):
    if len(command_can_run_pairs) % 2:
        command_can_run_pairs = command_can_run_pairs + [('', '')]

    pairs = list(sliced(command_can_run_pairs, COMMAND_COLUMNS))
    widths = [max(len(c[0]) for c in column) for column in zip(*pairs)]

    def format_pair(pair, width):
        command, can_run = pair
        if not command:
            return ''

        formatted = f'`{_padded(command, width)}`'
        return formatted if can_run else f'~~{formatted}~~'

    return (' '.join(map(format_pair, pair, widths)) for pair in pairs)


CROSSED_NOTE = '**Note:** You can\'t use commands\nthat are ~~crossed out~~.'


def _get_category_commands(bot, category):
    return {c for c in bot.all_commands.values() if command_category(c, 'Other') == category}


class CogPages(Paginator):
    goto = None

    @classmethod
    async def create(cls, ctx, category):
        command, commands = spy(
            c for c in _get_category_commands(ctx.bot, category) if not (c.hidden or ctx.bot.formatter.show_hidden)
        )

        pairs = [pair async for pair in _command_formatters(sorted(commands, key=str), ctx)]

        self = cls(ctx, _command_lines(pairs))
        pkg_name = command[0].module.rpartition('.')[0]
        module = sys.modules[pkg_name]

        self._cog_doc = inspect.getdoc(module) or 'No description yet.'
        self._cog_name = category.title() or 'Other'

        return self

    def create_embed(self, page):
        return (discord.Embed(description=self._cog_doc, color=self.color)
                .set_author(name=self._cog_name)
                .add_field(name='Commands:', value='\n'.join(page) + f'\n\n{CROSSED_NOTE}')
                .set_footer(text=f'Currently on page {self._index + 1}')
                )

    @trigger('\N{BLACK SQUARE FOR STOP}', fallback='exit')
    async def stop(self):
        """Exit."""

        await super().stop()


class GeneralHelpPaginator(Paginator):
    first = None
    last = None
    help_page = None

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)

    @classmethod
    async def create(cls, ctx):
        def sort_key(c):
            return command_category(c), c.qualified_name

        entries = (cmd for cmd in sorted(ctx.bot.commands, key=sort_key) if not cmd.hidden)

        nested_pages = []
        per_page = 30

        for parent, cmds in itertools.groupby(entries, key=command_category):
            command, cmds = spy(cmds)
            command = next(iter(command))

            pkg_name = command.module.rpartition('.')[0]
            module = sys.modules[pkg_name]
            description = inspect.getdoc(module) or 'No description yet.'

            lines = [pair async for pair in _command_formatters(cmds, ctx)]
            nested_pages.extend((parent.title(), description, page) for page in sliced(lines, per_page))

        return cls(ctx, nested_pages, per_page=1)

    def create_embed(self, page):
        name, description, lines = page[0]
        random_command = random.choice(next(zip(*lines)))
        note = (
            f'For more help on a command,\ntype `{self.ctx.clean_prefix}help "command"`.\n'
            f'**Example:** `{self.ctx.clean_prefix}help {random_command}`'
        )
        formatted = '\n'.join(_command_lines(lines))
        commands = f'{formatted}\n{"-" * 30}\n{note}\n\u200b\n{CROSSED_NOTE}'

        return (discord.Embed(description=description, color=self.color)
                .set_author(name=name)
                .add_field(name='Commands:', value=commands)
                .set_footer(text=f'Page {self._index + 1} / {len(self._pages)}'))

    @trigger('\N{BLACK LEFT-POINTING TRIANGLE}', fallback=r'\<')
    def previous(self):
        """Back."""

        return super().previous() or (self.instructions() if self._index == 0 else None)

    @trigger('\N{BLACK RIGHT-POINTING TRIANGLE}', fallback=r'\>')
    def next(self):
        """Next."""

        return super().next()

    @trigger('\N{INPUT SYMBOL FOR NUMBERS}', blocking=True)
    async def goto(self):
        """Goto."""

        return await maybe_awaitable(super().goto)

    def instructions(self):
        """Table of Contents."""

        self._index = -1
        ctx = self.ctx
        bot = self.ctx.bot

        def cog_pages(iterable, start):
            for name, count in run_length.encode(map(operator.itemgetter(0), iterable)):
                if count == 1:
                    yield str(start), name
                else:
                    yield f'{start}-{start + count - 1}', name

                start += count

        pairs = list(cog_pages(flatten(self._pages), 1))
        padding = max(len(p[0]) for p in pairs)
        lines = [f'`\u200b{numbers:<{padding}}\u200b` - {name}' for numbers, name in pairs]

        if self.using_reactions():
            docs = ((emoji, func.__doc__) for emoji, func in self._reaction_map.items())
            controls = '\n'.join(f'{p1[0]} `{p1[1]}` | `{p2[1]}` {p2[0]}' for p1, p2 in chunked(docs, 2))
            footer_text = 'Click one of the reactions below.'
        else:
            controls = '\n'.join(
                f'`' + pattern.replace('\\', '') + f'` = `{func.__doc__}`'
                for pattern, func in self._message_fallbacks
            )
            footer_text = 'Type on of these things below because I don\'t have perms to give you the opportunity to react. **REEEEEEEE**'

        description = (f'For help on a command, type `{ctx.clean_prefix}help "command"`.\n\n'
                       f'To list the commands from one of the Categories below, type `{ctx.clean_prefix}commands "Category"`.\n')

        return (discord.Embed(description=description, color=self.color)
                .set_author(name='Vale.py Help', icon_url=bot.user.avatar_url)
                .add_field(name='Categories:', value='\n'.join(lines), inline=False)
                .add_field(name='Controls:', value=controls, inline=False)
                .set_footer(text=footer_text)
                )

    default = instructions

    @trigger('\N{BLACK SQUARE FOR STOP}', fallback='exit')
    async def stop(self):
        """Exit."""

        await super().stop()

    def check(self, reaction, user):
        if super().check(reaction, user):
            return True


class _HelpCommand(BotCommand):
    _choices = [
        'Help yourself.',
        'Gtfo, google it!',
        'https://www.50-best.com/images/memes_for_facebook_comments/just_google_it.jpg',
    ]

    async def convert(self, ctx, argument):
        try:
            return await super().convert(ctx, argument)
        except commands.BadArgument:
            if argument.lower() != 'me':
                raise commands.BadArgument(random.choice(self._choices))


def _help_error(ctx, missing_perms):
    old_send = ctx.send

    async def new_send(content, **kwargs):
        content += ' Turn on your DMs ffs.'
        await old_send(content, **kwargs)

    ctx.send = new_send
    raise commands.BotMissingPermissions(missing_perms)


async def _help_command(ctx, command, func):
    permissions = ctx.me.permissions_in(ctx.channel)
    if not permissions.send_messages:
        return

    if permissions.embed_links:
        return await ctx.send(embed=_help_command_embed(ctx, command, func))

    try:
        await ctx.author.send(embed=_help_command_embed(ctx, command, func))
    except discord.HTTPException:
        _help_error(ctx, ['embed_links'])


async def _help_general(ctx):
    if not ctx.me.permissions_in(ctx.channel).send_messages:
        return

    paginator = await GeneralHelpPaginator.create(ctx)
    try:
        await paginator.interact()
    except commands.BotMissingPermissions as e:
        missing_perms = e.missing_perms
    else:
        return

    new_ctx = copy.copy(ctx)
    new_ctx.channel = paginator._channel = await ctx.author.create_dm()
    paginator.ctx = new_ctx

    try:
        await paginator.interact()
    except discord.HTTPException:
        _help_error(ctx, missing_perms)


def help_command(func=lambda s: s, **kwargs):
    """Creates a help command with a given transformation function."""

    async def command(_, ctx, *, command: _HelpCommand = None):
        if command is None:
            await _help_general(ctx)
        else:
            await _help_command(ctx, command, func)

    try:
        module = sys._getframe(1).f_globals.get('__name__', '__main__')
    except (AttributeError, ValueError):
        pass

    if module is not None:
        command.__module__ = module

    return commands.command(help=func('Shows this message and other crap'), **kwargs)(command)