aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--discord/ext/commands/__init__.py1
-rw-r--r--discord/ext/commands/bot.py49
-rw-r--r--discord/ext/commands/core.py50
-rw-r--r--discord/ext/commands/formatter.py285
4 files changed, 377 insertions, 8 deletions
diff --git a/discord/ext/commands/__init__.py b/discord/ext/commands/__init__.py
index ac5863c8..616e8910 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 e09362ea..2b2ad625 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')
+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 fad133dc..5817ec77 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 00000000..8320793d
--- /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 <subcommand chain> -> same as above
+
+# <description>
+
+# <command signature with aliases>
+
+# <long doc>
+
+# Cog:
+# <command> <shortdoc>
+# <command> <shortdoc>
+# Other Cog:
+# <command> <shortdoc>
+# No Category:
+# <command> <shortdoc>
+
+# Type <prefix>help command for more info on a command.
+# You can also type <prefix>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:
+ # <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 portion>
+ signature = self.get_command_signature()
+ self._count += 2 + len(signature) # '\n' sig '\n'
+ self._current_page.append(signature)
+ self._current_page.append('')
+
+ # <long doc> 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