From 4c981ee6315f43b28d99a84851d3f6138738a77f Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 20 Jan 2017 19:26:56 -0500 Subject: [PATCH] Add support for relationships. --- discord/__init__.py | 1 + discord/enums.py | 6 +++ discord/http.py | 23 +++++++++++ discord/member.py | 5 ++- discord/relationship.py | 82 +++++++++++++++++++++++++++++++++++++ discord/state.py | 30 +++++++++++++- discord/user.py | 91 ++++++++++++++++++++++++++++++++++++++++- docs/api.rst | 39 ++++++++++++++++++ 8 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 discord/relationship.py diff --git a/discord/__init__.py b/discord/__init__.py index f8a4eac2a..9624f2e9e 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -23,6 +23,7 @@ from .game import Game from .emoji import Emoji, PartialEmoji from .channel import * from .guild import Guild +from .relationship import Relationship from .member import Member, VoiceState from .message import Message from .errors import * diff --git a/discord/enums.py b/discord/enums.py index 8e4ebc509..f0acff111 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -96,6 +96,12 @@ class DefaultAvatar(Enum): def __str__(self): return self.name +class RelationshipType(Enum): + friend = 1 + blocked = 2 + incoming_request = 3 + outgoing_request = 4 + def try_enum(cls, val): """A function that tries to turn the value into enum ``cls``. diff --git a/discord/http.py b/discord/http.py index e4d48690b..db6b2969e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -618,6 +618,29 @@ class HTTPClient: def move_member(self, user_id, guild_id, channel_id): return self.edit_member(guild_id=guild_id, user_id=user_id, channel_id=channel_id) + + # Relationship related + + def remove_relationship(self, user_id): + r = Route('DELETE', '/users/@me/relationships/{user_id}', user_id=user_id) + return self.request(r) + + def add_relationship(self, user_id, type=None): + r = Route('PUT', '/users/@me/relationships/{user_id}', user_id=user_id) + payload = {} + if type is not None: + payload['type'] = type + + return self.request(r, json=payload) + + def send_friend_request(self, username, discriminator): + r = Route('POST', '/users/@me/relationships') + payload = { + 'username': username, + 'discriminator': int(discriminator) + } + return self.request(r, json=payload) + # Misc def application_info(self): diff --git a/discord/member.py b/discord/member.py index 2cc5a92d7..4c3cf4ab0 100644 --- a/discord/member.py +++ b/discord/member.py @@ -25,11 +25,12 @@ DEALINGS IN THE SOFTWARE. """ import asyncio +import itertools import discord.abc from . import utils -from .user import BaseUser +from .user import BaseUser, User from .game import Game from .permissions import Permissions from .enums import Status, ChannelType, try_enum @@ -74,7 +75,7 @@ class VoiceState: return ''.format(self) def flatten_user(cls): - for attr, value in BaseUser.__dict__.items(): + for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): # ignore private/special methods if attr.startswith('_'): continue diff --git a/discord/relationship.py b/discord/relationship.py new file mode 100644 index 000000000..a9132aee2 --- /dev/null +++ b/discord/relationship.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2016 Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from .enums import RelationshipType, try_enum + +import asyncio + +class Relationship: + """Represents a relationship in Discord. + + A relationship is like a friendship, a person who is blocked, etc. + Only non-bot accounts can have relationships. + + Attributes + ----------- + user: :class:`User` + The user you have the relationship with. + type: :class:`RelationshipType` + The type of relationship you have. + """ + + __slots__ = ('type', 'user', '_state') + + def __init__(self, *, state, data): + self._state = state + self.type = try_enum(RelationshipType, data['type']) + self.user = state.store_user(data['user']) + + def __repr__(self): + return ''.format(self) + + @asyncio.coroutine + def delete(self): + """|coro| + + Deletes the relationship. + + Raises + ------ + HTTPException + Deleting the relationship failed. + """ + + yield from self._state.http.remove_relationship(self.user.id) + + @asyncio.coroutine + def accept(self): + """|coro| + + Accepts the relationship request. e.g. accepting a + friend request. + + Raises + ------- + HTTPException + Accepting the relationship failed. + """ + + yield from self._state.http.add_relationship(self.user.id) diff --git a/discord/state.py b/discord/state.py index f68656cc9..542d18482 100644 --- a/discord/state.py +++ b/discord/state.py @@ -30,6 +30,7 @@ from .game import Game from .emoji import Emoji, PartialEmoji from .reaction import Reaction from .message import Message +from .relationship import Relationship from .channel import * from .member import Member from .role import Role @@ -247,6 +248,14 @@ class ConnectionState: if not self.is_bot or guild.large: guilds.append(guild) + for relationship in data.get('relationships', []): + try: + r_id = int(relationship['id']) + except KeyError: + continue + else: + self.user._relationships[r_id] = Relationship(state=self, data=relationship) + for pm in data.get('private_channels', []): factory, _ = _channel_factory(pm['type']) self._add_private_channel(factory(me=self.user, data=pm, state=self)) @@ -663,6 +672,25 @@ class ConnectionState: if call is not None: self.dispatch('call_remove', call) + def parse_relationship_add(self, data): + key = int(data['id']) + old = self.user.get_relationship(key) + new = Relationship(state=self, data=data) + self.user._relationships[key] = new + if old is not None: + self.dispatch('relationship_update', old, new) + else: + self.dispatch('relationship_add', new) + + def parse_relationship_remove(self, data): + key = int(data['id']) + try: + old = self.user._relationships.pop(key) + except KeyError: + pass + else: + self.dispatch('relationship_remove', old) + def _get_reaction_user(self, channel, user_id): if isinstance(channel, DMChannel) and user_id == channel.recipient.id: return channel.recipient @@ -761,7 +789,7 @@ class AutoShardedConnectionState(ConnectionState): if not hasattr(self, '_ready_state'): self._ready_state = ReadyState(launch=asyncio.Event(), guilds=[]) - self.user = self.store_user(data['user']) + self.user = ClientUser(state=self, data=data['user']) guilds = self._ready_state.guilds for guild_data in data['guilds']: diff --git a/discord/user.py b/discord/user.py index 9bd9fb581..f097ddab3 100644 --- a/discord/user.py +++ b/discord/user.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. """ from .utils import snowflake_time, _bytes_to_base64_data -from .enums import DefaultAvatar +from .enums import DefaultAvatar, RelationshipType from .errors import ClientException import discord.abc @@ -174,7 +174,7 @@ class ClientUser(BaseUser): premium: bool Specifies if the user is a premium user (e.g. has Discord Nitro). """ - __slots__ = ('email', 'verified', 'mfa_enabled', 'premium') + __slots__ = ('email', 'verified', 'mfa_enabled', 'premium', '_relationships') def __init__(self, *, state, data): super().__init__(state=state, data=data) @@ -182,12 +182,33 @@ class ClientUser(BaseUser): self.email = data.get('email') self.mfa_enabled = data.get('mfa_enabled', False) self.premium = data.get('premium', False) + self._relationships = {} def __repr__(self): return ''.format(self) + def get_relationship(self, user_id): + """Retrieves the :class:`Relationship` if applicable. + + Parameters + ----------- + user_id: int + The user ID to check if we have a relationship with them. + + Returns + -------- + Optional[:class:`Relationship`] + The relationship if available or ``None`` + """ + return self._relationships.get(user_id) + + @property + def relationships(self): + """Returns a list of :class:`Relationship` that the user has.""" + return list(self._relationships.values()) + @asyncio.coroutine def edit(self, **fields): """|coro| @@ -337,3 +358,69 @@ class User(BaseUser, discord.abc.Messageable): state = self._state data = yield from state.http.start_private_message(self.id) return state.add_dm_channel(data) + + @property + def relationship(self): + """Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise.""" + return self._state.user.get_relationship(self.id) + + @asyncio.coroutine + def block(self): + """|coro| + + Blocks the user. + + Raises + ------- + Forbidden + Not allowed to block this user. + HTTPException + Blocking the user failed. + """ + + yield from self._state.http.add_relationship(self.id, type=RelationshipType.blocked.value) + + @asyncio.coroutine + def unblock(self): + """|coro| + + Unblocks the user. + + Raises + ------- + Forbidden + Not allowed to unblock this user. + HTTPException + Unblocking the user failed. + """ + yield from self._state.http.remove_relationship(self.id) + + @asyncio.coroutine + def remove_friend(self): + """|coro| + + Removes the user as a friend. + + Raises + ------- + Forbidden + Not allowed to remove this user as a friend. + HTTPException + Removing the user as a friend failed. + """ + yield from self._state.http.remove_relationship(self.id) + + @asyncio.coroutine + def send_friend_request(self): + """|coro| + + Sends the user a friend request. + + Raises + ------- + Forbidden + Not allowed to send a friend request to the user. + HTTPException + Sending the friend request failed. + """ + yield from self._state.http.send_friend_request(username=self.name, discriminator=self.discriminator) diff --git a/docs/api.rst b/docs/api.rst index 7ab4c0b58..f8e0e8b5f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -409,6 +409,22 @@ to handle it, which defaults to print a traceback and ignore the exception. :param channel: The group that the user joined or left. :param user: The user that joined or left. +.. function:: on_relationship_add(relationship) + on_relationship_remove(relationship) + + Called when a :class:`Relationship` is added or removed from the + :class:`ClientUser`. + + :param relationship: The relationship that was added or removed. + +.. function:: on_relationship_update(before, after) + + Called when a :class:`Relationship` is updated, e.g. when you + block a friend or a friendship is accepted. + + :param before: The previous relationship status. + :param after: The updated relationship status. + .. _discord-api-utils: Utility Functions @@ -607,6 +623,23 @@ All enumerations are subclasses of `enum`_. a presence a la :meth:`Client.change_presence`. When you receive a user's presence this will be :attr:`offline` instead. +.. class:: RelationshipType + + Specifies the type of :class:`Relationship` + + .. attribute:: friend + + You are friends with this user. + .. attribute:: blocked + + You have blocked this user. + .. attribute:: incoming_request + + The user has sent you a friend request. + .. attribute:: outgoing_request + + You have sent a friend request to this user. + .. _discord_api_data: Data Classes @@ -652,6 +685,12 @@ ClientUser :members: :inherited-members: +Relationship +~~~~~~~~~~~~~~ + +.. autoclass:: Relationship + :members: + User ~~~~~