From 7177ddafd27d8a0f54f7b5b0bd8175f23a346ee8 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Dec 2015 17:10:39 -0500 Subject: [PATCH] Add member management functions. --- discord/client.py | 479 +++++++++++++++++++++++++++++++++++----------- 1 file changed, 365 insertions(+), 114 deletions(-) diff --git a/discord/client.py b/discord/client.py index f036f6785..4c16147c7 100644 --- a/discord/client.py +++ b/discord/client.py @@ -106,6 +106,8 @@ class Client: } self._closed = False + # internals + def _resolve_mentions(self, content, mentions): if isinstance(mentions, list): return [user.id for user in mentions] @@ -141,7 +143,6 @@ class Client: 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) @@ -149,7 +150,6 @@ class Client: 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'): @@ -157,16 +157,6 @@ class Client: 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) @@ -193,6 +183,103 @@ class Client: if hasattr(self, method): utils.create_task(self._run_event(method, *args, **kwargs), loop=self.loop) + @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') + + # properties + + @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 + + # helpers/getters + 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) @@ -231,18 +318,7 @@ class Client: 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 + # login state management @asyncio.coroutine def login(self, email, password): @@ -289,87 +365,14 @@ class Client: 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): + def logout(self): """|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') + 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 connect(self): @@ -396,6 +399,21 @@ class Client: self.received_message(json.loads(msg)) + @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 + + # event registration + def event(self, coro): """A decorator that registers an event to listen to. @@ -435,6 +453,8 @@ class Client: return self.event(coro) + # Message sending/management + @asyncio.coroutine def start_private_message(self, user): """|coro| @@ -674,7 +694,7 @@ class Client: yield from utils._verify_successful_response(response) @asyncio.coroutine - def edit_message(self, message, new_content, mentions=True): + def edit_message(self, message, new_content, *, mentions=True): """|coro| Edits a :class:`Message` with the new message content. @@ -717,16 +737,6 @@ class Client: 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| @@ -789,3 +799,244 @@ class Client: yield from utils._verify_successful_response(response) messages = yield from response.json() return generator_wrapper(messages) + + # Member management + + @asyncio.coroutine + def kick(self, member): + """|coro| + + Kicks a :class:`Member` from the server they belong to. + + Warning + -------- + This function kicks the :class:`Member` based on the server it + belongs to, which is accessed via :attr:`Member.server`. So you + must have the proper permissions in that server. + + Parameters + ----------- + member : :class:`Member` + The member to kick from their server. + + Raises + ------- + Forbidden + You do not have the proper permissions to kick. + HTTPException + Kicking failed. + """ + + url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) + 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 ban(self, member): + """|coro| + + Bans a :class:`Member` from the server they belong to. + + Warning + -------- + This function bans the :class:`Member` based on the server it + belongs to, which is accessed via :attr:`Member.server`. So you + must have the proper permissions in that server. + + Parameters + ----------- + member : :class:`Member` + The member to ban from their server. + + Raises + ------- + Forbidden + You do not have the proper permissions to ban. + HTTPException + Banning failed. + """ + + url = '{0}/{1.server.id}/bans/{1.id}'.format(endpoints.SERVERS, member) + response = yield from self.session.put(url, headers=self.headers) + log.debug(request_logging_format.format(method='PUT', response=response)) + yield from utils._verify_successful_response(response) + + @asyncio.coroutine + def unban(self, member): + """|coro| + + Unbans a :class:`Member` from the server they belong to. + + Warning + -------- + This function unbans the :class:`Member` based on the server it + belongs to, which is accessed via :attr:`Member.server`. So you + must have the proper permissions in that server. + + Parameters + ----------- + member : :class:`Member` + The member to unban from their server. + + Raises + ------- + Forbidden + You do not have the proper permissions to unban. + HTTPException + Unbanning failed. + """ + + url = '{0}/{1.server.id}/bans/{1.id}'.format(endpoints.SERVERS, member) + 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 server_voice_state(self, member, *, mute=False, deafen=False): + """|coro| + + Server mutes or deafens a specific :class:`Member`. + + Warning + -------- + This function mutes or un-deafens the :class:`Member` based on the + server it belongs to, which is accessed via :attr:`Member.server`. + So you must have the proper permissions in that server. + + Parameters + ----------- + member : :class:`Member` + The member to unban from their server. + mute : bool + Indicates if the member should be server muted or un-muted. + deafen : bool + Indicates if the member should be server deafened or un-deafened. + + Raises + ------- + Forbidden + You do not have the proper permissions to deafen or mute. + HTTPException + The operation failed. + """ + + url = '{0}/{1.server.id}/members/{1.id}'.format(endpoints.SERVERS, member) + payload = { + 'mute': mute, + 'deaf': deafen + } + + 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) + + @asyncio.coroutine + def edit_profile(self, password, **fields): + """|coro| + + Edits the current profile of the client. + + All fields except ``password`` are optional. + + Note + ----- + 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. + + Parameters + ----------- + password : str + The current password for the client's account. + new_password : str + The new password you wish to change to. + email : str + The new email you wish to change to. + username :str + The new username you wish to change to. + avatar : bytes + A *bytes-like object* representing the image to upload. + + Raises + ------ + HTTPException + Editing your profile failed. + """ + + avatar_bytes = fields.get('avatar') + avatar = None + 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) + r = yield from self.session.patch(url, headers=self.headers, data=to_json(payload)) + log.debug(request_logging_format.format(method='PATCH', 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.token = data['token'] + self.email = data['email'] + self.headers['authorization'] = self.token + + @asyncio.coroutine + def change_status(self, game_id=None, idle=False): + """|coro| + + 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 + + Parameters + ---------- + game_id : Optional[int] + The game ID being played. None if no game is being played. + idle : bool + Indicates if the client should go idle. + + Raises + ------ + InvalidArgument + If the ``game_id`` parameter is convertible integer or None. + """ + + idle_since = None if idle == False else int(time.time() * 1000) + try: + game_id = None if game_id is None else int(game_id) + except: + raise InvalidArgument('game_id must be convertible to an integer or None') + + payload = { + 'op': 3, + 'd': { + 'game_id': game_id, + 'idle_since': idle_since + } + } + + sent = to_json(payload) + log.debug('Sending "{}" to change status'.format(sent)) + yield from self.ws.send(sent)