Initial commit

This commit is contained in:
Rapptz
2015-08-21 18:18:34 -04:00
commit 3e0f09d32c
18 changed files with 1597 additions and 0 deletions

26
discord/__init__.py Normal file
View File

@ -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 *

79
discord/channel.py Normal file
View File

@ -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

280
discord/client.py Normal file
View File

@ -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

34
discord/endpoints.py Normal file
View File

@ -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'

33
discord/errors.py Normal file
View File

@ -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

89
discord/message.py Normal file
View File

@ -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]

75
discord/server.py Normal file
View File

@ -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

73
discord/user.py Normal file
View File

@ -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)