diff --git a/discord/ext/commands/__init__.py b/discord/ext/commands/__init__.py index ac5863c87..616e89100 100644 --- a/discord/ext/commands/__init__.py +++ b/discord/ext/commands/__init__.py @@ -14,3 +14,4 @@ from .bot import Bot, when_mentioned from .context import Context from .core import * from .errors import * +from .formatter import HelpFormatter diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index e09362ea4..2b2ad6252 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -29,11 +29,13 @@ import discord import inspect import importlib import sys +import functools -from .core import GroupMixin, Command +from .core import GroupMixin, Command, command from .view import StringView from .context import Context from .errors import CommandNotFound +from .formatter import HelpFormatter def _get_variable(name): stack = inspect.stack() @@ -50,6 +52,28 @@ def when_mentioned(bot, msg): to being mentioned, e.g. ``@bot ``.""" return '{0.user.mention} '.format(bot) +@command(pass_context=True, name='help') +@asyncio.coroutine +def _default_help_command(ctx, *commands : str): + """Shows this message.""" + bot = ctx.bot + destination = ctx.message.channel if not bot.pm_help else ctx.message.author + # help by itself just lists our own commands. + if len(commands) == 0: + pages = bot.formatter.format_help_for(ctx, bot) + else: + try: + command = functools.reduce(dict.__getitem__, commands, bot.commands) + except KeyError as e: + yield from bot.send_message(destination, 'No command called {} found.'.format(e)) + return + + pages = bot.formatter.format_help_for(ctx, command) + + for page in pages: + yield from bot.send_message(destination, page) + + class Bot(GroupMixin, discord.Client): """Represents a discord bot. @@ -74,13 +98,34 @@ class Bot(GroupMixin, discord.Client): multiple checks for the prefix should be used and the first one to match will be the invocation prefix. You can get this prefix via :attr:`Context.prefix`. + description : str + The content prefixed into the default help message. + formatter : :class:`HelpFormatter` + The formatter used to format the help message. By default, it uses a + the :class:`HelpFormatter`. Check it for more info on how to override it. + If you want to change the help command completely (add aliases, etc) then + a call to :meth:`remove_command` with 'help' as the argument would do the + trick. + pm_help : bool + A boolean that indicates if the help command should PM the user instead of + sending it to the channel it received it from. Defaults to ``False``. """ - def __init__(self, command_prefix, **options): + def __init__(self, command_prefix, formatter=None, description=None, pm_help=False, **options): super().__init__(**options) self.command_prefix = command_prefix self.extra_events = {} self.cogs = {} self.extensions = {} + self.description = description + self.pm_help = pm_help + if formatter is not None: + if not isinstance(formatter, HelpFormatter): + raise discord.ClientException('Formatter must be a subclass of HelpFormatter') + self.formatter = formatter + else: + self.formatter = HelpFormatter() + + self.add_command(_default_help_command) # internal helpers diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index fad133dcc..5817ec77c 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -93,6 +93,11 @@ class Command: :exc:`CommandError` should be used. Note that if the checks fail then :exc:`CheckFailure` exception is raised to the :func:`on_command_error` event. + description : str + The message prefixed into the default help command. + hidden : bool + If ``True``, the default help command does not show this in the + help output. """ def __init__(self, name, callback, **kwargs): self.name = name @@ -102,6 +107,8 @@ class Command: self.brief = kwargs.get('brief') self.aliases = kwargs.get('aliases', []) self.pass_context = kwargs.get('pass_context', False) + self.description = kwargs.get('description') + self.hidden = kwargs.get('hidden', False) signature = inspect.signature(callback) self.params = signature.parameters.copy() self.checks = kwargs.get('checks', []) @@ -276,12 +283,8 @@ class Command: try: if not self.enabled: raise DisabledCommand('{0.name} command is disabled'.format(self)) - - predicates = self.checks - if predicates: - check = all(predicate(ctx) for predicate in predicates) - if not check: - raise CheckFailure('The check functions for command {0.name} failed.'.format(self)) + if not self.can_run(ctx): + raise CheckFailure('The check functions for command {0.name} failed.'.format(self)) except CommandError as exc: self.handle_local_error(exc, ctx) ctx.bot.dispatch('command_error', exc, ctx) @@ -327,6 +330,41 @@ class Command: """The name of the cog this command belongs to. None otherwise.""" return type(self.instance).__name__ if self.instance is not None else None + @property + def short_doc(self): + """Gets the "short" documentation of a command. + + By default, this is the :attr:`brief` attribute. + If that lookup leads to an empty string then the first line of the + :attr:`help` attribute is used instead. + """ + if self.brief: + return self.brief + if self.help: + return self.help.split('\n', 1)[0] + return '' + + def can_run(self, context): + """Checks if the command can be executed by checking all the predicates + inside the :attr:`checks` attribute. + + Parameters + ----------- + context : :class:`Context` + The context of the command currently being invoked. + + Returns + -------- + bool + A boolean indicating if the command can be invoked. + """ + + predicates = self.checks + if not predicates: + # since we have no checks, then we just return True. + return True + return all(predicate(context) for predicate in predicates) + class GroupMixin: """A mixin that implements common functionality for classes that behave similar to :class:`Group` and are allowed to register commands. diff --git a/discord/ext/commands/formatter.py b/discord/ext/commands/formatter.py new file mode 100644 index 000000000..8320793d7 --- /dev/null +++ b/discord/ext/commands/formatter.py @@ -0,0 +1,285 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2016 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +import textwrap +import itertools + +from .core import GroupMixin, Command + +# help -> shows info of bot on top/bottom and lists subcommands +# help command -> shows detailed info of command +# help command -> same as above + +# + +# + +# + +# Cog: +# +# +# Other Cog: +# +# No Category: +# + +# Type help command for more info on a command. +# You can also type help category for more info on a category. + + +class HelpFormatter: + """The default base implementation that handles formatting of the help + command. + + To override the behaviour of the formatter, :meth:`format` + should be overridden. A number of utility functions are provided for use + inside that method. + + Parameters + ----------- + show_hidden : bool + Dictates if hidden commands should be shown in the output. + Defaults to ``False``. + show_check_faiure : bool + Dictates if commands that have their :attr:`Command.checks` failed + shown. Defaults to ``False``. + width : int + The maximum number of characters that fit in a line. + Defaults to 80. + """ + def __init__(self, show_hidden=False, show_check_faiure=False, width=80): + self.wrapper = textwrap.TextWrapper(width=width) + self.show_hidden = show_hidden + self.show_check_faiure = show_check_faiure + + def has_subcommands(self): + """bool : Specifies if the command has subcommands.""" + return isinstance(self.command, GroupMixin) + + def is_bot(self): + """bool : Specifies if the command being formatted is the bot itself.""" + return self.command is self.context.bot + + def shorten(self, text): + """Shortens text to fit into the :attr:`width`.""" + tmp = self.wrapper.max_lines + self.wrapper.max_lines = 1 + res = self.wrapper.fill(text) + self.wrapper.max_lines = tmp + del tmp + return res + + @property + def max_name_size(self): + """int : Returns the largest name length of a command or if it has subcommands + the largest subcommand name.""" + try: + return max(map(lambda c: len(c.name), self.command.commands.values())) + except AttributeError: + return len(self.command.name) + + @property + def clean_prefix(self): + """The cleaned up invoke prefix. i.e. mentions are ``@name`` instead of ``<@id>``.""" + user = self.context.bot.user + # this breaks if the prefix mention is not the bot itself but I + # consider this to be an *incredibly* strange use case. I'd rather go + # for this common use case rather than waste performance for the + # odd one. + return self.context.prefix.replace(user.mention, '@' + user.name) + + def get_command_signature(self): + """Retrieves the signature portion of the help page.""" + result = [] + prefix = self.clean_prefix + cmd = self.command + if len(cmd.aliases) > 0: + aliases = '|'.join(cmd.aliases) + name = '{0}[{1.name}|{2}]'.format(prefix, cmd, aliases) + result.append(name) + else: + result.append(prefix + cmd.name) + + params = cmd.clean_params + if len(params) > 0: + for name, param in params.items(): + cleaned_name = name.replace('_', '-') + if param.default is not param.empty: + result.append('{0}={1}'.format(cleaned_name, param.default)) + elif param.kind == param.VAR_POSITIONAL: + result.append(cleaned_name + '...') + else: + result.append(cleaned_name) + + return ' '.join(result) + + def get_ending_note(self): + 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.clean_prefix) + + def filter_command_list(self): + """Returns a filtered list of commands based on the two attributes + provided, :attr:`show_check_faiure` and :attr:`show_hidden`. + + Returns + -------- + iterable + An iterable with the filter being applied. The resulting value is + a (key, value) tuple of the command name and the command itself. + """ + def predicate(tuple): + cmd = tuple[1] + if cmd.hidden and not self.show_hidden: + return False + + if self.show_check_faiure: + # we don't wanna bother doing the checks if the user does not + # care about them, so just return true. + return True + return cmd.can_run(self.context) + + return filter(predicate, self.command.commands.items()) + + def _check_new_page(self): + # be a little on the safe side + if self._count > 1920: + # add the page + self._current_page.append('```') + self._pages.append('\n'.join(self._current_page)) + self._current_page = ['```'] + self._count = 4 + + def _add_subcommands_to_page(self, max_width, commands): + for name, command in commands: + if name in command.aliases: + # skip aliases + continue + + entry = ' {0:<{width}} {1}'.format(name, command.short_doc, width=max_width) + shortened = self.shorten(entry) + self._count += len(shortened) + self._check_new_page() + self._current_page.append(shortened) + + def format_help_for(self, context, command_or_bot): + """Formats the help page and handles the actual heavy lifting of how + the help command looks like. To change the behaviour, override the + :meth:`format` method. + + Parameters + ----------- + context : :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. + + Returns + -------- + list + A paginated output of the help command. + """ + self.context = context + self.command = command_or_bot + return self.format() + + def format(self): + """Handles the actual behaviour involved with formatting. + + To change the behaviour, this method should be overridden. + + Returns + -------- + list + A paginated output of the help command. + """ + self._pages = [] + self._count = 4 # ``` + '\n' + self._current_page = ['```'] + + # we need a padding of ~80 or so + + if self.command.description: + # portion + self._current_page.append(self.command.description) + self._current_page.append('') + self._count += len(self.command.description) + + if not self.is_bot(): + # + signature = self.get_command_signature() + self._count += 2 + len(signature) # '\n' sig '\n' + self._current_page.append(signature) + self._current_page.append('') + + # section + if self.command.help: + self._count += 2 + len(self.command.help) + self._current_page.append(self.command.help) + self._current_page.append('') + self._check_new_page() + + if not self.has_subcommands(): + self._current_page.append('```') + self._pages.append('\n'.join(self._current_page)) + return self._pages + + max_width = self.max_name_size + + def category(tup): + cog = tup[1].cog_name + # we insert the zero width space there to give it approximate + # last place sorting position. + return cog + ':' if cog is not None else '\u200bNo Category:' + + if self.is_bot(): + data = sorted(self.filter_command_list(), key=category) + for category, commands in itertools.groupby(data, key=category): + # there simply is no prettier way of doing this. + commands = list(commands) + if len(commands) > 0: + self._current_page.append(category) + self._count += len(category) + self._check_new_page() + + self._add_subcommands_to_page(max_width, commands) + else: + self._current_page.append('Commands:') + self._count += 1 + len(self._current_page[-1]) + self._add_subcommands_to_page(max_width, self.filter_command_list()) + + # add the ending note + self._current_page.append('') + ending_note = self.get_ending_note() + self._count += len(ending_note) + self._check_new_page() + self._current_page.append(ending_note) + + if len(self._current_page) > 1: + self._current_page.append('```') + self._pages.append('\n'.join(self._current_page)) + + return self._pages