7 changed files with 1069 additions and 0 deletions
@ -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. |
|||
|
|||
""" |
@ -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 * |
@ -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) |
@ -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) |
@ -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) |
@ -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 |
@ -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…
Reference in new issue