diff options
| author | Rapptz <[email protected]> | 2015-08-21 18:18:34 -0400 |
|---|---|---|
| committer | Rapptz <[email protected]> | 2015-08-21 18:18:34 -0400 |
| commit | 3e0f09d32c03de0916329c3b131ed1d46672bc02 (patch) | |
| tree | a758fa3db70ecabc0e6e2e2435e5a9219a8876d7 /discord | |
| download | discord.py-3e0f09d32c03de0916329c3b131ed1d46672bc02.tar.xz discord.py-3e0f09d32c03de0916329c3b131ed1d46672bc02.zip | |
Initial commit
Diffstat (limited to 'discord')
| -rw-r--r-- | discord/__init__.py | 26 | ||||
| -rw-r--r-- | discord/channel.py | 79 | ||||
| -rw-r--r-- | discord/client.py | 280 | ||||
| -rw-r--r-- | discord/endpoints.py | 34 | ||||
| -rw-r--r-- | discord/errors.py | 33 | ||||
| -rw-r--r-- | discord/message.py | 89 | ||||
| -rw-r--r-- | discord/server.py | 75 | ||||
| -rw-r--r-- | discord/user.py | 73 |
8 files changed, 689 insertions, 0 deletions
diff --git a/discord/__init__.py b/discord/__init__.py new file mode 100644 index 00000000..d27da12e --- /dev/null +++ b/discord/__init__.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +""" +Discord API Wrapper +~~~~~~~~~~~~~~~~~~~ + +A basic wrapper for the Discord API. + +:copyright: (c) 2015 Rapptz +:license: MIT, see LICENSE for more details. + +""" + +__title__ = 'discord' +__author__ = 'Rapptz' +__license__ = 'MIT' +__copyright__ = 'Copyright 2015 Rapptz' +__version__ = '0.1.0' +__build__ = 0x001000 + +from client import Client +from user import User +from channel import Channel, PrivateChannel +from server import Server +from message import Message +from errors import * diff --git a/discord/channel.py b/discord/channel.py new file mode 100644 index 00000000..734ad64a --- /dev/null +++ b/discord/channel.py @@ -0,0 +1,79 @@ +# -*- 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. +""" + +class Channel(object): + """Represents a Discord server channel. + + Instance attributes: + + .. attribute:: name + + The channel name. + .. attribute:: server + + The :class:`Server` the channel belongs to. + .. attribute:: id + + The channel ID. + .. attribute:: is_private + + ``True`` if the channel is a private channel (i.e. PM). ``False`` in this case. + .. attribute:: position + + The position in the channel list. + .. attribute:: type + + The channel type. Usually ``'voice'`` or ``'text'``. + """ + + def __init__(self, name, server, id, position, type, **kwargs): + self.name = name + self.server = server + self.id = id + self.is_private = False + self.position = position + self.type = type + +class PrivateChannel(object): + """Represents a Discord private channel. + + Instance attributes: + + .. attribute:: user + + The :class:`User` in the private channel. + .. attribute:: id + + The private channel ID. + .. attribute:: is_private + + ``True`` if the channel is a private channel (i.e. PM). ``True`` in this case. + """ + + def __init__(self, user, id, **kwargs): + self.user = user + self.id = id + self.is_private = True + diff --git a/discord/client.py b/discord/client.py new file mode 100644 index 00000000..203eef26 --- /dev/null +++ b/discord/client.py @@ -0,0 +1,280 @@ +# -*- 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. +""" + +import requests +import json, re +import endpoints +from ws4py.client.threadedclient import WebSocketClient +# from threading import Timer +from sys import platform as sys_platform +from errors import InvalidEventName, InvalidDestination +from user import User +from channel import Channel, PrivateChannel +from server import Server +from message import Message + +def _null_event(*args, **kwargs): + pass + +class Client(object): + """Represents a client connection that connects to Discord. + This class is used to interact with the Discord WebSocket and API. + + .. 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. + """ + + def __init__(self, **kwargs): + self._is_logged_in = False + self.user = None + self.servers = [] + self.private_channels = [] + self.token = '' + self.events = { + 'on_ready': _null_event, + 'on_disconnect': _null_event, + 'on_error': _null_event, + 'on_response': _null_event, + 'on_message': _null_event + } + + self.ws = WebSocketClient(endpoints.WEBSOCKET_HUB, protocols=['http-only', 'chat']) + + # this is kind of hacky, but it's to avoid deadlocks. + # i.e. python does not allow me to have the current thread running if it's self + # it throws a 'cannot join current thread' RuntimeError + # So instead of doing a basic inheritance scheme, we're overriding the member functions. + + self.ws.opened = self._opened + self.ws.closed = self._closed + self.ws.received_message = self._received_message + self.ws.connect() + + # the actual headers for the request... + # we only override 'authorization' since the rest could use the defaults. + self.headers = { + 'authorization': self.token, + } + + def _received_message(self, msg): + response = json.loads(str(msg)) + if response.get('op') != 0: + return + + self.events['on_response'](response) + event = response.get('t') + data = response.get('d') + + if event == 'READY': + self.user = User(**data['user']) + guilds = data.get('guilds') + + for guild in guilds: + guild['roles'] = [role.get('name') for role in guild['roles']] + guild['members'] = [User(**member['user']) for member in guild['members']] + + self.servers.append(Server(**guild)) + channels = [Channel(server=self.servers[-1], **channel) for channel in guild['channels']] + self.servers[-1].channels = channels + + for pm in data.get('private_channels'): + self.private_channels.append(PrivateChannel(id=pm['id'], user=User(**pm['recipient']))) + + # set the keep alive interval.. + self.ws.heartbeat_freq = data.get('heartbeat_interval') + + # we're all ready + self.events['on_ready']() + elif event == 'MESSAGE_CREATE': + channel = self.get_channel(data.get('channel_id')) + message = Message(channel=channel, **data) + self.events['on_message'](message) + + def _opened(self): + print('Opened!') + + def _closed(self, code, reason=None): + print('closed with ', code, reason) + + def run(self): + """Runs the client and allows it to receive messages and events.""" + self.ws.run_forever() + + @property + def is_logged_in(self): + """Returns True if the client is successfully logged in. False otherwise.""" + return self._is_logged_in + + def get_channel(self, id): + """Returns a :class:`Channel` or :class:`PrivateChannel` with the following ID. If not found, returns None.""" + 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 start_private_message(self, user): + """Starts a private message with the user. This allows you to :meth:`send_message` to it. + + Note that this method should rarely be called as :meth:`send_message` does it automatically. + + :param user: A :class:`User` to start the private message with. + """ + if not isinstance(user, User): + raise TypeError('user argument must be a User') + + payload = { + 'recipient_id': user.id + } + + r = response.post('{}/{}/channels'.format(endpoints.USERS, self.user.id), json=payload, headers=self.headers) + if r.status_code == 200: + data = r.json() + self.private_channels.append(PrivateChannel(id=data['id'], user=user)) + + def send_message(self, destination, content, mentions=True): + """Sends a message to the destination given with the content given. + + The destination could be a :class:`Channel` or a :class:`PrivateChannel`. 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. + + 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`. + + :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. + """ + + channel_id = '' + is_private_message = True + if isinstance(destination, Channel) or isinstance(destination, PrivateChannel): + channel_id = destination.id + is_private_message = destination.is_private + elif isinstance(destination, User): + found = next((pm for pm in self.private_channels if pm.user == destination), None) + 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 + else: + channel_id = found.id + else: + raise InvalidDestination('Destination must be Channel, PrivateChannel, or User') + + content = str(content) + + if isinstance(mentions, list): + mentions = [user.id for user in mentions] + elif mentions == True: + mentions = re.findall(r'@<(\d+)>', content) + else: + mentions = [] + + url = '{base}/{id}/messages'.format(base=endpoints.CHANNELS, id=channel_id) + payload = { + 'content': content, + } + + if not is_private_message: + payload['mentions'] = mentions + + response = requests.post(url, json=payload, headers=self.headers) + + + def login(self, email, password): + """Logs in the user with the following credentials. + + After this function is called, :attr:`is_logged_in` returns True if no + errors occur. + + :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) + + if r.status_code == 200: + body = r.json() + self.token = body['token'] + self.headers['authorization'] = self.token + second_payload = { + 'op': 2, + 'd': { + 'token': self.token, + 'properties': { + '$os': sys_platform, + '$browser': 'pydiscord', + '$device': 'pydiscord', + '$referrer': '', + '$referring_domain': '' + } + } + } + + self.ws.send(json.dumps(second_payload)) + self._is_logged_in = True + + 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 <discord-api-events>`. + + Example: :: + + @client.event + def on_ready(): + print('Ready!') + """ + + if function.__name__ not in self.events: + raise InvalidEventName('The function name {} is not a valid event name'.format(function.__name__)) + + self.events[function.__name__] = function + return function + diff --git a/discord/endpoints.py b/discord/endpoints.py new file mode 100644 index 00000000..315e63d1 --- /dev/null +++ b/discord/endpoints.py @@ -0,0 +1,34 @@ +# -*- 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. +""" + +WEBSOCKET_HUB = 'wss://discordapp.com/hub' +BASE = 'https://discordapp.com' +API_BASE = BASE + '/api' +USERS = API_BASE + '/users' +LOGIN = API_BASE + '/auth/login' +LOGOUT = API_BASE + '/auth/logout' +SERVERS = API_BASE + '/guilds' +CHANNELS = API_BASE + '/channels' diff --git a/discord/errors.py b/discord/errors.py new file mode 100644 index 00000000..b0a6679c --- /dev/null +++ b/discord/errors.py @@ -0,0 +1,33 @@ +# -*- 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. +""" + +class InvalidEventName(Exception): + """Thrown when an event from :meth:`Client.event` has an invalid name.""" + pass + +class InvalidDestination(Exception): + """Thrown when the destination from :meth:`Client.send_message` is invalid.""" + pass diff --git a/discord/message.py b/discord/message.py new file mode 100644 index 00000000..a7773809 --- /dev/null +++ b/discord/message.py @@ -0,0 +1,89 @@ +# -*- 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. +""" + +import datetime +from user import User + +class Message(object): + """Represents a message from Discord. + + There should be no need to create one of these manually. + + Instance attributes: + + .. attribute:: edited_timestamp + + A datetime object containing the edited time of the message. Could be None. + .. attribute:: timestamp + + A datetime object containing the time the message was created. + .. attribute:: tts + + Checks the message has text-to-speech support. + .. attribute:: author + + A :class:`User` that sent the message. + .. attribute:: content + + The actual contents of the message. + .. attribute:: embeds + + An array of embedded objects. + .. attribute:: channel + + The :class:`Channel` that the message was sent from. Could be a :class:`PrivateChannel` if it's a private message. + .. attribute:: mention_everyone + + A boolean specifying if the message mentions everyone. + .. attribute:: mentions + + An array of :class:`User`s that were mentioned. + .. attribute:: id + + The message ID. + """ + + def __init__(self, edited_timestamp, timestamp, tts, content, mention_everyone, mentions, embeds, attachments, id, channel, author, **kwargs): + # at the moment, the timestamps seem to be naive so they have no time zone and operate on UTC time. + # we can use this to our advantage to use strptime instead of a complicated parsing routine. + # example timestamp: 2015-08-21T12:03:45.782000+00:00 + time_format = "%Y-%m-%dT%H:%M:%S.%f+00:00" + self.edited_timestamp = None + if edited_timestamp is not None: + self.edited_timestamp = datetime.datetime.strptime(edited_timestamp, time_format) + + self.timestamp = datetime.datetime.strptime(timestamp, time_format) + self.tts = tts + self.content = content + self.mention_everyone = mention_everyone + self.embeds = embeds + self.id = id + self.channel = channel + self.author = User(**author) + self.mentions = [User(**mention) for mention in mentions] + + + diff --git a/discord/server.py b/discord/server.py new file mode 100644 index 00000000..ae4d8164 --- /dev/null +++ b/discord/server.py @@ -0,0 +1,75 @@ +# -*- 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 user import User + +class Server(object): + """Represents a Discord server. + + Instance attributes: + + .. attribute:: name + + The server name. + .. attribute:: roles + + An array of role names. + .. attribute:: region + + The region the server belongs on. + .. attribute:: afk_timeout + + The timeout to get sent to the AFK channel. + .. attribute:: afk_channel_id + + The channel ID for the AFK channel. None if it doesn't exist. + .. attribute:: members + + An array of :class:`User` that are currently on the server. + .. attribute:: channels + + An array of :class:`Channel` that are currently on the server. + .. attribute:: icon + + The server's icon. + .. attribute:: id + + The server's ID. + .. attribute:: owner_id + + The ID of the server's owner. + """ + + def __init__(self, name, roles, region, afk_timeout, afk_channel_id, members, icon, id, owner_id, **kwargs): + self.name = name + self.roles = roles + self.region = region + self.afk_timeout = afk_timeout + self.afk_channel_id = afk_channel_id + self.members = members + self.icon = icon + self.id = id + self.owner_id = owner_id diff --git a/discord/user.py b/discord/user.py new file mode 100644 index 00000000..804850e1 --- /dev/null +++ b/discord/user.py @@ -0,0 +1,73 @@ +# -*- 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. +""" + +class User(object): + """Represents a Discord user. + + Instance attributes: + + .. attribute:: name + + The user's username. + .. attribute:: id + + The user's unique ID. + .. attribute:: discriminator + + The user's discriminator. This is given when the username has conflicts. + .. attribute:: avatar + + The avatar hash the user has. Could be None. + """ + + def __init__(self, username, id, discriminator, avatar, **kwargs): + self.name = username + self.id = id + self.discriminator = discriminator + self.avatar = avatar + + def __str__(self): + return self.name + + def __eq__(self, other): + return isinstance(other, User) and other.id == self.id + + def __ne__(self, other): + if isinstance(other, User): + return other.id != self.id + return False + + def avatar_url(self): + """Returns a friendly URL version of the avatar variable the user has. An empty string if + the user has no avatar.""" + if self.avatar is None: + return '' + return 'https://discordapp.com/api/users/{0.id}/avatars/{0.avatar}.jpg'.format(self) + + def mention(self): + """Returns a string that allows you to mention the given user.""" + return '<@{0.id}>'.format(self) + |