mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-06-03 18:42:43 +00:00
Initial implementation of commands extension module.
This commit is contained in:
parent
97874293e2
commit
59ca5ec386
12
discord/ext/__init__.py
Normal file
12
discord/ext/__init__.py
Normal file
@ -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.
|
||||
|
||||
"""
|
16
discord/ext/commands/__init__.py
Normal file
16
discord/ext/commands/__init__.py
Normal file
@ -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 *
|
221
discord/ext/commands/bot.py
Normal file
221
discord/ext/commands/bot.py
Normal file
@ -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)
|
84
discord/ext/commands/context.py
Normal file
84
discord/ext/commands/context.py
Normal file
@ -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)
|
506
discord/ext/commands/core.py
Normal file
506
discord/ext/commands/core.py
Normal file
@ -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)
|
63
discord/ext/commands/errors.py
Normal file
63
discord/ext/commands/errors.py
Normal file
@ -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
|
167
discord/ext/commands/view.py
Normal file
167
discord/ext/commands/view.py
Normal file
@ -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)
|
Loading…
x
Reference in New Issue
Block a user