diff --git a/discord/client.py b/discord/client.py index c3594638f..ff2479590 100644 --- a/discord/client.py +++ b/discord/client.py @@ -32,6 +32,7 @@ from .server import Server from .message import Message from .invite import Invite from .object import Object +from .reaction import Reaction from .role import Role from .errors import * from .state import ConnectionState @@ -775,6 +776,130 @@ class Client: self.connection._add_private_channel(channel) return channel + @asyncio.coroutine + def add_reaction(self, message, emoji): + """|coro| + + Add a reaction to the given message. + + The message must be a :class:`Message` that exists. emoji may be a unicode emoji, + or a custom server :class:`Emoji`. + + Parameters + ------------ + message : :class:`Message` + The message to react to. + emoji : :class:`Emoji` or str + The emoji to react with. + + Raises + -------- + HTTPException + Adding the reaction failed. + Forbidden + You do not have the proper permissions to react to the message. + NotFound + The message or emoji you specified was not found. + InvalidArgument + The message or emoji parameter is invalid. + """ + if not isinstance(message, Message): + raise InvalidArgument('message argument must be a Message') + if not isinstance(emoji, (str, Emoji)): + raise InvalidArgument('emoji argument must be a string or Emoji') + + if isinstance(emoji, Emoji): + emoji = '{}:{}'.format(emoji.name, emoji.id) + + yield from self.http.add_reaction(message.id, message.channel.id, emoji) + + @asyncio.coroutine + def remove_reaction(self, message, emoji, member): + """|coro| + + Remove a reaction by the member from the given message. + + If member != server.me, you need Manage Messages to remove the reaction. + + The message must be a :class:`Message` that exists. emoji may be a unicode emoji, + or a custom server :class:`Emoji`. + + Parameters + ------------ + message : :class:`Message` + The message. + emoji : :class:`Emoji` or str + The emoji to remove. + member : :class:`Member` + The member for which to delete the reaction. + + Raises + -------- + HTTPException + Adding the reaction failed. + Forbidden + You do not have the proper permissions to remove the reaction. + NotFound + The message or emoji you specified was not found. + InvalidArgument + The message or emoji parameter is invalid. + """ + if not isinstance(message, Message): + raise InvalidArgument('message argument must be a Message') + if not isinstance(emoji, (str, Emoji)): + raise InvalidArgument('emoji must be a string or Emoji') + + if isinstance(emoji, Emoji): + emoji = '{}:{}'.format(emoji.name, emoji.id) + + if member == self.user: + member_id = '@me' + else: + member_id = member.id + + yield from self.http.remove_reaction(message.id, message.channel.id, emoji, member_id) + + @asyncio.coroutine + def get_reaction_users(self, reaction, limit=100, after=None): + """|coro| + + Get the users that added a reaction to a message. + + Parameters + ------------ + reaction : :class:`Reaction` + The reaction to retrieve users for. + limit : int + The maximum number of results to return. + after : :class:`Member` or :class:`Object` + For pagination, reactions are sorted by member. + + Raises + -------- + HTTPException + Getting the users for the reaction failed. + NotFound + The message or emoji you specified was not found. + InvalidArgument + The reaction parameter is invalid. + """ + if not isinstance(reaction, Reaction): + raise InvalidArgument('reaction must be a Reaction') + + emoji = reaction.emoji + + if isinstance(emoji, Emoji): + emoji = '{}:{}'.format(emoji.name, emoji.id) + + if after: + after = after.id + + data = yield from self.http.get_reaction_users( + reaction.message.id, reaction.message.channel.id, + emoji, limit, after=after) + + return [User(**user) for user in data] + @asyncio.coroutine def send_message(self, destination, content, *, tts=False): """|coro| diff --git a/discord/http.py b/discord/http.py index 26824fcb2..01d17d551 100644 --- a/discord/http.py +++ b/discord/http.py @@ -261,6 +261,24 @@ class HTTPClient: } return self.patch(url, json=payload, bucket='messages:' + str(guild_id)) + def add_reaction(self, message_id, channel_id, emoji): + url = '{0.CHANNELS}/{1}/messages/{2}/reactions/{3}/@me'.format( + self, channel_id, message_id, emoji) + return self.put(url, bucket=_func_()) + + def remove_reaction(self, message_id, channel_id, emoji, member_id): + url = '{0.CHANNELS}/{1}/messages/{2}/reactions/{3}/{4}'.format( + self, channel_id, message_id, emoji, member_id) + return self.delete(url, bucket=_func_()) + + def get_reaction_users(self, message_id, channel_id, emoji, limit, after=None): + url = '{0.CHANNELS}/{1}/messages/{2}/reactions/{3}'.format( + self, channel_id, message_id, emoji) + params = {'limit': limit} + if after: + params['after'] = after + return self.get(url, params=params, bucket=_func_()) + def get_message(self, channel_id, message_id): url = '{0.CHANNELS}/{1}/messages/{2}'.format(self, channel_id, message_id) return self.get(url, bucket=_func_()) diff --git a/discord/message.py b/discord/message.py index 0413424e6..d2bdf87e5 100644 --- a/discord/message.py +++ b/discord/message.py @@ -26,6 +26,7 @@ DEALINGS IN THE SOFTWARE. from . import utils from .user import User +from .reaction import Reaction from .object import Object from .calls import CallMessage import re @@ -102,6 +103,8 @@ class Message: A list of attachments given to a message. pinned: bool Specifies if the message is currently pinned. + reactions : List[:class:`Reaction`] + Reactions to a message. Reactions can be either custom emoji or standard unicode emoji. """ __slots__ = [ 'edited_timestamp', 'timestamp', 'tts', 'content', 'channel', @@ -109,7 +112,7 @@ class Message: 'channel_mentions', 'server', '_raw_mentions', 'attachments', '_clean_content', '_raw_channel_mentions', 'nonce', 'pinned', 'role_mentions', '_raw_role_mentions', 'type', 'call', - '_system_content' ] + '_system_content', 'reactions' ] def __init__(self, **kwargs): self._update(**kwargs) @@ -135,6 +138,7 @@ class Message: self._handle_upgrades(data.get('channel_id')) self._handle_mentions(data.get('mentions', []), data.get('mention_roles', [])) self._handle_call(data.get('call')) + self.reactions = [Reaction(message=self, **reaction) for reaction in data.get('reactions', [])] # clear the cached properties cached = filter(lambda attr: attr[0] == '_', self.__slots__) diff --git a/discord/permissions.py b/discord/permissions.py index 0fc69c5c7..e6a1df586 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -127,7 +127,7 @@ class Permissions: def all(cls): """A factory method that creates a :class:`Permissions` with all permissions set to True.""" - return cls(0b01111111111101111111110000111111) + return cls(0b01111111111101111111110001111111) @classmethod def all_channel(cls): @@ -142,7 +142,7 @@ class Permissions: - change_nicknames - manage_nicknames """ - return cls(0b00110011111101111111110000010001) + return cls(0b00110011111101111111110001010001) @classmethod def general(cls): @@ -154,7 +154,7 @@ class Permissions: def text(cls): """A factory method that creates a :class:`Permissions` with all "Text" permissions from the official Discord UI set to True.""" - return cls(0b00000000000001111111110000000000) + return cls(0b00000000000001111111110001000000) @classmethod def voice(cls): @@ -248,6 +248,15 @@ class Permissions: def manage_server(self, value): self._set(5, value) + @property + def add_reactions(self): + """Returns True if a user can add reactions to messages.""" + return self._bit(6) + + @add_reactions.setter + def add_reactions(self, value): + self._set(6, value) + # 4 unused @property diff --git a/discord/reaction.py b/discord/reaction.py new file mode 100644 index 000000000..ec30fa226 --- /dev/null +++ b/discord/reaction.py @@ -0,0 +1,88 @@ +# -*- 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 .emoji import Emoji + +class Reaction: + """Represents a reaction to a message. + + Depending on the way this object was created, some of the attributes can + have a value of ``None``. + + Similar to members, the same reaction to a different message are equal. + + Supported Operations: + + +-----------+-------------------------------------------+ + | Operation | Description | + +===========+===========================================+ + | x == y | Checks if two reactions are the same. | + +-----------+-------------------------------------------+ + | x != y | Checks if two reactions are not the same. | + +-----------+-------------------------------------------+ + | hash(x) | Return the emoji's hash. | + +-----------+-------------------------------------------+ + + Attributes + ----------- + emoji : :class:`Emoji` or str + The reaction emoji. May be a custom emoji, or a unicode emoji. + custom_emoji : bool + If this is a custom emoji. + count : int + Number of times this reaction was made + me : bool + If the user has send this reaction. + message: :class:`Message` + Message this reaction is for. + """ + __slots__ = ['message', 'count', 'emoji', 'me', 'custom_emoji'] + + def __init__(self, **kwargs): + self.message = kwargs.pop('message') + self._from_data(kwargs) + + def _from_data(self, reaction): + self.count = reaction.get('count', 1) + self.me = reaction.get('me') + emoji = reaction['emoji'] + if emoji['id']: + self.custom_emoji = True + self.emoji = Emoji(server=None, id=emoji['id'], name=emoji['name']) + else: + self.custom_emoji = False + self.emoji = emoji['name'] + + def __eq__(self, other): + return isinstance(other, self.__class__) and other.emoji == self.emoji + + def __ne__(self, other): + if isinstance(other, self.__class__): + return other.emoji != self.emoji + return True + + def __hash__(self): + return hash(self.emoji) diff --git a/discord/state.py b/discord/state.py index 747c3bd94..4d3855fc2 100644 --- a/discord/state.py +++ b/discord/state.py @@ -28,6 +28,7 @@ from .server import Server from .user import User from .game import Game from .emoji import Emoji +from .reaction import Reaction from .message import Message from .channel import Channel, PrivateChannel from .member import Member @@ -251,6 +252,54 @@ class ConnectionState: self.dispatch('message_edit', older_message, message) + def parse_message_reaction_add(self, data): + message = self._get_message(data['message_id']) + if message is not None: + if data['emoji']['id']: + reaction_emoji = Emoji(server=None, **data['emoji']) + else: + reaction_emoji = data['emoji']['name'] + reaction = utils.get( + message.reactions, emoji=reaction_emoji) + + is_me = data['user_id'] == self.user.id + + if not reaction: + reaction = Reaction(message=message, me=is_me, **data) + message.reactions.append(reaction) + else: + reaction.count += 1 + if is_me: + reaction.me = True + + channel = self.get_channel(data['channel_id']) + member = self._get_member(channel, data['user_id']) + + self.dispatch('message_reaction_add', message, reaction, member) + + def parse_message_reaction_remove(self, data): + message = self._get_message(data['message_id']) + if message is not None: + if data['emoji']['id']: + reaction_emoji = Emoji(server=None, **data['emoji']) + else: + reaction_emoji = data['emoji']['name'] + reaction = utils.get( + message.reactions, emoji=reaction_emoji) + + # if reaction isn't in the list, we crash. This means discord + # sent bad data, or we stored improperly + reaction.count -= 1 + if data['user_id'] == self.user.id: + reaction.me = False + if reaction.count == 0: + message.reactions.remove(reaction) + + channel = self.get_channel(data['channel_id']) + member = self._get_member(channel, data['user_id']) + + self.dispatch('message_reaction_remove', message, reaction, member) + def parse_presence_update(self, data): server = self._get_server(data.get('guild_id')) if server is None: @@ -625,6 +674,12 @@ class ConnectionState: if call is not None: self.dispatch('call_remove', call) + def _get_member(self, channel, id): + if channel.is_private: + return utils.get(channel.recipients, id=id) + else: + return channel.server.get_member(id) + def get_channel(self, id): if id is None: return None diff --git a/docs/api.rst b/docs/api.rst index 641331c45..72162f516 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -207,6 +207,26 @@ to handle it, which defaults to print a traceback and ignore the exception. :param before: A :class:`Message` of the previous version of the message. :param after: A :class:`Message` of the current version of the message. +.. function:: on_message_reaction_add(message, reaction, user) + + Called when a message has a reaction added to it. Similar to on_message_edit, + if the message is not found in the :attr:`Client.messages` cache, then this + event will not be called. + + :param message: A :class:`Message` that was reacted to. + :param reaction: A :class:`Reaction` showing the current state of the reaction. + :param user: A :class:`User` or :class:`Member` of the user who added the reaction. + +.. function:: on_message_reaction_remove(message, reaction, user) + + Called when a message has a reaction removed from it. Similar to on_message_edit, + if the message is not found in the :attr:`Client.messages` cache, then this event + will not be called. + + :param message: A :class:`Message` that was reacted to. + :param reaction: A :class:`Reaction` showing the current state of the reaction. + :param user: A :class:`User` or :class:`Member` of the user who removed the reaction. + .. function:: on_channel_delete(channel) on_channel_create(channel)