From 6fb2e35de315da0a394f97863cbdbb07521fa8a3 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Dec 2015 06:09:56 -0500 Subject: [PATCH] Move async_client.py to client.py --- discord/async_client.py | 791 ------------------ discord/client.py | 1743 ++++++++++++--------------------------- docs/api.rst | 2 +- 3 files changed, 531 insertions(+), 2005 deletions(-) delete mode 100644 discord/async_client.py diff --git a/discord/async_client.py b/discord/async_client.py deleted file mode 100644 index f036f6785..000000000 --- a/discord/async_client.py +++ /dev/null @@ -1,791 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015 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 . import endpoints -from .user import User -from .channel import Channel, PrivateChannel -from .server import Server -from .message import Message -from .invite import Invite -from .object import Object -from .errors import * -from .state import ConnectionState -from . import utils - -import asyncio -import aiohttp -import websockets - -import logging, traceback -import sys, time, re, json - -log = logging.getLogger(__name__) -request_logging_format = '{method} {response.url} has returned {response.status}' -request_success_log = '{response.url} with {json} received {data}' - -def to_json(obj): - return json.dumps(obj, separators=(',', ':'), ensure_ascii=True) - -class Client: - """Represents a client connection that connects to Discord. - This class is used to interact with the Discord WebSocket and API. - - A number of options can be passed to the :class:`Client`. - - .. _deque: https://docs.python.org/3.4/library/collections.html#collections.deque - .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html - - Parameters - ---------- - max_messages : Optional[int] - The maximum number of messages to store in :attr:`messages`. - This defaults to 5000. Passing in `None` or a value less than 100 - will use the default instead of the passed in value. - loop : Optional[event loop]. - The `event loop`_ to use for asynchronous operations. Defaults to ``None``, - in which case the default event loop is used via ``asyncio.get_event_loop()``. - - Attributes - ----------- - user : Optional[:class:`User`] - Represents the connected client. None if not logged in. - servers : list of :class:`Server` - The servers that the connected client is a member of. - private_channels : list of :class:`PrivateChannel` - The private channels that the connected client is participating on. - messages - A deque_ of :class:`Message` that the client has received from all - servers and private messages. The number of messages stored in this - deque is controlled by the ``max_messages`` parameter. - email - The email used to login. This is only set if login is successful, - otherwise it's None. - gateway - The websocket gateway the client is currently connected to. Could be None. - loop - The `event loop`_ that the client uses for HTTP requests and websocket operations. - - """ - def __init__(self, *, loop=None, **options): - self.ws = None - self.token = None - self.gateway = None - self.loop = asyncio.get_event_loop() if loop is None else loop - - max_messages = options.get('max_messages') - if max_messages is None or max_messages < 100: - max_messages = 5000 - - self.connection = ConnectionState(self.dispatch, max_messages) - self.session = aiohttp.ClientSession(loop=self.loop) - self.headers = { - 'content-type': 'application/json', - } - self._closed = False - - def _resolve_mentions(self, content, mentions): - if isinstance(mentions, list): - return [user.id for user in mentions] - elif mentions == True: - return re.findall(r'<@(\d+)>', content) - else: - return [] - - def _resolve_invite(self, invite): - if isinstance(invite, Invite) or isinstance(invite, Object): - return invite.id - else: - rx = r'(?:https?\:\/\/)?discord\.gg\/(.+)' - m = re.match(rx, invite) - if m: - return m.group(1) - return invite - - def _resolve_destination(self, destination): - if isinstance(destination, (Channel, PrivateChannel, Server)): - return destination.id - elif isinstance(destination, User): - found = utils.find(lambda pm: pm.user == destination, self.private_channels) - if found is None: - # Couldn't find the user, so start a PM with them first. - self.start_private_message(destination) - channel_id = self.private_channels[-1].id - return channel_id - else: - return found.id - elif isinstance(destination, Object): - return destination.id - else: - raise InvalidArgument('Destination must be Channel, PrivateChannel, User, or Object') - - # Compatibility shim - def __getattr__(self, name): - if name in ('user', 'email', 'servers', 'private_channels', 'messages'): - return getattr(self.connection, name) - else: - msg = "'{}' object has no attribute '{}'" - raise AttributeError(msg.format(self.__class__, name)) - - # Compatibility shim - def __setattr__(self, name, value): - if name in ('user', 'email', 'servers', 'private_channels', - 'messages'): - return setattr(self.connection, name, value) - else: - object.__setattr__(self, name, value) - - @property - def is_logged_in(self): - """bool: Indicates if the client has logged in successfully.""" - return self._is_logged_in - - @property - def is_closed(self): - """bool: Indicates if the websocket connection is closed.""" - return self._closed - - @asyncio.coroutine - def _get_gateway(self): - resp = yield from self.session.get(endpoints.GATEWAY, headers=self.headers) - if resp.status != 200: - raise GatewayNotFound() - data = yield from resp.json() - return data.get('url') - - @asyncio.coroutine - def _run_event(self, event, *args, **kwargs): - try: - yield from getattr(self, event)(*args, **kwargs) - except Exception as e: - yield from self.on_error(event, *args, **kwargs) - - def dispatch(self, event, *args, **kwargs): - log.debug('Dispatching event {}'.format(event)) - method = 'on_' + event - handler = 'handle_' + event - - if hasattr(self, handler): - getattr(self, handler)(*args, **kwargs) - - if hasattr(self, method): - utils.create_task(self._run_event(method, *args, **kwargs), loop=self.loop) - - def get_channel(self, id): - """Returns a :class:`Channel` or :class:`PrivateChannel` with the following ID. If not found, returns None.""" - return self.connection.get_channel(id) - - def get_all_channels(self): - """A generator that retrieves every :class:`Channel` the client can 'access'. - - This is equivalent to: :: - - for server in client.servers: - for channel in server.channels: - yield channel - - Note - ----- - Just because you receive a :class:`Channel` does not mean that - you can communicate in said channel. :meth:`Channel.permissions_for` should - be used for that. - """ - - for server in self.servers: - for channel in server.channels: - yield channel - - def get_all_members(self): - """Returns a generator with every :class:`Member` the client can see. - - This is equivalent to: :: - - for server in client.servers: - for member in server.members: - yield member - - """ - for server in self.servers: - for member in server.members: - yield member - - @asyncio.coroutine - def close(self): - """Closes the websocket connection. - - To reconnect the websocket connection, :meth:`connect` must be used. - """ - if self._closed: - return - - yield from self.ws.close() - self.keep_alive.cancel() - self._closed = True - - @asyncio.coroutine - def login(self, email, password): - """|coro| - - Logs in the client with the specified credentials. - - Parameters - ---------- - email : str - The email used to login. - password : str - The password used to login. - - Raises - ------ - LoginFailure - The wrong credentials are passed. - HTTPException - An unknown HTTP related error occurred, - usually when it isn't 200 or the known incorrect credentials - passing status code. - """ - payload = { - 'email': email, - 'password': password - } - - data = to_json(payload) - resp = yield from self.session.post(endpoints.LOGIN, data=data, headers=self.headers) - log.debug(request_logging_format.format(method='POST', response=resp)) - if resp.status == 400: - raise LoginFailure('Improper credentials have been passed.') - elif resp.status != 200: - data = yield from resp.json() - raise HTTPException(resp, data.get('message')) - - log.info('logging in returned status code {}'.format(resp.status)) - self.email = email - - body = yield from resp.json() - self.token = body['token'] - self.headers['authorization'] = self.token - self._is_logged_in = True - - @asyncio.coroutine - def keep_alive_handler(self, interval): - while not self._closed: - payload = { - 'op': 1, - 'd': int(time.time()) - } - - msg = 'Keeping websocket alive with timestamp {}' - log.debug(msg.format(payload['d'])) - yield from self.ws.send(to_json(payload)) - yield from asyncio.sleep(interval) - - @asyncio.coroutine - def on_error(self, event_method, *args, **kwargs): - """|coro| - - The default error handler provided by the client. - - By default this prints to ``sys.stderr`` however it could be - overridden to have a different implementation. - Check :func:`discord.on_error` for more details. - """ - print('Ignoring exception in {}'.format(event_method), file=sys.stderr) - traceback.print_exc() - - def received_message(self, msg): - log.debug('WebSocket Event: {}'.format(msg)) - self.dispatch('socket_response', msg) - - op = msg.get('op') - data = msg.get('d') - - if op != 0: - log.info('Unhandled op {}'.format(op)) - return - - event = msg.get('t') - - if event == 'READY': - interval = data['heartbeat_interval'] / 1000.0 - self.keep_alive = utils.create_task(self.keep_alive_handler(interval), loop=self.loop) - - if event in ('READY', 'MESSAGE_CREATE', 'MESSAGE_DELETE', - 'MESSAGE_UPDATE', 'PRESENCE_UPDATE', 'USER_UPDATE', - 'CHANNEL_DELETE', 'CHANNEL_UPDATE', 'CHANNEL_CREATE', - 'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', - 'GUILD_MEMBER_UPDATE', 'GUILD_CREATE', 'GUILD_DELETE', - 'GUILD_ROLE_CREATE', 'GUILD_ROLE_DELETE', 'TYPING_START', - 'GUILD_ROLE_UPDATE', 'VOICE_STATE_UPDATE'): - parser = 'parse_' + event.lower() - if hasattr(self.connection, parser): - getattr(self.connection, parser)(data) - else: - log.info("Unhandled event {}".format(event)) - - @asyncio.coroutine - def _make_websocket(self): - if not self.is_logged_in: - raise ClientException('You must be logged in to connect') - - self.gateway = yield from self._get_gateway() - self.ws = yield from websockets.connect(self.gateway) - self.ws.max_size = None - log.info('Created websocket connected to {0.gateway}'.format(self)) - payload = { - 'op': 2, - 'd': { - 'token': self.token, - 'properties': { - '$os': sys.platform, - '$browser': 'discord.py', - '$device': 'discord.py', - '$referrer': '', - '$referring_domain': '' - }, - 'v': 3 - } - } - - yield from self.ws.send(to_json(payload)) - log.info('sent the initial payload to create the websocket') - - @asyncio.coroutine - def connect(self): - """|coro| - - Creates a websocket connection and connects to the websocket listen - to messages from discord. - - This function is implemented using a while loop in the background. - If you need to run this event listening in another thread then - you should run it in an executor or schedule the coroutine to - be executed later using ``loop.create_task``. - - This function throws :exc:`ClientException` if called before - logging in via :meth:`login`. - """ - yield from self._make_websocket() - - while not self._closed: - msg = yield from self.ws.recv() - if msg is None: - yield from self.close() - break - - self.received_message(json.loads(msg)) - - def event(self, coro): - """A decorator that registers an event to listen to. - - You can find more info about the events on the :ref:`documentation below `. - - The events must be a |corourl|_, if not, :exc:`ClientException` is raised. - - Examples - --------- - - Using the basic :meth:`event` decorator: :: - - @client.event - @asyncio.coroutine - def on_ready(): - print('Ready!') - - Saving characters by using the :meth:`async_event` decorator: :: - - @client.async_event - def on_ready(): - print('Ready!') - - """ - - if not asyncio.iscoroutinefunction(coro): - raise ClientException('event registered must be a coroutine function') - - setattr(self, coro.__name__, coro) - log.info('{0.__name__} has successfully been registered as an event'.format(coro)) - return coro - - def async_event(self, coro): - """A shorthand decorator for ``asyncio.coroutine`` + :meth:`event`.""" - if not asyncio.iscoroutinefunction(coro): - coro = asyncio.coroutine(coro) - - return self.event(coro) - - @asyncio.coroutine - def start_private_message(self, user): - """|coro| - - Starts a private message with the user. This allows you to - :meth:`send_message` to the user. - - Note - ----- - This method should rarely be called as :meth:`send_message` - does it automatically for you. - - Parameters - ----------- - user : :class:`User` - The user to start the private message with. - - Raises - ------ - HTTPException - The request failed. - InvalidArgument - The user argument was not of :class:`User`. - """ - - if not isinstance(user, User): - raise InvalidArgument('user argument must be a User') - - payload = { - 'recipient_id': user.id - } - - url = '{}/{}/channels'.format(endpoints.USERS, self.user.id) - r = yield from self.session.post(url, data=to_json(payload), headers=self.headers) - log.debug(request_logging_format.format(method='POST', response=r)) - yield from utils._verify_successful_response(r) - data = yield from r.json() - log.debug(request_success_log.format(response=r, json=payload, data=data)) - self.private_channels.append(PrivateChannel(id=data['id'], user=user)) - - @asyncio.coroutine - def send_message(self, destination, content, *, mentions=True, tts=False): - """|coro| - - Sends a message to the destination given with the content given. - - The destination could be a :class:`Channel`, :class:`PrivateChannel` or :class:`Server`. - For convenience it could also be a :class:`User`. If it's a :class:`User` or :class:`PrivateChannel` - then it sends the message via private message, otherwise it sends the message to the channel. - If the destination is a :class:`Server` then it's equivalent to calling - :meth:`Server.get_default_channel` and sending it there. If it is a :class:`Object` - instance then it is assumed to be the destination ID. - - .. versionchanged:: 0.9.0 - ``str`` being allowed was removed and replaced with :class:`Object`. - - The content must be a type that can convert to a string through ``str(content)``. - - The mentions must be either an array of :class:`User` to mention or a boolean. If - ``mentions`` is ``True`` then all the users mentioned in the content are mentioned, otherwise - no one is mentioned. Note that to mention someone in the content, you should use :meth:`User.mention`. - - Parameters - ------------ - destination - The location to send the message. - content - The content of the message to send. - mentions - A list of :class:`User` to mention in the message or a boolean. Ignored for private messages. - tts : bool - Indicates if the message should be sent using text-to-speech. - - Raises - -------- - HTTPException - Sending the message failed. - Forbidden - You do not have the proper permissions to send the message. - NotFound - The destination was not found and hence is invalid. - InvalidArgument - The destination parameter is invalid. - - Returns - --------- - :class:`Message` - The message that was sent. - """ - - channel_id = self._resolve_destination(destination) - - content = str(content) - mentions = self._resolve_mentions(content, mentions) - - url = '{base}/{id}/messages'.format(base=endpoints.CHANNELS, id=channel_id) - payload = { - 'content': content, - 'mentions': mentions - } - - if tts: - payload['tts'] = True - - resp = yield from self.session.post(url, data=to_json(payload), headers=self.headers) - log.debug(request_logging_format.format(method='POST', response=resp)) - yield from utils._verify_successful_response(resp) - data = yield from resp.json() - log.debug(request_success_log.format(response=resp, json=payload, data=data)) - channel = self.get_channel(data.get('channel_id')) - message = Message(channel=channel, **data) - return message - - @asyncio.coroutine - def send_typing(self, destination): - """|coro| - - Send a *typing* status to the destination. - - *Typing* status will go away after 10 seconds, or after a message is sent. - - The destination parameter follows the same rules as :meth:`send_message`. - - Parameters - ---------- - destination - The location to send the typing update. - """ - - channel_id = self._resolve_destination(destination) - - url = '{base}/{id}/typing'.format(base=endpoints.CHANNELS, id=channel_id) - - response = yield from self.session.post(url, headers=self.headers) - log.debug(request_logging_format.format(method='POST', response=response)) - yield from utils._verify_successful_response(response) - - @asyncio.coroutine - def send_file(self, destination, fp, filename=None): - """|coro| - - Sends a message to the destination given with the file given. - - The destination parameter follows the same rules as :meth:`send_message`. - - The ``fp`` parameter should be either a string denoting the location for a - file or a *file-like object*. The *file-like object* passed is **not closed** - at the end of execution. You are responsible for closing it yourself. - - .. note:: - - If the file-like object passed is opened via ``open`` then the modes - 'rb' should be used. - - The ``filename`` parameter is the filename of the file. - If this is not given then it defaults to ``fp.name`` or if ``fp`` is a string - then the ``filename`` will default to the string given. You can overwrite - this value by passing this in. - - Parameters - ------------ - destination - The location to send the message. - fp - The *file-like object* or file path to send. - filename : str - The filename of the file. Defaults to ``fp.name`` if it's available. - - Raises - ------- - InvalidArgument - If ``fp.name`` is an invalid default for ``filename``. - HTTPException - Sending the file failed. - - Returns - -------- - :class:`Message` - The message sent. - """ - - channel_id = self._resolve_destination(destination) - - url = '{base}/{id}/messages'.format(base=endpoints.CHANNELS, id=channel_id) - - try: - # attempt to open the file and send the request - with open(fp, 'rb') as f: - files = { - 'file': (fp if filename is None else filename, f) - } - except TypeError: - # if we got a TypeError then this is probably a file-like object - fname = getattr(fp, 'name', None) if filename is None else filename - if fname is None: - raise InvalidArgument('file-like object has no name attribute and no filename was specified') - - files = { - 'file': (fname, fp) - } - - response = yield from self.session.post(url, files=files, headers=self.headers) - log.debug(request_logging_format.format(method='POST', response=response)) - yield from utils._verify_successful_response(response) - data = yield from response.json() - msg = 'POST {0.url} returned {0.status} with {1} response' - log.debug(msg.format(response, data)) - channel = self.get_channel(data.get('channel_id')) - message = Message(channel=channel, **data) - return message - - @asyncio.coroutine - def delete_message(self, message): - """|coro| - - Deletes a :class:`Message`. - - Your own messages could be deleted without any proper permissions. However to - delete other people's messages, you need the proper permissions to do so. - - Parameters - ----------- - message : :class:`Message` - The message to delete. - - Raises - ------ - Forbidden - You do not have proper permissions to delete the message. - HTTPException - Deleting the message failed. - """ - - url = '{}/{}/messages/{}'.format(endpoints.CHANNELS, message.channel.id, message.id) - response = yield from self.session.delete(url, headers=self.headers) - log.debug(request_logging_format.format(method='DELETE', response=response)) - yield from utils._verify_successful_response(response) - - @asyncio.coroutine - def edit_message(self, message, new_content, mentions=True): - """|coro| - - Edits a :class:`Message` with the new message content. - - The new_content must be able to be transformed into a string via ``str(new_content)``. - - Parameters - ----------- - message : :class:`Message` - The message to edit. - new_content - The new content to replace the message with. - mentions - The mentions for the user. Same as :meth:`send_message`. - - Raises - ------- - HTTPException - Editing the message failed. - - Returns - -------- - :class:`Message` - The new edited message. - """ - - channel = message.channel - content = str(new_content) - - url = '{}/{}/messages/{}'.format(endpoints.CHANNELS, channel.id, message.id) - payload = { - 'content': content, - 'mentions': self._resolve_mentions(content, mentions) - } - - response = yield from self.session.patch(url, headers=self.headers, data=to_json(payload)) - log.debug(request_logging_format.format(method='PATCH', response=response)) - yield from utils._verify_successful_response(response) - data = yield from response.json() - log.debug(request_success_log.format(response=response, json=payload, data=data)) - return Message(channel=channel, **data) - - @asyncio.coroutine - def logout(self): - """|coro| - - Logs out of Discord and closes all connections.""" - response = yield from self.session.post(endpoints.LOGOUT, headers=self.headers) - yield from self.close() - self._is_logged_in = False - log.debug(request_logging_format.format(method='POST', response=response)) - - @asyncio.coroutine - def logs_from(self, channel, limit=100, *, before=None, after=None): - """|coro| - - This coroutine returns a generator that obtains logs from a specified channel. - - Parameters - ----------- - channel : :class:`Channel` - The channel to obtain the logs from. - limit : int - The number of messages to retrieve. - before : :class:`Message` - The message before which all returned messages must be. - after : :class:`Message` - The message after which all returned messages must be. - - Raises - ------ - Forbidden - You do not have permissions to get channel logs. - NotFound - The channel you are requesting for doesn't exist. - HTTPException - The request to get logs failed. - - Yields - ------- - :class:`Message` - The message with the message data parsed. - - Examples - --------- - - Basic logging: :: - - logs = yield from client.logs_from(channel) - for message in logs: - if message.content.startswith('!hello'): - if message.author == client.user: - yield from client.edit_message(message, 'goodbye') - """ - - def generator_wrapper(data): - for message in data: - yield Message(channel=channel, **message) - - url = '{}/{}/messages'.format(endpoints.CHANNELS, channel.id) - params = { - 'limit': limit - } - - if before: - params['before'] = before.id - if after: - params['after'] = after.id - - response = yield from self.session.get(url, params=params, headers=self.headers) - log.debug(request_logging_format.format(method='GET', response=response)) - yield from utils._verify_successful_response(response) - messages = yield from response.json() - return generator_wrapper(messages) diff --git a/discord/client.py b/discord/client.py index 8e94971cb..f036f6785 100644 --- a/discord/client.py +++ b/discord/client.py @@ -24,443 +24,87 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from __future__ import print_function - from . import endpoints -from .errors import * from .user import User from .channel import Channel, PrivateChannel from .server import Server -from .member import Member -from .role import Role, Permissions from .message import Message -from . import utils from .invite import Invite from .object import Object +from .errors import * +from .state import ConnectionState +from . import utils + +import asyncio +import aiohttp +import websockets -import traceback -import requests -import json, re, time, copy -from collections import deque -import threading -from ws4py.client import WebSocketBaseClient -import sys -import logging -import itertools -import datetime -from base64 import b64encode +import logging, traceback +import sys, time, re, json log = logging.getLogger(__name__) -request_logging_format = '{response.request.method} {response.url} has returned {response.status_code}' +request_logging_format = '{method} {response.url} has returned {response.status}' request_success_log = '{response.url} with {json} received {data}' -class KeepAliveHandler(threading.Thread): - def __init__(self, seconds, socket, **kwargs): - threading.Thread.__init__(self, **kwargs) - self.seconds = seconds - self.socket = socket - self.stop = threading.Event() - - def run(self): - while not self.stop.wait(self.seconds): - payload = { - 'op': 1, - 'd': int(time.time()) - } - - msg = 'Keeping websocket alive with timestamp {0}' - log.debug(msg.format(payload['d'])) - self.socket.send(json.dumps(payload, separators=(',', ':'))) - -class WebSocket(WebSocketBaseClient): - def __init__(self, dispatch, url): - WebSocketBaseClient.__init__(self, url, - protocols=['http-only', 'chat']) - self.dispatch = dispatch - self.keep_alive = None - - def opened(self): - log.info('Opened at {}'.format(int(time.time()))) - self.dispatch('socket_opened') - - def closed(self, code, reason=None): - if self.keep_alive is not None: - self.keep_alive.stop.set() - log.info('Closed with {} ("{}") at {}'.format(code, reason, - int(time.time()))) - self.dispatch('socket_closed') - - def handshake_ok(self): - pass - - def send(self, payload, binary=False): - self.dispatch('socket_raw_send', payload, binary) - WebSocketBaseClient.send(self, payload, binary) - - def received_message(self, msg): - self.dispatch('socket_raw_receive', msg) - response = json.loads(str(msg)) - log.debug('WebSocket Event: {}'.format(response)) - self.dispatch('socket_response', response) - - op = response.get('op') - data = response.get('d') - - if op != 0: - log.info("Unhandled op {}".format(op)) - return # What about op 7? - - event = response.get('t') - - if event == 'READY': - interval = data['heartbeat_interval'] / 1000.0 - self.keep_alive = KeepAliveHandler(interval, self) - self.keep_alive.start() - - - if event in ('READY', 'MESSAGE_CREATE', 'MESSAGE_DELETE', - 'MESSAGE_UPDATE', 'PRESENCE_UPDATE', 'USER_UPDATE', - 'CHANNEL_DELETE', 'CHANNEL_UPDATE', 'CHANNEL_CREATE', - 'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', - 'GUILD_MEMBER_UPDATE', 'GUILD_CREATE', 'GUILD_DELETE', - 'GUILD_ROLE_CREATE', 'GUILD_ROLE_DELETE', 'TYPING_START', - 'GUILD_ROLE_UPDATE', 'VOICE_STATE_UPDATE'): - self.dispatch('socket_update', event, data) - - else: - log.info("Unhandled event {}".format(event)) - - -class ConnectionState(object): - def __init__(self, dispatch, **kwargs): - self.dispatch = dispatch - self.user = None - self.email = None - self.servers = [] - self.private_channels = [] - self.messages = deque([], maxlen=kwargs.get('max_length', 5000)) - - def _get_message(self, msg_id): - return utils.find(lambda m: m.id == msg_id, self.messages) - - def _get_server(self, guild_id): - return utils.find(lambda g: g.id == guild_id, self.servers) - - def _add_server(self, guild): - server = Server(**guild) - self.servers.append(server) - - def handle_ready(self, data): - self.user = User(**data['user']) - guilds = data.get('guilds') - - for guild in guilds: - if guild.get('unavailable', False): - continue - self._add_server(guild) - - for pm in data.get('private_channels'): - self.private_channels.append(PrivateChannel(id=pm['id'], - user=User(**pm['recipient']))) - - # we're all ready - self.dispatch('ready') - - def handle_message_create(self, data): - channel = self.get_channel(data.get('channel_id')) - message = Message(channel=channel, **data) - self.dispatch('message', message) - self.messages.append(message) - - def handle_message_delete(self, data): - channel = self.get_channel(data.get('channel_id')) - message_id = data.get('id') - found = self._get_message(message_id) - if found is not None: - self.dispatch('message_delete', found) - self.messages.remove(found) - - def handle_message_update(self, data): - older_message = self._get_message(data.get('id')) - if older_message is not None: - # create a copy of the new message - message = copy.copy(older_message) - # update the new update - for attr in data: - if attr == 'channel_id' or attr == 'author': - continue - value = data[attr] - if 'time' in attr: - setattr(message, attr, utils.parse_time(value)) - else: - setattr(message, attr, value) - self.dispatch('message_edit', older_message, message) - # update the older message - older_message = message - - def handle_presence_update(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - status = data.get('status') - user = data['user'] - member_id = user['id'] - member = utils.find(lambda m: m.id == member_id, server.members) - if member is not None: - old_member = copy.copy(member) - member.status = data.get('status') - member.game_id = data.get('game_id') - member.name = user.get('username', member.name) - member.avatar = user.get('avatar', member.avatar) - - # call the event now - self.dispatch('status', member, old_member.game_id, old_member.status) - self.dispatch('member_update', old_member, member) - - def handle_user_update(self, data): - self.user = User(**data) - - def handle_channel_delete(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - channel_id = data.get('id') - channel = utils.find(lambda c: c.id == channel_id, server.channels) - try: - server.channels.remove(channel) - except ValueError: - return - else: - self.dispatch('channel_delete', channel) - - def handle_channel_update(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - channel_id = data.get('id') - channel = utils.find(lambda c: c.id == channel_id, server.channels) - channel.update(server=server, **data) - self.dispatch('channel_update', channel) - - def handle_channel_create(self, data): - is_private = data.get('is_private', False) - channel = None - if is_private: - recipient = User(**data.get('recipient')) - pm_id = data.get('id') - channel = PrivateChannel(id=pm_id, user=recipient) - self.private_channels.append(channel) - else: - server = self._get_server(data.get('guild_id')) - if server is not None: - channel = Channel(server=server, **data) - server.channels.append(channel) - - self.dispatch('channel_create', channel) - - def handle_guild_member_add(self, data): - server = self._get_server(data.get('guild_id')) - member = Member(server=server, deaf=False, mute=False, **data) - server.members.append(member) - self.dispatch('member_join', member) - - def handle_guild_member_remove(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - user_id = data['user']['id'] - member = utils.find(lambda m: m.id == user_id, server.members) - try: - server.members.remove(member) - except ValueError: - return - else: - self.dispatch('member_remove', member) - - def handle_guild_member_update(self, data): - server = self._get_server(data.get('guild_id')) - user_id = data['user']['id'] - member = utils.find(lambda m: m.id == user_id, server.members) - if member is not None: - user = data['user'] - old_member = copy.copy(member) - member.name = user['username'] - member.discriminator = user['discriminator'] - member.avatar = user['avatar'] - member.roles = [] - # update the roles - for role in server.roles: - if role.id in data['roles']: - member.roles.append(role) - - self.dispatch('member_update', old_member, member) - - def handle_guild_create(self, data): - unavailable = data.get('unavailable') - if unavailable == False: - # GUILD_CREATE with unavailable in the response - # usually means that the server has become available - # and is therefore in the cache - server = self._get_server(data.get('id')) - if server is not None: - server.unavailable = False - self.dispatch('server_available', server) - return - - if unavailable == True: - # joined a server with unavailable == True so.. - return - - # if we're at this point then it was probably - # unavailable during the READY event and is now - # available, so it isn't in the cache... - - self._add_server(data) - self.dispatch('server_join', self.servers[-1]) - - def handle_guild_delete(self, data): - server = self._get_server(data.get('id')) - if data.get('unavailable', False) and server is not None: - # GUILD_DELETE with unavailable being True means that the - # server that was available is now currently unavailable - server.unavailable = True - self.dispatch('server_unavailable', server) - return - - try: - self.servers.remove(server) - except ValueError: - return - else: - self.dispatch('server_remove', server) - - def handle_guild_role_create(self, data): - server = self._get_server(data.get('guild_id')) - role_data = data.get('role', {}) - everyone = server.id == role_data.get('id') - role = Role(everyone=everyone, **role_data) - server.roles.append(role) - self.dispatch('server_role_create', server, role) - - def handle_guild_role_delete(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - role_id = data.get('role_id') - role = utils.find(lambda r: r.id == role_id, server.roles) - server.roles.remove(role) - self.dispatch('server_role_delete', server, role) - - def handle_guild_role_update(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - role_id = data['role']['id'] - role = utils.find(lambda r: r.id == role_id, server.roles) - role.update(**data['role']) - self.dispatch('server_role_update', role) - - def handle_voice_state_update(self, data): - server = self._get_server(data.get('guild_id')) - if server is not None: - updated_member = server._update_voice_state(data) - self.dispatch('voice_state_update', updated_member) - - def handle_typing_start(self, data): - channel = self.get_channel(data.get('channel_id')) - if channel is not None: - member = None - user_id = data.get('user_id') - is_private = getattr(channel, 'is_private', None) - if is_private == None: - return - - if is_private: - member = channel.user - else: - members = channel.server.members - member = utils.find(lambda m: m.id == user_id, members) - - if member is not None: - timestamp = datetime.datetime.utcfromtimestamp(data.get('timestamp')) - self.dispatch('typing', channel, member, timestamp) - - def get_channel(self, id): - if id is None: - return None - - for server in self.servers: - for channel in server.channels: - if channel.id == id: - return channel - - for pm in self.private_channels: - if pm.id == id: - return pm +def to_json(obj): + return json.dumps(obj, separators=(',', ':'), ensure_ascii=True) - -class Client(object): +class Client: """Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. - A number of options can be passed to the :class:`Client` via keyword arguments. - - :param int max_length: The maximum number of messages to store in :attr:`messages`. Defaults to 5000. - - Instance attributes: - - .. attribute:: user - - A :class:`User` that represents the connected client. None if not logged in. - .. attribute:: servers - - A list of :class:`Server` that the connected client has available. - .. attribute:: private_channels - - A list of :class:`PrivateChannel` that the connected client is participating on. - .. attribute:: messages - - A deque_ of :class:`Message` that the client has received from all servers and private messages. - .. attribute:: email - - The email used to login. This is only set if login is successful, otherwise it's None. + A number of options can be passed to the :class:`Client`. .. _deque: https://docs.python.org/3.4/library/collections.html#collections.deque - """ + .. _event loop: https://docs.python.org/3/library/asyncio-eventloops.html + + Parameters + ---------- + max_messages : Optional[int] + The maximum number of messages to store in :attr:`messages`. + This defaults to 5000. Passing in `None` or a value less than 100 + will use the default instead of the passed in value. + loop : Optional[event loop]. + The `event loop`_ to use for asynchronous operations. Defaults to ``None``, + in which case the default event loop is used via ``asyncio.get_event_loop()``. + + Attributes + ----------- + user : Optional[:class:`User`] + Represents the connected client. None if not logged in. + servers : list of :class:`Server` + The servers that the connected client is a member of. + private_channels : list of :class:`PrivateChannel` + The private channels that the connected client is participating on. + messages + A deque_ of :class:`Message` that the client has received from all + servers and private messages. The number of messages stored in this + deque is controlled by the ``max_messages`` parameter. + email + The email used to login. This is only set if login is successful, + otherwise it's None. + gateway + The websocket gateway the client is currently connected to. Could be None. + loop + The `event loop`_ that the client uses for HTTP requests and websocket operations. - def __init__(self, **kwargs): - self._is_logged_in = False - self._close = False - self.options = kwargs - self.connection = ConnectionState(self.dispatch, **kwargs) - self.dispatch_lock = threading.RLock() - self.token = '' - - # the actual headers for the request... - # we only override 'authorization' since the rest could use the defaults. + """ + def __init__(self, *, loop=None, **options): + self.ws = None + self.token = None + self.gateway = None + self.loop = asyncio.get_event_loop() if loop is None else loop + + max_messages = options.get('max_messages') + if max_messages is None or max_messages < 100: + max_messages = 5000 + + self.connection = ConnectionState(self.dispatch, max_messages) + self.session = aiohttp.ClientSession(loop=self.loop) self.headers = { - 'authorization': self.token, + 'content-type': 'application/json', } - - def _create_websocket(self, url, reconnect=False): - if url is None: - raise GatewayNotFound() - log.info('websocket gateway found') - self.ws = WebSocket(self.dispatch, url) - self.ws.connect() - log.info('websocket has connected') - - if reconnect == False: - second_payload = { - 'op': 2, - 'd': { - 'token': self.token, - 'properties': { - '$os': sys.platform, - '$browser': 'discord.py', - '$device': 'discord.py', - '$referrer': '', - '$referring_domain': '' - }, - 'v': 3 - } - } - - self.ws.send(json.dumps(second_payload, separators=(',', ':'))) + self._closed = False def _resolve_mentions(self, content, mentions): if isinstance(mentions, list): @@ -497,10 +141,6 @@ class Client(object): else: raise InvalidArgument('Destination must be Channel, PrivateChannel, User, or Object') - def on_error(self, event_method, *args, **kwargs): - print('Ignoring exception in {}'.format(event_method), file=sys.stderr) - traceback.print_exc() - # Compatibility shim def __getattr__(self, name): if name in ('user', 'email', 'servers', 'private_channels', 'messages'): @@ -517,84 +157,329 @@ class Client(object): else: object.__setattr__(self, name, value) + @property + def is_logged_in(self): + """bool: Indicates if the client has logged in successfully.""" + return self._is_logged_in + + @property + def is_closed(self): + """bool: Indicates if the websocket connection is closed.""" + return self._closed + + @asyncio.coroutine + def _get_gateway(self): + resp = yield from self.session.get(endpoints.GATEWAY, headers=self.headers) + if resp.status != 200: + raise GatewayNotFound() + data = yield from resp.json() + return data.get('url') + + @asyncio.coroutine + def _run_event(self, event, *args, **kwargs): + try: + yield from getattr(self, event)(*args, **kwargs) + except Exception as e: + yield from self.on_error(event, *args, **kwargs) + def dispatch(self, event, *args, **kwargs): - with self.dispatch_lock: - log.debug("Dispatching event {}".format(event)) - handle_method = '_'.join(('handle', event)) - event_method = '_'.join(('on', event)) - getattr(self, handle_method, utils._null_event)(*args, **kwargs) - try: - getattr(self, event_method, utils._null_event)(*args, **kwargs) - except Exception as e: - getattr(self, 'on_error')(event_method, *args, **kwargs) - - def handle_socket_update(self, event, data): - method = '_'.join(('handle', event.lower())) - getattr(self.connection, method)(data) - - def run(self): - """Runs the client and allows it to receive messages and events. - - This function can raise a :exc:`GatewayNotFound` exception while attempting - to reconnect. + log.debug('Dispatching event {}'.format(event)) + method = 'on_' + event + handler = 'handle_' + event - .. note:: + if hasattr(self, handler): + getattr(self, handler)(*args, **kwargs) + + if hasattr(self, method): + utils.create_task(self._run_event(method, *args, **kwargs), loop=self.loop) + + def get_channel(self, id): + """Returns a :class:`Channel` or :class:`PrivateChannel` with the following ID. If not found, returns None.""" + return self.connection.get_channel(id) + + def get_all_channels(self): + """A generator that retrieves every :class:`Channel` the client can 'access'. + + This is equivalent to: :: + + for server in client.servers: + for channel in server.channels: + yield channel - This function attempts to reconnect if the websocket got closed - without explicitly calling :meth:`logout`. When this reconnect is - triggered, the :func:`discord.on_ready` event is called again. + Note + ----- + Just because you receive a :class:`Channel` does not mean that + you can communicate in said channel. :meth:`Channel.permissions_for` should + be used for that. """ - log.info('Client is being run') - self.ws.run() - # The WebSocket is guaranteed to be terminated after ws.run(). - # Check if we wanted it to close and reconnect if not. - while not self._close: - gateway = requests.get(endpoints.GATEWAY, headers=self.headers) - if gateway.status_code != 200: - raise GatewayNotFound() - self.connection = ConnectionState(self.dispatch, **self.options) - self._create_websocket(gateway.json().get('url'), reconnect=False) - self.ws.run() + for server in self.servers: + for channel in server.channels: + yield channel - log.info('Client exiting') + def get_all_members(self): + """Returns a generator with every :class:`Member` the client can see. - @property - def is_logged_in(self): - """Returns True if the client is successfully logged in. False otherwise.""" - return self._is_logged_in + This is equivalent to: :: + + for server in client.servers: + for member in server.members: + yield member - def get_channel(self, id): - """Returns a :class:`Channel` or :class:`PrivateChannel` with the - following ID. If not found, returns None. """ - return self.connection.get_channel(id) + for server in self.servers: + for member in server.members: + yield member + + @asyncio.coroutine + def close(self): + """Closes the websocket connection. + + To reconnect the websocket connection, :meth:`connect` must be used. + """ + if self._closed: + return + + yield from self.ws.close() + self.keep_alive.cancel() + self._closed = True + + @asyncio.coroutine + def login(self, email, password): + """|coro| + + Logs in the client with the specified credentials. + + Parameters + ---------- + email : str + The email used to login. + password : str + The password used to login. + + Raises + ------ + LoginFailure + The wrong credentials are passed. + HTTPException + An unknown HTTP related error occurred, + usually when it isn't 200 or the known incorrect credentials + passing status code. + """ + payload = { + 'email': email, + 'password': password + } + + data = to_json(payload) + resp = yield from self.session.post(endpoints.LOGIN, data=data, headers=self.headers) + log.debug(request_logging_format.format(method='POST', response=resp)) + if resp.status == 400: + raise LoginFailure('Improper credentials have been passed.') + elif resp.status != 200: + data = yield from resp.json() + raise HTTPException(resp, data.get('message')) + + log.info('logging in returned status code {}'.format(resp.status)) + self.email = email + + body = yield from resp.json() + self.token = body['token'] + self.headers['authorization'] = self.token + self._is_logged_in = True + + @asyncio.coroutine + def keep_alive_handler(self, interval): + while not self._closed: + payload = { + 'op': 1, + 'd': int(time.time()) + } + + msg = 'Keeping websocket alive with timestamp {}' + log.debug(msg.format(payload['d'])) + yield from self.ws.send(to_json(payload)) + yield from asyncio.sleep(interval) + + @asyncio.coroutine + def on_error(self, event_method, *args, **kwargs): + """|coro| + + The default error handler provided by the client. + + By default this prints to ``sys.stderr`` however it could be + overridden to have a different implementation. + Check :func:`discord.on_error` for more details. + """ + print('Ignoring exception in {}'.format(event_method), file=sys.stderr) + traceback.print_exc() + + def received_message(self, msg): + log.debug('WebSocket Event: {}'.format(msg)) + self.dispatch('socket_response', msg) + + op = msg.get('op') + data = msg.get('d') + + if op != 0: + log.info('Unhandled op {}'.format(op)) + return + + event = msg.get('t') + + if event == 'READY': + interval = data['heartbeat_interval'] / 1000.0 + self.keep_alive = utils.create_task(self.keep_alive_handler(interval), loop=self.loop) + + if event in ('READY', 'MESSAGE_CREATE', 'MESSAGE_DELETE', + 'MESSAGE_UPDATE', 'PRESENCE_UPDATE', 'USER_UPDATE', + 'CHANNEL_DELETE', 'CHANNEL_UPDATE', 'CHANNEL_CREATE', + 'GUILD_MEMBER_ADD', 'GUILD_MEMBER_REMOVE', + 'GUILD_MEMBER_UPDATE', 'GUILD_CREATE', 'GUILD_DELETE', + 'GUILD_ROLE_CREATE', 'GUILD_ROLE_DELETE', 'TYPING_START', + 'GUILD_ROLE_UPDATE', 'VOICE_STATE_UPDATE'): + parser = 'parse_' + event.lower() + if hasattr(self.connection, parser): + getattr(self.connection, parser)(data) + else: + log.info("Unhandled event {}".format(event)) + + @asyncio.coroutine + def _make_websocket(self): + if not self.is_logged_in: + raise ClientException('You must be logged in to connect') + self.gateway = yield from self._get_gateway() + self.ws = yield from websockets.connect(self.gateway) + self.ws.max_size = None + log.info('Created websocket connected to {0.gateway}'.format(self)) + payload = { + 'op': 2, + 'd': { + 'token': self.token, + 'properties': { + '$os': sys.platform, + '$browser': 'discord.py', + '$device': 'discord.py', + '$referrer': '', + '$referring_domain': '' + }, + 'v': 3 + } + } + + yield from self.ws.send(to_json(payload)) + log.info('sent the initial payload to create the websocket') + + @asyncio.coroutine + def connect(self): + """|coro| + + Creates a websocket connection and connects to the websocket listen + to messages from discord. + + This function is implemented using a while loop in the background. + If you need to run this event listening in another thread then + you should run it in an executor or schedule the coroutine to + be executed later using ``loop.create_task``. + + This function throws :exc:`ClientException` if called before + logging in via :meth:`login`. + """ + yield from self._make_websocket() + + while not self._closed: + msg = yield from self.ws.recv() + if msg is None: + yield from self.close() + break + + self.received_message(json.loads(msg)) + + def event(self, coro): + """A decorator that registers an event to listen to. + + You can find more info about the events on the :ref:`documentation below `. + + The events must be a |corourl|_, if not, :exc:`ClientException` is raised. + + Examples + --------- + + Using the basic :meth:`event` decorator: :: + + @client.event + @asyncio.coroutine + def on_ready(): + print('Ready!') + + Saving characters by using the :meth:`async_event` decorator: :: + + @client.async_event + def on_ready(): + print('Ready!') + + """ + + if not asyncio.iscoroutinefunction(coro): + raise ClientException('event registered must be a coroutine function') + + setattr(self, coro.__name__, coro) + log.info('{0.__name__} has successfully been registered as an event'.format(coro)) + return coro + + def async_event(self, coro): + """A shorthand decorator for ``asyncio.coroutine`` + :meth:`event`.""" + if not asyncio.iscoroutinefunction(coro): + coro = asyncio.coroutine(coro) + + return self.event(coro) + + @asyncio.coroutine def start_private_message(self, user): - """Starts a private message with the user. This allows you to :meth:`send_message` to it. + """|coro| - Note that this method should rarely be called as :meth:`send_message` does it automatically. + Starts a private message with the user. This allows you to + :meth:`send_message` to the user. - This function raises :exc:`HTTPException` if the request failed. + Note + ----- + This method should rarely be called as :meth:`send_message` + does it automatically for you. - :param user: A :class:`User` to start the private message with. + Parameters + ----------- + user : :class:`User` + The user to start the private message with. + + Raises + ------ + HTTPException + The request failed. + InvalidArgument + The user argument was not of :class:`User`. """ + if not isinstance(user, User): - raise TypeError('user argument must be a User') + raise InvalidArgument('user argument must be a User') payload = { 'recipient_id': user.id } - r = requests.post('{}/{}/channels'.format(endpoints.USERS, self.user.id), json=payload, headers=self.headers) - log.debug(request_logging_format.format(response=r)) - utils._verify_successful_response(r) - data = r.json() + url = '{}/{}/channels'.format(endpoints.USERS, self.user.id) + r = yield from self.session.post(url, data=to_json(payload), headers=self.headers) + log.debug(request_logging_format.format(method='POST', response=r)) + yield from utils._verify_successful_response(r) + data = yield from r.json() log.debug(request_success_log.format(response=r, json=payload, data=data)) self.private_channels.append(PrivateChannel(id=data['id'], user=user)) - def send_message(self, destination, content, mentions=True, tts=False): - """Sends a message to the destination given with the content given. + @asyncio.coroutine + def send_message(self, destination, content, *, mentions=True, tts=False): + """|coro| + + Sends a message to the destination given with the content given. The destination could be a :class:`Channel`, :class:`PrivateChannel` or :class:`Server`. For convenience it could also be a :class:`User`. If it's a :class:`User` or :class:`PrivateChannel` @@ -612,14 +497,32 @@ class Client(object): ``mentions`` is ``True`` then all the users mentioned in the content are mentioned, otherwise no one is mentioned. Note that to mention someone in the content, you should use :meth:`User.mention`. - If the destination parameter is invalid, then this function raises :exc:`InvalidArgument`. - This function raises :exc:`HTTPException` if the request failed. - - :param destination: The location to send the message. - :param content: The content of the message to send. - :param mentions: A list of :class:`User` to mention in the message or a boolean. Ignored for private messages. - :param tts: If ``True``, sends tries to send the message using text-to-speech. - :return: The :class:`Message` sent. + Parameters + ------------ + destination + The location to send the message. + content + The content of the message to send. + mentions + A list of :class:`User` to mention in the message or a boolean. Ignored for private messages. + tts : bool + Indicates if the message should be sent using text-to-speech. + + Raises + -------- + HTTPException + Sending the message failed. + Forbidden + You do not have the proper permissions to send the message. + NotFound + The destination was not found and hence is invalid. + InvalidArgument + The destination parameter is invalid. + + Returns + --------- + :class:`Message` + The message that was sent. """ channel_id = self._resolve_destination(destination) @@ -636,35 +539,44 @@ class Client(object): if tts: payload['tts'] = True - response = requests.post(url, json=payload, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - data = response.json() - log.debug(request_success_log.format(response=response, json=payload, data=data)) + resp = yield from self.session.post(url, data=to_json(payload), headers=self.headers) + log.debug(request_logging_format.format(method='POST', response=resp)) + yield from utils._verify_successful_response(resp) + data = yield from resp.json() + log.debug(request_success_log.format(response=resp, json=payload, data=data)) channel = self.get_channel(data.get('channel_id')) message = Message(channel=channel, **data) return message + @asyncio.coroutine def send_typing(self, destination): - """Send a "typing" status to the destination. + """|coro| + + Send a *typing* status to the destination. - "Typing" status will go away after 10 seconds, or after a message is sent. + *Typing* status will go away after 10 seconds, or after a message is sent. The destination parameter follows the same rules as :meth:`send_message`. - :param destination: The location to send the typing update. + Parameters + ---------- + destination + The location to send the typing update. """ channel_id = self._resolve_destination(destination) url = '{base}/{id}/typing'.format(base=endpoints.CHANNELS, id=channel_id) - response = requests.post(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) + response = yield from self.session.post(url, headers=self.headers) + log.debug(request_logging_format.format(method='POST', response=response)) + yield from utils._verify_successful_response(response) + @asyncio.coroutine def send_file(self, destination, fp, filename=None): - """Sends a message to the destination given with the file given. + """|coro| + + Sends a message to the destination given with the file given. The destination parameter follows the same rules as :meth:`send_message`. @@ -682,21 +594,31 @@ class Client(object): then the ``filename`` will default to the string given. You can overwrite this value by passing this in. - Note that this requires proper permissions in order to work. - This function raises :exc:`HTTPException` if the request failed. - It also raises :exc:`InvalidArgument` if ``fp.name`` is an invalid - default for ``filename``. + Parameters + ------------ + destination + The location to send the message. + fp + The *file-like object* or file path to send. + filename : str + The filename of the file. Defaults to ``fp.name`` if it's available. + + Raises + ------- + InvalidArgument + If ``fp.name`` is an invalid default for ``filename``. + HTTPException + Sending the file failed. - :param destination: The location to send the message. - :param fp: The *file-like object* or file path to send. - :param filename: The filename of the file. Defaults to ``fp.name`` if it's available. - :return: The :class:`Message` sent. + Returns + -------- + :class:`Message` + The message sent. """ channel_id = self._resolve_destination(destination) url = '{base}/{id}/messages'.format(base=endpoints.CHANNELS, id=channel_id) - response = None try: # attempt to open the file and send the request @@ -704,7 +626,6 @@ class Client(object): files = { 'file': (fp if filename is None else filename, f) } - response = requests.post(url, files=files, headers=self.headers) except TypeError: # if we got a TypeError then this is probably a file-like object fname = getattr(fp, 'name', None) if filename is None else filename @@ -714,43 +635,70 @@ class Client(object): files = { 'file': (fname, fp) } - response = requests.post(url, files=files, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - data = response.json() - log.debug(request_success_log.format(response=response, json=response.text, data=filename)) + response = yield from self.session.post(url, files=files, headers=self.headers) + log.debug(request_logging_format.format(method='POST', response=response)) + yield from utils._verify_successful_response(response) + data = yield from response.json() + msg = 'POST {0.url} returned {0.status} with {1} response' + log.debug(msg.format(response, data)) channel = self.get_channel(data.get('channel_id')) message = Message(channel=channel, **data) return message + @asyncio.coroutine def delete_message(self, message): - """Deletes a :class:`Message`. + """|coro| + + Deletes a :class:`Message`. Your own messages could be deleted without any proper permissions. However to delete other people's messages, you need the proper permissions to do so. - This function raises :exc:`HTTPException` if the request failed. + Parameters + ----------- + message : :class:`Message` + The message to delete. - :param message: The :class:`Message` to delete. + Raises + ------ + Forbidden + You do not have proper permissions to delete the message. + HTTPException + Deleting the message failed. """ url = '{}/{}/messages/{}'.format(endpoints.CHANNELS, message.channel.id, message.id) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) + response = yield from self.session.delete(url, headers=self.headers) + log.debug(request_logging_format.format(method='DELETE', response=response)) + yield from utils._verify_successful_response(response) + @asyncio.coroutine def edit_message(self, message, new_content, mentions=True): - """Edits a :class:`Message` with the new message content. + """|coro| + + Edits a :class:`Message` with the new message content. The new_content must be able to be transformed into a string via ``str(new_content)``. - This function raises :exc:`HTTPException` if the request failed. + Parameters + ----------- + message : :class:`Message` + The message to edit. + new_content + The new content to replace the message with. + mentions + The mentions for the user. Same as :meth:`send_message`. - :param message: The :class:`Message` to edit. - :param new_content: The new content to replace the message with. - :param mentions: The mentions for the user. Same as :meth:`send_message`. - :return: The new edited message. + Raises + ------- + HTTPException + Editing the message failed. + + Returns + -------- + :class:`Message` + The new edited message. """ channel = message.channel @@ -762,713 +710,82 @@ class Client(object): 'mentions': self._resolve_mentions(content, mentions) } - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - data = response.json() + response = yield from self.session.patch(url, headers=self.headers, data=to_json(payload)) + log.debug(request_logging_format.format(method='PATCH', response=response)) + yield from utils._verify_successful_response(response) + data = yield from response.json() log.debug(request_success_log.format(response=response, json=payload, data=data)) return Message(channel=channel, **data) - def login(self, email, password): - """Logs in the user with the following credentials and initialises - the connection to Discord. - - After this function is called, :attr:`is_logged_in` returns True if no - errors occur. If an error occurs during the login process, then - :exc:`LoginFailure` or :exc:`HTTPException` is raised. - - This function raises :exc:`GatewayNotFound` if it was unavailable to connect - to a websocket gateway. - - :param str email: The email used to login. - :param str password: The password used to login. - """ - - payload = { - 'email': email, - 'password': password - } - - r = requests.post(endpoints.LOGIN, json=payload) - log.debug(request_logging_format.format(response=r)) - if r.status_code == 400: - raise LoginFailure('Improper credentials have been passed.') - elif r.status_code != 200: - raise HTTPException(r) - - log.info('logging in returned status code {}'.format(r.status_code)) - self.email = email - - body = r.json() - self.token = body['token'] - self.headers['authorization'] = self.token - - gateway = requests.get(endpoints.GATEWAY, headers=self.headers) - self._create_websocket(gateway.json().get('url'), reconnect=False) - self._is_logged_in = True - - def register(self, username, invite, fingerprint=None): - """Register a new unclaimed account using an invite to a server. - - After this function is called, the client will be logged in to the - user created and :attr:`is_logged_in` returns True if no errors - occur. - - This function raises :exc:`GatewayNotFound` if the gateway to - connect the websocket is not found. It also raises :exc:`HTTPException` - if the request failed. - - :param str username: The username to register as. - :param invite: An invite URL, ID, or :class:`Invite` to register with. - :param str fingerprint: Unknown API parameter, defaults to None - """ - - payload = { - 'fingerprint': fingerprint, - 'username': username, - 'invite': self._resolve_invite(invite) - } - - r = requests.post(endpoints.REGISTER, json=payload) - log.debug(request_logging_format.format(response=r)) - - utils._verify_successful_response(r) - log.info('register returned a successful status code') - self.email = '' - - body = r.json() - self.token = body['token'] - self.headers['authorization'] = self.token - - gateway = requests.get(endpoints.GATEWAY, headers=self.headers) - if gateway.status_code != 200: - raise GatewayNotFound() - self._create_websocket(gateway.json().get('url'), reconnect=False) - self._is_logged_in = True - + @asyncio.coroutine def logout(self): - """Logs out of Discord and closes all connections.""" - response = requests.post(endpoints.LOGOUT) - self._close = True - self.ws.close() - self._is_logged_in = False - log.debug(request_logging_format.format(response=response)) - - def get_all_channels(self): - """Returns a generator with every :class:`Channel` the client can 'access'. - - This is equivalent to: :: - - for server in client.servers: - for channel in server.channels: - yield channel - - Note that just because you receive a :class:`Channel` does not mean that - you can communicate in said channel. :meth:`Channel.permissions_for` should - be used for that. - """ - - for server in self.servers: - for channel in server.channels: - yield channel - - def get_all_members(self): - """Returns a generator with every :class:`Member` the client can see. - - This is equivalent to: :: - - for server in client.servers: - for member in server.members: - yield member - - """ - for server in self.servers: - for member in server.members: - yield member - - def logs_from(self, channel, limit=100, before=None, after=None): - """A generator that obtains logs from a specified channel. - - Yielding from the generator returns a :class:`Message` object with the message data. - - Will return the newest messages within the specified range, up to `limit` messages. + """|coro| - This function raises :exc:`HTTPException` if the request failed. - - Example: :: - - for message in client.logs_from(channel): + Logs out of Discord and closes all connections.""" + response = yield from self.session.post(endpoints.LOGOUT, headers=self.headers) + yield from self.close() + self._is_logged_in = False + log.debug(request_logging_format.format(method='POST', response=response)) + + @asyncio.coroutine + def logs_from(self, channel, limit=100, *, before=None, after=None): + """|coro| + + This coroutine returns a generator that obtains logs from a specified channel. + + Parameters + ----------- + channel : :class:`Channel` + The channel to obtain the logs from. + limit : int + The number of messages to retrieve. + before : :class:`Message` + The message before which all returned messages must be. + after : :class:`Message` + The message after which all returned messages must be. + + Raises + ------ + Forbidden + You do not have permissions to get channel logs. + NotFound + The channel you are requesting for doesn't exist. + HTTPException + The request to get logs failed. + + Yields + ------- + :class:`Message` + The message with the message data parsed. + + Examples + --------- + + Basic logging: :: + + logs = yield from client.logs_from(channel) + for message in logs: if message.content.startswith('!hello'): if message.author == client.user: - client.edit_message(message, 'goodbye') - - - :param channel: The :class:`Channel` to obtain the logs from. - :param limit: The number of messages to retrieve. - :param before: :class:`Message` before which all returned messages must be. - :param after: :class:`Message` after which all returned messages must be. + yield from client.edit_message(message, 'goodbye') """ + def generator_wrapper(data): + for message in data: + yield Message(channel=channel, **message) + url = '{}/{}/messages'.format(endpoints.CHANNELS, channel.id) params = { 'limit': limit } + if before: params['before'] = before.id if after: params['after'] = after.id - response = requests.get(url, params=params, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - messages = response.json() - for message in messages: - yield Message(channel=channel, **message) - - def event(self, function): - """A decorator that registers an event to listen to. - - You can find more info about the events on the :ref:`documentation below `. - - Example: :: - - @client.event - def on_ready(): - print('Ready!') - """ - - setattr(self, function.__name__, function) - log.info('{0.__name__} has successfully been registered as an event'.format(function)) - return function - - def delete_channel(self, channel): - """Deletes a channel. - - In order to delete the channel, the client must have the proper permissions - in the server the channel belongs to. - - This function raises :exc:`HTTPException` if the request failed. - - :param channel: The :class:`Channel` to delete. - """ - - url = '{}/{}'.format(endpoints.CHANNELS, channel.id) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def kick(self, server, user): - """Kicks a :class:`User` from their respective :class:`Server`. - - You must have the proper permissions to kick a user in the server. - - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` to kick the member from. - :param user: The :class:`User` to kick. - """ - - url = '{base}/{server}/members/{user}'.format(base=endpoints.SERVERS, server=server.id, user=user.id) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def ban(self, server, user): - """Bans a :class:`User` from their respective :class:`Server`. - - You must have the proper permissions to ban a user in the server. - - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` to ban the member from. - :param user: The :class:`User` to ban. - """ - - url = '{base}/{server}/bans/{user}'.format(base=endpoints.SERVERS, server=server.id, user=user.id) - response = requests.put(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def unban(self, server, user): - """Unbans a :class:`User` from their respective :class:`Server`. - - You must have the proper permissions to unban a user in the server. - - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` to unban the member from. - :param user: The :class:`User` to unban. - :returns: True if unban was successful, False otherwise. - """ - - url = '{base}/{server}/bans/{user}'.format(base=endpoints.SERVERS, server=server.id, user=user.id) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def edit_profile(self, password, **fields): - """Edits the current profile of the client. - - All fields except password are optional. - - This function raises :exc:`HTTPException` if the request failed. - - To upload an avatar, a *bytes-like object* must be passed in that - represents the image being uploaded. If this is done through a file - then the file must be opened via ``open('some_filename', 'rb')`` and - the *bytes-like object* is given through the use of ``fp.read()``. - - The only image formats supported for uploading is JPEG and PNG. - - :param password: The current password for the client's account. - :param new_password: The new password you wish to change to. - :param email: The new email you wish to change to. - :param username: The new username you wish to change to. - :param avatar: A *bytes-like object* representing the image to upload. - """ - - avatar_bytes = fields.get('avatar') - avatar = self.user.avatar - if avatar_bytes is not None: - fmt = 'data:{mime};base64,{data}' - mime = utils._get_mime_type_for_image(avatar_bytes) - b64 = b64encode(avatar_bytes).decode('ascii') - avatar = fmt.format(mime=mime, data=b64) - - payload = { - 'password': password, - 'new_password': fields.get('new_password'), - 'email': fields.get('email', self.email), - 'username': fields.get('username', self.user.name), - 'avatar': avatar - } - - url = '{0}/@me'.format(endpoints.USERS) - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - data = response.json() - log.debug(request_success_log.format(response=response, json=payload, data=data)) - self.token = data['token'] - self.email = data['email'] - self.headers['authorization'] = self.token - self.user = User(**data) - - def edit_channel(self, channel, **options): - """Edits a :class:`Channel`. - - You must have the proper permissions to edit the channel. - - References pointed to the channel will be updated with the new information. - - This function raises :exc:`HTTPException` if the request failed. - - :param channel: The :class:`Channel` to update. - :param name: The new channel name. - :param position: The new channel's position in the GUI. - :param topic: The new channel's topic. - """ - - url = '{0}/{1.id}'.format(endpoints.CHANNELS, channel) - payload = { - 'name': options.get('name', channel.name), - 'topic': options.get('topic', channel.topic), - 'position': options.get('position', channel.position) - } - - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - data = response.json() - log.debug(request_success_log.format(response=response, json=payload, data=data)) - channel.update(server=channel.server, **data) - - def create_channel(self, server, name, type='text'): - """Creates a :class:`Channel` in the specified :class:`Server`. - - Note that you need the proper permissions to create the channel. - - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` to create the channel in. - :param name: The channel's name. - :param type: The type of channel to create. 'text' or 'voice'. - :returns: The newly created :class:`Channel`. - """ - - payload = { - 'name': name, - 'type': type - } - - url = '{0}/{1.id}/channels'.format(endpoints.SERVERS, server) - response = requests.post(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - data = response.json() - log.debug(request_success_log.format(response=response, data=data, json=payload)) - channel = Channel(server=server, **data) - # We don't append it to server.channels because CHANNEL_CREATE handles it for us. - return channel - - def leave_server(self, server): - """Leaves a :class:`Server`. - - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` to leave. - """ - - url = '{0}/{1.id}'.format(endpoints.SERVERS, server) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def create_invite(self, destination, **options): - """Creates an invite for the destination which could be either a :class:`Server` or :class:`Channel`. - - This function raises :exc:`HTTPException` if the request failed. - - The available options are: - - :param destination: The :class:`Server` or :class:`Channel` to create the invite to. - :param max_age: How long the invite should last. If it's 0 then the invite doesn't expire. Defaults to 0. - :param max_uses: How many uses the invite could be used for. If it's 0 then there are unlimited uses. Defaults to 0. - :param temporary: A boolean to denote that the invite grants temporary membership (i.e. they get kicked after they disconnect). Defaults to False. - :param xkcd: A boolean to indicate if the invite URL is human readable. Defaults to False. - :returns: The :class:`Invite` if creation is successful. - """ - - payload = { - 'max_age': options.get('max_age', 0), - 'max_uses': options.get('max_uses', 0), - 'temporary': options.get('temporary', False), - 'xkcdpass': options.get('xkcd', False) - } - - url = '{0}/{1.id}/invites'.format(endpoints.CHANNELS, destination) - response = requests.post(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - - utils._verify_successful_response(response) - data = response.json() - log.debug(request_success_log.format(json=payload, response=response, data=data)) - data['server'] = self.connection._get_server(data['guild']['id']) - channel_id = data['channel']['id'] - data['channel'] = utils.find(lambda ch: ch.id == channel_id, data['server'].channels) - return Invite(**data) - - def get_invite(self, url): - """Returns a :class:`Invite` object from the discord.gg invite URL or ID. - - .. note:: - - If the invite is for a server you have not joined, the server and channel - attributes of the returned invite will be :class:`Object` with the names - patched in. - - """ - - destination = self._resolve_invite(url) - rurl = '{0}/invite/{1}'.format(endpoints.API_BASE, destination) - response = requests.get(rurl, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - data = response.json() - server = self.connection._get_server(data['guild']['id']) - if server is not None: - ch_id = data['channel']['id'] - channels = getattr(server, 'channels', []) - channel = utils.find(lambda c: c.id == ch_id, channels) - else: - server = Object(id=data['guild']['id']) - server.name = data['guild']['name'] - channel = Object(id=data['channel']['id']) - channel.name = data['channel']['name'] - data['server'] = server - data['channel'] = channel - return Invite(**data) - - def accept_invite(self, invite): - """Accepts an :class:`Invite`, URL or ID to an invite. - - The URL must be a discord.gg URL. e.g. "http://discord.gg/codehere". - An ID for the invite is just the "codehere" portion of the invite URL. - - This function raises :exc:`HTTPException` if the request failed. If - the invite is invalid, then :exc:`InvalidArgument` is raised. - - :param invite: The :class:`Invite` or URL to an invite to accept. - """ - - destination = self._resolve_invite(invite) - - if destination is None: - raise InvalidArgument('The invite ({}) is invalid.'.format(invite)) - - url = '{0}/invite/{1}'.format(endpoints.API_BASE, destination) - response = requests.post(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def edit_role(self, server, role, **fields): - """Edits the specified :class:`Role` for the entire :class:`Server`. - - This function raises :exc:`HTTPException` if the request failed. - - .. versionchanged:: 0.8.0 - Editing now uses keyword arguments instead of editing the :class:`Role` object directly. - - .. note:: - - At the moment, the Discord API allows you to set the colour to any - RGB value. This will change in the future so it is recommended that - you use the constants in the :class:`Colour` instead such as - :meth:`Colour.green`. - - :param server: The :class:`Server` the role belongs to. - :param role: The :class:`Role` to edit. - :param name: The new role name to change to. (optional) - :param permissions: The new :class:`Permissions` to change to. (optional) - :param colour: The new :class:`Colour` to change to. (optional) (aliased to color as well) - :param hoist: A boolean indicating if the role should be shown separately. (optional) - """ - - url = '{0}/{1.id}/roles/{2.id}'.format(endpoints.SERVERS, server, role) - color = fields.get('color') - if color is None: - color = fields.get('colour', role.colour) - - payload = { - 'name': fields.get('name', role.name), - 'permissions': fields.get('permissions', role.permissions).value, - 'color': color.value, - 'hoist': fields.get('hoist', role.hoist) - } - - response = requests.patch(url, json=payload, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - data = response.json() - log.debug(request_success_log.format(json=payload, response=response, data=data)) - role.update(**data) - - def delete_role(self, server, role): - """Deletes the specified :class:`Role` for the entire :class:`Server`. - - Works in a similar matter to :func:`edit_role`. - This function raises :exc:`HTTPException` if the request failed. - - :param server: The :class:`Server` the role belongs to. - :param role: The :class:`Role` to delete. - """ - - url = '{0}/{1.id}/roles/{2.id}'.format(endpoints.SERVERS, server, role) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def add_roles(self, member, *roles): - """Gives the specified :class:`Member` a number of :class:`Role` s. - - You must have the proper permissions to use this function. - This function raises :exc:`HTTPException` if the request failed. - - This method **appends** a role to a member. - - :param member: The :class:`Member` to give roles to. - :param roles: An argument list of :class:`Role` s to give the member. - """ - - url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) - new_roles = [role.id for role in itertools.chain(member.roles, roles)] - payload = { - 'roles': new_roles - } - - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def remove_roles(self, member, *roles): - """Removes the :class:`Role` s from the :class:`Member`. - - You must have the proper permissions to use this function. - This function raises :exc:`HTTPException` if the request failed. - - :param member: The :class:`Member` to remove roles from. - :param roles: An argument list of :class:`Role` s to remove from the member. - """ - - url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) - - new_roles = [role.id for role in member.roles] - for role in roles: - if role.id in new_roles: - new_roles.remove(role.id) - - payload = { - 'roles': new_roles - } - - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def replace_roles(self, member, *roles): - """Replaces the :class:`Member`'s roles. - - You must have the proper permissions to use this function. - - This function **replaces** all roles that the member has. - For example if the member has roles ``[a, b, c]`` and the - call is ``client.replace_roles(member, d, e, c)`` then - the member has the roles ``[d, e, c]``. - - This function raises :exc:`HTTPException` if the request failed. - - :param member: The :class:`Member` to replace roles for. - :param roles: An argument list of :class:`Role` s to replace with. - """ - - url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) - - payload = { - 'roles': [role.id for role in roles] - } - - response = requests.patch(url, headers=self.headers, json=payload) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - member.roles = list(roles) - - def create_role(self, server, **fields): - """Creates a :class:`Role`. - - The fields parameter is the same as :func:`edit_role`. - - This function raises :exc:`HTTPException` if the request failed. - - :return: The :class:`Role` that was created. - """ - - url = '{0}/{1.id}/roles'.format(endpoints.SERVERS, server) - response = requests.post(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - data = response.json() - everyone = server.id == data.get('id') - role = Role(everyone=everyone, **data) - - # we have to call edit because you can't pass a payload to the - # http request currently. - self.edit_role(server, role, **fields) - return role - - def set_channel_permissions(self, channel, target, allow=None, deny=None): - """Sets the channel specific permission overwrites for a target in the - specified :class:`Channel`. - - The ``target`` parameter should either be a :class:`Member` or a - :class:`Role` that belongs to the channel's server. - - You must have the proper permissions to do this. - - This function raises :exc:`HTTPException` if the request failed. - This function also raises :exc:`InvalidArgument` if invalid arguments are - passed to this function. - - Example code: :: - - allow = discord.Permissions.none() - deny = discord.Permissions.none() - allow.can_mention_everyone = True - deny.can_manage_messages = True - client.set_channel_permissions(message.channel, message.author, allow, deny) - - :param channel: The :class:`Channel` to give the specific permissions for. - :param target: The :class:`Member` or :class:`Role` to overwrite permissions for. - :param allow: A :class:`Permissions` object representing the permissions to explicitly allow. (optional) - :param deny: A :class:`Permissions` object representing the permissions to explicitly deny. (optional) - """ - - url = '{0}/{1.id}/permissions/{2.id}'.format(endpoints.CHANNELS, channel, target) - - allow = Permissions.none() if allow is None else allow - deny = Permissions.none() if deny is None else deny - - if not (isinstance(allow, Permissions) and isinstance(deny, Permissions)): - raise InvalidArgument('allow and deny parameters must be discord.Permissions') - - deny = deny.value - allow = allow.value - - payload = { - 'id': target.id, - 'allow': allow, - 'deny': deny - } - - if isinstance(target, Member): - payload['type'] = 'member' - elif isinstance(target, Role): - payload['type'] = 'role' - else: - raise InvalidArgument('target parameter must be either discord.Member or discord.Role') - - response = requests.put(url, json=payload, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def delete_channel_permissions(self, channel, target): - """Removes a channel specific permission overwrites for a target - in the specified :class:`Channel`. - - The target parameter follows the same rules as :meth:`set_channel_permissions`. - - You must have the proper permissions to do this. - This function raises :exc:`HTTPException` if the request failed. - - :param channel: The :class:`Channel` to give the specific permissions for. - :param target: The :class:`Member` or :class:`Role` to overwrite permissions for. - """ - - url = '{0}/{1.id}/permissions/{2.id}'.format(endpoints.CHANNELS, channel, target) - response = requests.delete(url, headers=self.headers) - log.debug(request_logging_format.format(response=response)) - utils._verify_successful_response(response) - - def change_status(self, game_id=None, idle=False): - """Changes the client's status. - - The game_id parameter is a numeric ID (not a string) that represents - a game being played currently. The list of game_id to actual games changes - constantly and would thus be out of date pretty quickly. An old version of - the game_id database can be seen `here`_ to help you get started. - - The idle parameter is a boolean parameter that indicates whether the - client should go idle or not. - - .. _here: https://gist.github.com/Rapptz/a82b82381b70a60c281b - - :param game_id: The numeric game ID being played. None if no game is being played. - :param idle: A boolean indicating if the client should go idle.""" - - idle_since = None if idle == False else int(time.time() * 1000) - payload = { - 'op': 3, - 'd': { - 'game_id': game_id, - 'idle_since': idle_since - } - } - - sent = json.dumps(payload) - log.debug('Sending "{}" to change status'.format(sent)) - self.ws.send(sent) + response = yield from self.session.get(url, params=params, headers=self.headers) + log.debug(request_logging_format.format(method='GET', response=response)) + yield from utils._verify_successful_response(response) + messages = yield from response.json() + return generator_wrapper(messages) diff --git a/docs/api.rst b/docs/api.rst index cc2989038..59efac785 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -17,7 +17,7 @@ The following section outlines the API of discord.py. Client ------- -.. autoclass:: discord.async_client.Client +.. autoclass:: Client :members: .. _discord-api-events: