aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--discord/ext/__init__.py12
-rw-r--r--discord/ext/commands/__init__.py16
-rw-r--r--discord/ext/commands/bot.py221
-rw-r--r--discord/ext/commands/context.py84
-rw-r--r--discord/ext/commands/core.py506
-rw-r--r--discord/ext/commands/errors.py63
-rw-r--r--discord/ext/commands/view.py167
7 files changed, 1069 insertions, 0 deletions
diff --git a/discord/ext/__init__.py b/discord/ext/__init__.py
new file mode 100644
index 00000000..af6a0088
--- /dev/null
+++ b/discord/ext/__init__.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+"""
+discord.py extensions
+~~~~~~~~~~~~~~~~~~~~~~
+
+Extensions for the discord.py library live in this namespace.
+
+:copyright: (c) 2016 Rapptz
+:license: MIT, see LICENSE for more details.
+
+"""
diff --git a/discord/ext/commands/__init__.py b/discord/ext/commands/__init__.py
new file mode 100644
index 00000000..527d5157
--- /dev/null
+++ b/discord/ext/commands/__init__.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+
+"""
+discord.ext.commands
+~~~~~~~~~~~~~~~~~~~~~
+
+An extension module to facilitate creation of bot commands.
+
+:copyright: (c) 2016 Rapptz
+:license: MIT, see LICENSE for more details.
+"""
+
+from .bot import Bot
+from .context import Context
+from .core import *
+from .errors import *
diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py
new file mode 100644
index 00000000..a788f670
--- /dev/null
+++ b/discord/ext/commands/bot.py
@@ -0,0 +1,221 @@
+# -*- 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 asyncio
+import discord
+import inspect
+
+from .core import GroupMixin
+from .view import StringView
+from .context import Context
+
+class Bot(GroupMixin, discord.Client):
+ """Represents a discord bot.
+
+ This class is a subclass of :class:`discord.Client` and as a result
+ anything that you can do with a :class:`discord.Client` you can do with
+ this bot.
+
+ This class also subclasses :class:`GroupMixin` to provide the functionality
+ to manage commands.
+
+ Parameters
+ -----------
+ command_prefix
+ The command prefix is what the message content must contain initially
+ to have a command invoked. This prefix could either be a string to
+ indicate what the prefix should be, or a callable that takes in a
+ :class:`discord.Message` as its first parameter and returns the prefix.
+ This is to facilitate "dynamic" command prefixes.
+ """
+ def __init__(self, command_prefix, **options):
+ super().__init__(**options)
+ self.command_prefix = command_prefix
+
+ def _get_variable(self, name):
+ stack = inspect.stack()
+ for frames in stack:
+ current_locals = frames[0].f_locals
+ if name in current_locals:
+ return current_locals[name]
+
+ def _get_prefix(self, message):
+ prefix = self.command_prefix
+ if callable(prefix):
+ return prefix(message)
+ else:
+ return prefix
+
+ @asyncio.coroutine
+ def say(self, content):
+ """|coro|
+
+ A helper function that is equivalent to doing
+
+ .. code-block:: python
+
+ self.send_message(message.channel, content)
+
+ Parameters
+ ----------
+ content : str
+ The content to pass to :class:`Client.send_message`
+ """
+ destination = self._get_variable('_internal_channel')
+ result = yield from self.send_message(destination, content)
+ return result
+
+ @asyncio.coroutine
+ def whisper(self, content):
+ """|coro|
+
+ A helper function that is equivalent to doing
+
+ .. code-block:: python
+
+ self.send_message(message.author, content)
+
+ Parameters
+ ----------
+ content : str
+ The content to pass to :class:`Client.send_message`
+ """
+ destination = self._get_variable('_internal_author')
+ result = yield from self.send_message(destination, content)
+ return result
+
+ @asyncio.coroutine
+ def reply(self, content):
+ """|coro|
+
+ A helper function that is equivalent to doing
+
+ .. code-block:: python
+
+ msg = '{0.mention}, {1}'.format(message.author, content)
+ self.send_message(message.channel, msg)
+
+ Parameters
+ ----------
+ content : str
+ The content to pass to :class:`Client.send_message`
+ """
+ author = self._get_variable('_internal_author')
+ destination = self._get_variable('_internal_channel')
+ fmt = '{0.mention}, {1}'.format(author, str(content))
+ result = yield from self.send_message(destination, fmt)
+ return result
+
+ @asyncio.coroutine
+ def upload(self, fp, name=None):
+ """|coro|
+
+ A helper function that is equivalent to doing
+
+ .. code-block:: python
+
+ self.send_file(message.channel, fp, name)
+
+ Parameters
+ ----------
+ fp
+ The first parameter to pass to :meth:`Client.send_file`
+ name
+ The second parameter to pass to :meth:`Client.send_file`
+ """
+ destination = self._get_variable('_internal_channel')
+ result = yield from self.send_file(destination, fp, name)
+ return result
+
+ @asyncio.coroutine
+ def type(self):
+ """|coro|
+
+ A helper function that is equivalent to doing
+
+ .. code-block:: python
+
+ self.send_typing(message.channel)
+
+ See Also
+ ---------
+ The :meth:`Client.send_typing` function.
+ """
+ destination = self._get_variable('_internal_channel')
+ yield from self.send_typing(destination)
+
+ @asyncio.coroutine
+ def process_commands(self, message):
+ """|coro|
+
+ This function processes the commands that have been registered
+ to the bot and other groups. Without this coroutine, none of the
+ commands will be triggered.
+
+ By default, this coroutine is called inside the :func:`on_message`
+ event. If you choose to override the :func:`on_message` event, then
+ you should invoke this coroutine as well.
+
+ Warning
+ --------
+ This function is necessary for :meth:`say`, :meth:`whisper`,
+ :meth:`type`, :meth:`reply`, and :meth:`upload` to work due to the
+ way they are written.
+
+ Parameters
+ -----------
+ message : discord.Message
+ The message to process commands for.
+ """
+ _internal_channel = message.channel
+ _internal_author = message.author
+
+ view = StringView(message.content)
+ if message.author == self.user:
+ return
+
+ prefix = self._get_prefix(message)
+ if not view.skip_string(prefix):
+ return
+
+ view.skip_ws()
+ invoker = view.get_word()
+ if invoker in self.commands:
+ command = self.commands[invoker]
+ tmp = {
+ 'bot': self,
+ 'invoked_with': invoker,
+ 'message': message,
+ 'view': view,
+ 'command': command
+ }
+ ctx = Context(**tmp)
+ del tmp
+ yield from command.invoke(ctx)
+
+ @asyncio.coroutine
+ def on_message(self, message):
+ yield from self.process_commands(message)
diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py
new file mode 100644
index 00000000..3e8266ed
--- /dev/null
+++ b/discord/ext/commands/context.py
@@ -0,0 +1,84 @@
+# -*- 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 asyncio
+
+class Context:
+ """Represents the context in which a command is being invoked under.
+
+ This class contains a lot of meta data to help you understand more about
+ the invocation context. This class is not created manually and is instead
+ passed around to commands by passing in :attr:`Command.pass_context`.
+
+ Attributes
+ -----------
+ message : :class:`discord.Message`
+ The message that triggered the command being executed.
+ bot : :class:`Bot`
+ The bot that contains the command being executed.
+ args : list
+ The list of transformed arguments that were passed into the command.
+ If this is accessed during the :func:`on_command_error` event
+ then this list could be incomplete.
+ kwargs : dict
+ A dictionary of transformed arguments that were passed into the command.
+ Similar to :attr:`args`\, if this is accessed in the
+ :func:`on_command_error` event then this dict could be incomplete.
+ command
+ The command (i.e. :class:`Command` or its superclasses) that is being
+ invoked currently.
+ invoked_with : str
+ The command name that triggered this invocation. Useful for finding out
+ which alias called the command.
+ invoked_subcommand
+ The subcommand (i.e. :class:`Command` or its superclasses) that was
+ invoked. If no valid subcommand was invoked then this is equal to
+ `None`.
+ subcommand_passed : Optional[str]
+ The string that was attempted to call a subcommand. This does not have
+ to point to a valid registered subcommand and could just point to a
+ nonsense string. If nothing was passed to attempt a call to a
+ subcommand then this is set to `None`.
+ """
+ __slots__ = ['message', 'bot', 'args', 'kwargs', 'command', 'view',
+ 'invoked_with', 'invoked_subcommand', 'subcommand_passed']
+
+ def __init__(self, **attrs):
+ self.message = attrs.pop('message', None)
+ self.bot = attrs.pop('bot', None)
+ self.args = attrs.pop('args', [])
+ self.kwargs = attrs.pop('kwargs', {})
+ self.command = attrs.pop('command', None)
+ self.view = attrs.pop('view', None)
+ self.invoked_with = attrs.pop('invoked_with', None)
+ self.invoked_subcommand = attrs.pop('invoked_subcommand', None)
+ self.subcommand_passed = attrs.pop('subcommand_passed', None)
+
+ @asyncio.coroutine
+ def invoke(self, command, **kwargs):
+ if len(kwargs) == 0:
+ yield from command.invoke(self)
+ else:
+ yield from command.callback(**kwargs)
diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py
new file mode 100644
index 00000000..8729d53b
--- /dev/null
+++ b/discord/ext/commands/core.py
@@ -0,0 +1,506 @@
+# -*- 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 inspect
+import re
+import discord
+
+from .errors import *
+from .view import quoted_word
+
+__all__ = [ 'Command', 'Group', 'GroupMixin', 'command', 'group',
+ 'has_role', 'has_permissions', 'has_any_role', 'check' ]
+
+class Command:
+ """A class that implements the protocol for a bot text command.
+
+ These are not created manually, instead they are created via the
+ decorator or functional interface.
+
+ Attributes
+ -----------
+ name : str
+ The name of the command.
+ callback : coroutine
+ The coroutine that is executed when the command is called.
+ help : str
+ The long help text for the command.
+ brief : str
+ The short help text for the command.
+ aliases : list
+ The list of aliases the command can be invoked under.
+ pass_context : bool
+ A boolean that indicates that the current :class:`Context` should
+ be passed as the **first parameter**. Defaults to `False`.
+ checks
+ A list of predicates that verifies if the command could be executed
+ with the given :class:`Context` as the sole parameter. If an exception
+ is necessary to be thrown to signal failure, then one derived from
+ :exc:`CommandError` should be used. Note that if the checks fail then
+ :exc:`CheckFailure` exception is raised to the :func:`on_command_error`
+ event.
+ """
+ def __init__(self, name, callback, **kwargs):
+ self.name = name
+ self.callback = callback
+ self.help = kwargs.get('help')
+ self.brief = kwargs.get('brief')
+ self.aliases = kwargs.get('aliases', [])
+ self.pass_context = kwargs.get('pass_context', False)
+ signature = inspect.signature(callback)
+ self.params = signature.parameters.copy()
+ self.checks = kwargs.get('checks', [])
+
+ def _receive_item(self, message, argument, regex, receiver, generator):
+ match = re.match(regex, argument)
+ result = None
+ private = message.channel.is_private
+ receiver = getattr(message.server, receiver, ())
+ if match is None:
+ if not private:
+ result = discord.utils.get(receiver, name=argument)
+ else:
+ iterable = receiver if not private else generator
+ result = discord.utils.get(iterable, id=match.group(1))
+ return result
+
+ def do_conversion(self, bot, message, converter, argument):
+ if converter.__module__.split('.')[0] != 'discord':
+ return converter(argument)
+
+ # special handling for discord.py related classes
+ if converter is discord.User or converter is discord.Member:
+ member = self._receive_item(message, argument, r'<@([0-9]+)>', 'members', bot.get_all_members())
+ if member is None:
+ raise BadArgument('User/Member not found.')
+ return member
+ elif converter is discord.Channel:
+ channel = self._receive_item(message, argument, r'<#([0-9]+)>', 'channels', bot.get_all_channels())
+ if channel is None:
+ raise BadArgument('Channel not found.')
+ return channel
+ elif converter is discord.Colour:
+ arg = argument.replace('0x', '').lower()
+ try:
+ value = int(arg, base=16)
+ return discord.Colour(value=value)
+ except ValueError:
+ method = getattr(discord.Colour, arg, None)
+ if method is None or not inspect.ismethod(method):
+ raise BadArgument('Colour passed is invalid.')
+ return method()
+ elif converter is discord.Role:
+ if message.channel.is_private:
+ raise NoPrivateMessage()
+
+ role = discord.utils.get(message.server.roles, name=argument)
+ if role is None:
+ raise BadArgument('Role not found')
+ return role
+ elif converter is discord.Game:
+ return discord.Game(name=argument)
+ elif converter is discord.Invite:
+ try:
+ return bot.get_invite(argument)
+ except:
+ raise BadArgument('Invite is invalid')
+
+ def transform(self, ctx, param):
+ required = param.default is param.empty
+ converter = param.annotation
+ view = ctx.view
+
+ if converter is param.empty:
+ if not required:
+ converter = type(param.default)
+ else:
+ converter = str
+ elif not inspect.isclass(type(converter)):
+ raise discord.ClientException('Function annotation must be a type')
+
+ view.skip_ws()
+
+ if view.eof:
+ if param.kind == param.VAR_POSITIONAL:
+ raise StopIteration() # break the loop
+ if required:
+ raise MissingRequiredArgument('{0.name} is a required argument that is missing.'.format(param))
+ return param.default
+
+ argument = quoted_word(view)
+
+ try:
+ return self.do_conversion(ctx.bot, ctx.message, converter, argument)
+ except CommandError as e:
+ raise e
+ except Exception:
+ raise BadArgument('Converting to "{0.__name__}" failed.'.format(converter))
+
+ def _parse_arguments(self, ctx):
+ try:
+ ctx.args = []
+ ctx.kwargs = {}
+ args = ctx.args
+ kwargs = ctx.kwargs
+
+ first = True
+ view = ctx.view
+ for name, param in self.params.items():
+ if first and self.pass_context:
+ args.append(ctx)
+ first = False
+ continue
+
+ if param.kind == param.POSITIONAL_OR_KEYWORD:
+ args.append(self.transform(ctx, param))
+ elif param.kind == param.KEYWORD_ONLY:
+ # kwarg only param denotes "consume rest" semantics
+ kwargs[name] = view.read_rest()
+ break
+ elif param.kind == param.VAR_POSITIONAL:
+ while not view.eof:
+ try:
+ args.append(self.transform(ctx, param))
+ except StopIteration:
+ break
+ except CommandError as e:
+ ctx.bot.dispatch('command_error', e, ctx)
+ return False
+ return True
+
+ def _verify_checks(self, ctx):
+ predicates = self.checks
+ if predicates:
+ try:
+ check = all(predicate(ctx) for predicate in predicates)
+ if not check:
+ raise CheckFailure('The check functions for command {0.name} failed.'.format(self))
+ except CommandError as exc:
+ ctx.bot.dispatch('command_error', exc, ctx)
+ return False
+
+ return True
+
+ @asyncio.coroutine
+ def invoke(self, ctx):
+ if not self._verify_checks(ctx):
+ return
+
+ if self._parse_arguments(ctx):
+ yield from self.callback(*ctx.args, **ctx.kwargs)
+
+class GroupMixin:
+ """A mixin that implements common functionality for classes that behave
+ similar to :class:`Group` and are allowed to register commands.
+
+ Attributes
+ -----------
+ commands : dict
+ A mapping of command name to :class:`Command` or superclass
+ objects.
+ """
+ def __init__(self, **kwargs):
+ self.commands = {}
+ super().__init__(**kwargs)
+
+ def add_command(self, command):
+ """Adds a :class:`Command` or its superclasses into the internal list
+ of commands.
+
+ This is usually not called, instead the :meth:`command` or
+ :meth:`group` shortcut decorators are used instead.
+
+ Parameters
+ -----------
+ command
+ The command to add.
+
+ Raises
+ -------
+ discord.ClientException
+ If the command is already registered.
+ TypeError
+ If the command passed is not a subclass of :class:`Command`.
+ """
+
+ if not isinstance(command, Command):
+ raise TypeError('The command passed must be a subclass of Command')
+
+ if command.name in self.commands:
+ raise discord.ClientException('Command {0.name} is already registered.'.format(command))
+
+ self.commands[command.name] = command
+ for alias in command.aliases:
+ if alias in self.commands:
+ raise discord.ClientException('The alias {} is already an existing command or alias.'.format(alias))
+ self.commands[alias] = command
+
+ def command(self, *args, **kwargs):
+ """A shortcut decorator that invokes :func:`command` and adds it to
+ the internal command list via :meth:`add_command`.
+ """
+ def decorator(func):
+ result = command(*args, **kwargs)(func)
+ self.add_command(result)
+ return result
+
+ return decorator
+
+ def group(self, *args, **kwargs):
+ """A shortcut decorator that invokes :func:`group` and adds it to
+ the internal command list via :meth:`add_command`.
+ """
+ def decorator(func):
+ result = group(*args, **kwargs)(func)
+ self.add_command(result)
+ return result
+
+ return decorator
+
+class Group(GroupMixin, Command):
+ """A class that implements a grouping protocol for commands to be
+ executed as subcommands.
+
+ This class is a subclass of :class:`Command` and thus all options
+ valid in :class:`Command` are valid in here as well.
+ """
+ def __init__(self, **attrs):
+ super().__init__(**attrs)
+
+ @asyncio.coroutine
+ def invoke(self, ctx):
+ if not self._verify_checks(ctx):
+ return
+
+ if not self._parse_arguments(ctx):
+ return
+
+ view = ctx.view
+
+ view.skip_ws()
+ trigger = view.get_word()
+
+ if trigger:
+ ctx.subcommand_passed = trigger
+ if trigger in self.commands:
+ ctx.invoked_subcommand = self.commands[trigger]
+
+ yield from self.callback(*ctx.args, **ctx.kwargs)
+
+ if ctx.invoked_subcommand:
+ yield from ctx.invoked_subcommand.invoke(ctx)
+
+
+# Decorators
+
+def command(name=None, cls=None, **attrs):
+ """A decorator that transforms a function into a :class:`Command`.
+
+ By default the ``help`` attribute is received automatically from the
+ docstring of the function and is cleaned up with the use of
+ ``inspect.cleandoc``. If the docstring is ``bytes``, then it is decoded
+ into ``str`` using utf-8 encoding.
+
+ All checks added using the :func:`check` & co. decorators are added into
+ the function. There is no way to supply your own checks through this
+ decorator.
+
+ Parameters
+ -----------
+ name : str
+ The name to create the command with. By default this uses the
+ function named unchanged.
+ cls
+ The class to construct with. By default this is :class:`Command`.
+ You usually do not change this.
+ attrs
+ Keyword arguments to pass into the construction of :class:`Command`.
+
+ Raises
+ -------
+ TypeError
+ If the function is not a coroutine or is already a command.
+ """
+ if cls is None:
+ cls = Command
+
+ def decorator(func):
+ if isinstance(func, Command):
+ raise TypeError('Callback is already a command.')
+ if not asyncio.iscoroutinefunction(func):
+ raise TypeError('Callback must be a coroutine.')
+
+ try:
+ checks = func.__commands_checks__
+ checks.reverse()
+ del func.__commands_checks__
+ except AttributeError:
+ checks = []
+
+ help_doc = attrs.get('help')
+ if help_doc is not None:
+ help_doc = inspect.cleandoc(help_doc)
+ else:
+ help_doc = inspect.getdoc(func)
+ if isinstance(help_doc, bytes):
+ help_doc = help_doc.decode('utf-8')
+
+ attrs['help'] = help_doc
+ fname = name or func.__name__.lower()
+ return cls(name=fname, callback=func, checks=checks, **attrs)
+
+ return decorator
+
+def group(name=None, **attrs):
+ """A decorator that transforms a function into a :class:`Group`.
+
+ This is similar to the :func:`command` decorator but creates a
+ :class:`Group` instead of a :class:`Command`.
+ """
+ return command(name=name, cls=Group, **attrs)
+
+def check(predicate):
+ """A decorator that adds a check to the :class:`Command` or its
+ subclasses. These checks could be accessed via :attr:`Command.checks`.
+
+ These checks should be predicates that take in a single parameter taking
+ a :class:`Context`. If the check returns a ``False``\-like value then
+ during invocation a :exc:`CheckFailure` exception is raised and sent to
+ the :func:`on_command_error` event.
+
+ If an exception should be thrown in the predicate then it should be a
+ subclass of :exc:`CommandError`. Any exception not subclassed from it
+ will be propagated while those subclassed will be sent to
+ :func:`on_command_error`.
+
+ Parameters
+ -----------
+ predicate
+ The predicate to check if the command should be invoked.
+ """
+
+ def decorator(func):
+ if isinstance(func, Command):
+ func.checks.append(predicate)
+ else:
+ if not hasattr(func, '__commands_checks__'):
+ func.__commands_checks__ = []
+
+ func.__commands_checks__.append(predicate)
+
+ return func
+ return decorator
+
+def has_role(name):
+ """A :func:`check` that is added that checks if the member invoking the
+ command has the role specified via the name specified.
+
+ The name is case sensitive and must be exact. No normalisation is done in
+ the input.
+
+ If the message is invoked in a private message context then the check will
+ return ``False``.
+
+ Parameters
+ -----------
+ name : str
+ The name of the role to check.
+ """
+
+ def predicate(ctx):
+ msg = ctx.message
+ ch = msg.channel
+ if ch.is_private:
+ return False
+
+ role = discord.utils.get(msg.author.roles, name=name)
+ return role is not None
+
+ return check(predicate)
+
+def has_any_role(*names):
+ """A :func:`check` that is added that checks if the member invoking the
+ command has **any** of the roles specified. This means that if they have
+ one out of the three roles specified, then this check will return `True`.
+
+ Similar to :func:`has_role`\, the names passed in must be exact.
+
+ Parameters
+ -----------
+ names
+ An argument list of names to check that the member has roles wise.
+
+ Example
+ --------
+
+ .. code-block:: python
+
+ @bot.command()
+ @has_any_role('Library Devs', 'Moderators')
+ async def cool():
+ await bot.say('You are cool indeed')
+ """
+ def predicate(ctx):
+ msg = ctx.message
+ ch = msg.channel
+ if ch.is_private:
+ return False
+
+ getter = partial(discord.utils.get, msg.author.roles)
+ return any(getter(name=name) is not None for name in names)
+ return check(predicate)
+
+def has_permissions(**perms):
+ """A :func:`check` that is added that checks if the member has any of
+ the permissions necessary.
+
+ The permissions passed in must be exactly like the properties shown under
+ :class:`discord.Permissions`.
+
+ Parameters
+ ------------
+ perms
+ An argument list of permissions to check for.
+
+ Example
+ ---------
+
+ .. code-block:: python
+
+ @bot.command()
+ @has_permissions(manage_messages=True)
+ async def test():
+ await bot.say('You can manage messages.')
+
+ """
+ def predicate(ctx):
+ msg = ctx.message
+ ch = msg.channel
+ me = msg.server.me if not ch.is_private else ctx.bot.user
+ permissions = ch.permissions_for(me)
+ return all(getattr(permissions, perm, None) == value for perm, value in perms.items())
+
+ return check(predicate)
diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py
new file mode 100644
index 00000000..01bd5035
--- /dev/null
+++ b/discord/ext/commands/errors.py
@@ -0,0 +1,63 @@
+# -*- 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.
+"""
+
+from discord.errors import DiscordException
+
+
+__all__ = [ 'CommandError', 'MissingRequiredArgument', 'BadArgument',
+ 'NoPrivateMessage', 'CheckFailure' ]
+
+class CommandError(DiscordException):
+ """The base exception type for all command related errors.
+
+ This inherits from :exc:`discord.DiscordException`.
+
+ This exception and exceptions derived from it are handled
+ in a special way as they are caught and passed into a special event
+ from :class:`Bot`\, :func:`on_command_error`.
+ """
+ pass
+
+class MissingRequiredArgument(CommandError):
+ """Exception raised when parsing a command and a parameter
+ that is required is not encountered.
+ """
+ pass
+
+class BadArgument(CommandError):
+ """Exception raised when a parsing or conversion failure is encountered
+ on an argument to pass into a command.
+ """
+ pass
+
+class NoPrivateMessage(CommandError):
+ """Exception raised when an operation does not work in private message
+ contexts.
+ """
+ pass
+
+class CheckFailure(CommandError):
+ """Exception raised when the predicates in :attr:`Command.checks` have failed."""
+ pass
diff --git a/discord/ext/commands/view.py b/discord/ext/commands/view.py
new file mode 100644
index 00000000..c1a19ba0
--- /dev/null
+++ b/discord/ext/commands/view.py
@@ -0,0 +1,167 @@
+# -*- 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.
+"""
+
+from .errors import BadArgument
+
+class StringView:
+ def __init__(self, buffer):
+ self.index = 0
+ self.buffer = buffer
+ self.end = len(buffer)
+ self.previous = 0
+
+ @property
+ def current(self):
+ return None if self.eof else self.buffer[self.index]
+
+ @property
+ def eof(self):
+ return self.index >= self.end
+
+ def undo(self):
+ self.index = self.previous
+
+ def skip_ws(self):
+ pos = 0
+ while not self.eof:
+ try:
+ current = self.buffer[self.index + pos]
+ if not current.isspace():
+ break
+ pos += 1
+ except IndexError:
+ break
+
+ self.previous = self.index
+ self.index += pos
+ return self.previous != self.index
+
+ def skip_string(self, string):
+ strlen = len(string)
+ if self.buffer[self.index:self.index + strlen] == string:
+ self.previous = self.index
+ self.index += strlen
+ return True
+ return False
+
+ def read_rest(self):
+ result = self.buffer[self.index:]
+ self.previous = self.index
+ self.index = self.end
+ return result
+
+ def read(self, n):
+ result = self.buffer[self.index:self.index + n]
+ self.previous = self.index
+ self.index += n
+ return result
+
+ def get(self):
+ try:
+ result = self.buffer[self.index + 1]
+ except IndexError:
+ result = None
+
+ self.previous = self.index
+ self.index += 1
+ return result
+
+ def get_word(self):
+ pos = 0
+ while not self.eof:
+ try:
+ current = self.buffer[self.index + pos]
+ if current.isspace():
+ break
+ pos += 1
+ except IndexError:
+ break
+ self.previous = self.index
+ result = self.buffer[self.index:self.index + pos]
+ self.index += pos
+ return result
+
+ def __repr__(self):
+ return '<StringView pos: {0.index} prev: {0.previous} end: {0.end} eof: {0.eof}>'.format(self)
+
+# Parser
+
+def quoted_word(view):
+ current = view.current
+
+ if current is None:
+ return None
+
+ is_quoted = current == '"'
+ result = [] if is_quoted else [current]
+
+ while not view.eof:
+ current = view.get()
+ if not current:
+ if is_quoted:
+ # unexpected EOF
+ raise BadArgument('Expected closing "')
+ return ''.join(result)
+
+ # currently we accept strings in the format of "hello world"
+ # to embed a quote inside the string you must escape it: "a \"world\""
+ if current == '\\':
+ next_char = view.get()
+ if not next_char:
+ # string ends with \ and no character after it
+ if is_quoted:
+ # if we're quoted then we're expecting a closing quote
+ raise BadArgument('Expected closing "')
+ # if we aren't then we just let it through
+ return ''.join(result)
+
+ if next_char == '"':
+ # escaped quote
+ result.append('"')
+ else:
+ # different escape character, ignore it
+ view.undo()
+ result.append(current)
+ continue
+
+ # closing quote
+ if current == '"':
+ next_char = view.get()
+ valid_eof = not next_char or next_char.isspace()
+ if is_quoted:
+ if not valid_eof:
+ raise BadArgument('Expected space after closing quotation')
+
+ # we're quoted so it's okay
+ return ''.join(result)
+ else:
+ # we aren't quoted
+ raise BadArgument('Unexpected quote mark in non-quoted string')
+
+ if current.isspace() and not is_quoted:
+ # end of word found
+ return ''.join(result)
+
+ result.append(current)