Browse Source

Re-add support for reactions.

We now store emojis in a global cache and make things like adding
and removing reactions part of the stateful Message class.
pull/447/head
Rapptz 9 years ago
parent
commit
c187d87dae
  1. 2
      discord/__init__.py
  2. 3
      discord/emoji.py
  3. 4
      discord/guild.py
  4. 130
      discord/message.py
  5. 72
      discord/reaction.py
  6. 115
      discord/state.py

2
discord/__init__.py

@ -20,7 +20,7 @@ __version__ = '0.16.0'
from .client import Client, AppInfo, ChannelPermissions from .client import Client, AppInfo, ChannelPermissions
from .user import User from .user import User
from .game import Game from .game import Game
from .emoji import Emoji from .emoji import Emoji, PartialEmoji
from .channel import * from .channel import *
from .guild import Guild from .guild import Guild
from .member import Member, VoiceState from .member import Member, VoiceState

3
discord/emoji.py

@ -25,10 +25,13 @@ DEALINGS IN THE SOFTWARE.
""" """
import asyncio import asyncio
from collections import namedtuple
from . import utils from . import utils
from .mixins import Hashable from .mixins import Hashable
PartialEmoji = namedtuple('PartialEmoji', 'id name')
class Emoji(Hashable): class Emoji(Hashable):
"""Represents a custom emoji. """Represents a custom emoji.

4
discord/guild.py

@ -237,7 +237,7 @@ class Guild(Hashable):
self.id = int(guild['id']) self.id = int(guild['id'])
self.roles = [Role(guild=self, data=r, state=self._state) for r in guild.get('roles', [])] self.roles = [Role(guild=self, data=r, state=self._state) for r in guild.get('roles', [])]
self.mfa_level = guild.get('mfa_level') self.mfa_level = guild.get('mfa_level')
self.emojis = [Emoji(server=self, data=r, state=self._state) for r in guild.get('emojis', [])] self.emojis = tuple(map(lambda d: self._state.store_emoji(self, d), guild.get('emojis', [])))
self.features = guild.get('features', []) self.features = guild.get('features', [])
self.splash = guild.get('splash') self.splash = guild.get('splash')
@ -653,7 +653,7 @@ class Guild(Hashable):
img = utils._bytes_to_base64_data(image) img = utils._bytes_to_base64_data(image)
data = yield from self._state.http.create_custom_emoji(self.id, name, img) data = yield from self._state.http.create_custom_emoji(self.id, name, img)
return Emoji(guild=self, data=data, state=self._state) return self._state.store_emoji(self, data)
@asyncio.coroutine @asyncio.coroutine
def create_role(self, **fields): def create_role(self, **fields):

130
discord/message.py

@ -29,10 +29,12 @@ import re
from .user import User from .user import User
from .reaction import Reaction from .reaction import Reaction
from .emoji import Emoji
from . import utils, abc from . import utils, abc
from .object import Object from .object import Object
from .calls import CallMessage from .calls import CallMessage
from .enums import MessageType, try_enum from .enums import MessageType, try_enum
from .errors import InvalidArgument
class Message: class Message:
"""Represents a message from Discord. """Represents a message from Discord.
@ -66,8 +68,6 @@ class Message:
In :issue:`very rare cases <21>` this could be a :class:`Object` instead. In :issue:`very rare cases <21>` this could be a :class:`Object` instead.
For the sake of convenience, this :class:`Object` instance has an attribute ``is_private`` set to ``True``. For the sake of convenience, this :class:`Object` instance has an attribute ``is_private`` set to ``True``.
guild: Optional[:class:`Guild`]
The guild that the message belongs to. If not applicable (i.e. a PM) then it's None instead.
call: Optional[:class:`CallMessage`] call: Optional[:class:`CallMessage`]
The call that the message refers to. This is only applicable to messages of type The call that the message refers to. This is only applicable to messages of type
:attr:`MessageType.call`. :attr:`MessageType.call`.
@ -112,16 +112,15 @@ class Message:
__slots__ = ( 'edited_timestamp', 'tts', 'content', 'channel', 'webhook_id', __slots__ = ( 'edited_timestamp', 'tts', 'content', 'channel', 'webhook_id',
'mention_everyone', 'embeds', 'id', 'mentions', 'author', 'mention_everyone', 'embeds', 'id', 'mentions', 'author',
'_cs_channel_mentions', 'guild', '_cs_raw_mentions', 'attachments', '_cs_channel_mentions', '_cs_raw_mentions', 'attachments',
'_cs_clean_content', '_cs_raw_channel_mentions', 'nonce', 'pinned', '_cs_clean_content', '_cs_raw_channel_mentions', 'nonce', 'pinned',
'role_mentions', '_cs_raw_role_mentions', 'type', 'call', 'role_mentions', '_cs_raw_role_mentions', 'type', 'call',
'_cs_system_content', '_state', 'reactions' ) '_cs_system_content', '_state', 'reactions' )
def __init__(self, *, state, channel, data): def __init__(self, *, state, channel, data):
self._state = state self._state = state
self.reactions = kwargs.pop('reactions') self.id = int(data['id'])
for reaction in self.reactions: self.reactions = [Reaction(message=self, data=d) for d in data.get('reactions', [])]
reaction.message = self
self._update(channel, data) self._update(channel, data)
def _try_patch(self, data, key, transform): def _try_patch(self, data, key, transform):
@ -132,6 +131,41 @@ class Message:
else: else:
setattr(self, key, transform(value)) setattr(self, key, transform(value))
def _add_reaction(self, data):
emoji = self._state.reaction_emoji(data['emoji'])
reaction = utils.find(lambda r: r.emoji == emoji, self.reactions)
is_me = data['me'] = int(data['user_id']) == self._state.self_id
if reaction is None:
reaction = Reaction(message=self, data=data, emoji=emoji)
self.reactions.append(reaction)
else:
reaction.count += 1
if is_me:
reaction.me = is_me
return reaction
def _remove_reaction(self, data):
emoji = self._state.reaction_emoji(data['emoji'])
reaction = utils.find(lambda r: r.emoji == emoji, self.reactions)
if reaction is None:
# already removed?
raise ValueError('Emoji already removed?')
# if reaction isn't in the list, we crash. This means discord
# sent bad data, or we stored improperly
reaction.count -= 1
if int(data['user_id']) == self._state.self_id:
reaction.me = False
if reaction.count == 0:
# this raises ValueError if something went wrong as well.
self.reactions.remove(reaction)
return reaction
def _update(self, channel, data): def _update(self, channel, data):
self.channel = channel self.channel = channel
for handler in ('mentions', 'mention_roles', 'call'): for handler in ('mentions', 'mention_roles', 'call'):
@ -198,6 +232,11 @@ class Message:
call['participants'] = participants call['participants'] = participants
self.call = CallMessage(message=self, **call) self.call = CallMessage(message=self, **call)
@property
def guild(self):
"""Optional[:class:`Guild`]: The guild that the message belongs to, if applicable."""
return getattr(self.channel, 'guild', None)
@utils.cached_slot_property('_cs_raw_mentions') @utils.cached_slot_property('_cs_raw_mentions')
def raw_mentions(self): def raw_mentions(self):
"""A property that returns an array of user IDs matched with """A property that returns an array of user IDs matched with
@ -428,3 +467,82 @@ class Message:
yield from self._state.http.unpin_message(self.channel.id, self.id) yield from self._state.http.unpin_message(self.channel.id, self.id)
self.pinned = False self.pinned = False
@asyncio.coroutine
def add_reaction(self, emoji):
"""|coro|
Add a reaction to the message.
The emoji may be a unicode emoji or a custom server :class:`Emoji`.
You must have the :attr:`Permissions.add_reactions` permission to
add new reactions to a message.
Parameters
------------
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 emoji you specified was not found.
InvalidArgument
The emoji parameter is invalid.
"""
if isinstance(emoji, Emoji):
emoji = '%s:%s' % (emoji.name, emoji.id)
elif isinstance(emoji, str):
pass # this is okay
else:
raise InvalidArgument('emoji argument must be a string or discord.Emoji')
yield from self._state.http.add_reaction(self.id, self.channel.id, emoji)
@asyncio.coroutine
def remove_reaction(self, emoji, member):
"""|coro|
Remove a reaction by the member from the message.
The emoji may be a unicode emoji or a custom server :class:`Emoji`.
If the reaction is not your own (i.e. ``member`` parameter is not you) then
the :attr:`Permissions.manage_messages` permission is needed.
The ``member`` parameter must represent a member and meet
the :class:`abc.Snowflake` abc.
Parameters
------------
emoji: :class:`Emoji` or str
The emoji to remove.
member: :class:`abc.Snowflake`
The member for which to remove the reaction.
Raises
--------
HTTPException
Removing the reaction failed.
Forbidden
You do not have the proper permissions to remove the reaction.
NotFound
The member or emoji you specified was not found.
InvalidArgument
The emoji parameter is invalid.
"""
if isinstance(emoji, Emoji):
emoji = '%s:%s' % (emoji.name, emoji.id)
elif isinstance(emoji, str):
pass # this is okay
else:
raise InvalidArgument('emoji argument must be a string or discord.Emoji')
yield from self._state.http.remove_reaction(self.id, self.channel.id, emoji, member.id)

72
discord/reaction.py

@ -24,7 +24,9 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
DEALINGS IN THE SOFTWARE. DEALINGS IN THE SOFTWARE.
""" """
from .emoji import Emoji import asyncio
from .user import User
class Reaction: class Reaction:
"""Represents a reaction to a message. """Represents a reaction to a message.
@ -48,25 +50,27 @@ class Reaction:
Attributes Attributes
----------- -----------
emoji : :class:`Emoji` or str emoji: :class:`Emoji` or str
The reaction emoji. May be a custom emoji, or a unicode emoji. The reaction emoji. May be a custom emoji, or a unicode emoji.
custom_emoji : bool count: int
If this is a custom emoji.
count : int
Number of times this reaction was made Number of times this reaction was made
me : bool me: bool
If the user sent this reaction. If the user sent this reaction.
message: :class:`Message` message: :class:`Message`
Message this reaction is for. Message this reaction is for.
""" """
__slots__ = ['message', 'count', 'emoji', 'me', 'custom_emoji'] __slots__ = ('message', 'count', 'emoji', 'me')
def __init__(self, *, message, data, emoji=None):
self.message = message
self.emoji = message._state.reaction_emoji(data['emoji']) if emoji is None else emoji
self.count = data.get('count', 1)
self.me = data.get('me')
def __init__(self, **kwargs): @property
self.message = kwargs.get('message') def custom_emoji(self):
self.emoji = kwargs['emoji'] """bool: If this is a custom emoji."""
self.count = kwargs.get('count', 1) return not isinstance(self.emoji, str)
self.me = kwargs.get('me')
self.custom_emoji = isinstance(self.emoji, Emoji)
def __eq__(self, other): def __eq__(self, other):
return isinstance(other, self.__class__) and other.emoji == self.emoji return isinstance(other, self.__class__) and other.emoji == self.emoji
@ -78,3 +82,45 @@ class Reaction:
def __hash__(self): def __hash__(self):
return hash(self.emoji) return hash(self.emoji)
@asyncio.coroutine
def users(self, limit=100, after=None):
"""|coro|
Get the users that added this reaction.
The ``after`` parameter must represent a member
and meet the :class:`abc.Snowflake` abc.
Parameters
------------
limit: int
The maximum number of results to return.
after: :class:`abc.Snowflake`
For pagination, reactions are sorted by member.
Raises
--------
HTTPException
Getting the users for the reaction failed.
Returns
--------
List[:class:`User`]
A list of users who reacted to the message.
"""
# TODO: Return an iterator a la `MessageChannel.history`?
if self.custom_emoji:
emoji = '{0.name}:{0.id}'.format(self.emoji)
else:
emoji = self.emoji
if after:
after = after.id
msg = self.message
state = msg._state
data = yield from state.http.get_reaction_users(msg.id, msg.channel.id, emoji, limit, after=after)
return [User(state=state, data=user) for user in data]

115
discord/state.py

@ -27,7 +27,7 @@ DEALINGS IN THE SOFTWARE.
from .guild import Guild from .guild import Guild
from .user import User from .user import User
from .game import Game from .game import Game
from .emoji import Emoji from .emoji import Emoji, PartialEmoji
from .reaction import Reaction from .reaction import Reaction
from .message import Message from .message import Message
from .channel import * from .channel import *
@ -47,10 +47,16 @@ class ListenerType(enum.Enum):
chunk = 0 chunk = 0
Listener = namedtuple('Listener', ('type', 'future', 'predicate')) Listener = namedtuple('Listener', ('type', 'future', 'predicate'))
StateContext = namedtuple('StateContext', 'store_user http self_id')
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
ReadyState = namedtuple('ReadyState', ('launch', 'guilds')) ReadyState = namedtuple('ReadyState', ('launch', 'guilds'))
class StateContext:
__slots__ = ('store_user', 'http', 'self_id', 'store_emoji', 'reaction_emoji')
def __init__(self, **kwargs):
for attr, value in kwargs.items():
setattr(self, attr, value)
class ConnectionState: class ConnectionState:
def __init__(self, *, dispatch, chunker, syncer, http, loop, **options): def __init__(self, *, dispatch, chunker, syncer, http, loop, **options):
self.loop = loop self.loop = loop
@ -60,7 +66,10 @@ class ConnectionState:
self.syncer = syncer self.syncer = syncer
self.is_bot = None self.is_bot = None
self._listeners = [] self._listeners = []
self.ctx = StateContext(store_user=self.store_user, http=http, self_id=None) self.ctx = StateContext(store_user=self.store_user,
store_emoji=self.store_emoji,
reaction_emoji=self._get_reaction_emoji,
http=http, self_id=None)
self.clear() self.clear()
def clear(self): def clear(self):
@ -69,6 +78,7 @@ class ConnectionState:
self.session_id = None self.session_id = None
self._calls = {} self._calls = {}
self._users = {} self._users = {}
self._emojis = {}
self._guilds = {} self._guilds = {}
self._voice_clients = {} self._voice_clients = {}
self._private_channels = {} self._private_channels = {}
@ -128,6 +138,14 @@ class ConnectionState:
self._users[user_id] = user = User(state=self.ctx, data=data) self._users[user_id] = user = User(state=self.ctx, data=data)
return user return user
def store_emoji(self, guild, data):
emoji_id = int(data['id'])
try:
return self._emojis[emoji_id]
except KeyError:
self._emojis[emoji_id] = emoji = Emoji(guild=guild, state=self.ctx, data=data)
return emoji
@property @property
def guilds(self): def guilds(self):
return self._guilds.values() return self._guilds.values()
@ -274,26 +292,11 @@ class ConnectionState:
self.dispatch('message_edit', older_message, message) self.dispatch('message_edit', older_message, message)
def parse_message_reaction_add(self, data): def parse_message_reaction_add(self, data):
message = self._get_message(data['message_id']) message = self._get_message(int(data['message_id']))
if message is not None: if message is not None:
emoji = self._get_reaction_emoji(**data.pop('emoji')) reaction = message._add_reaction(data)
reaction = utils.get(message.reactions, emoji=emoji) user = self._get_reaction_user(message.channel, int(data['user_id']))
self.dispatch('reaction_add', reaction, user)
is_me = data['user_id'] == self.user.id
if not reaction:
reaction = Reaction(
message=message, emoji=emoji, 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('reaction_add', reaction, member)
def parse_message_reaction_remove_all(self, data): def parse_message_reaction_remove_all(self, data):
message = self._get_message(data['message_id']) message = self._get_message(data['message_id'])
@ -303,26 +306,15 @@ class ConnectionState:
self.dispatch('reaction_clear', message, old_reactions) self.dispatch('reaction_clear', message, old_reactions)
def parse_message_reaction_remove(self, data): def parse_message_reaction_remove(self, data):
message = self._get_message(data['message_id']) message = self._get_message(int(data['message_id']))
if message is not None: if message is not None:
emoji = self._get_reaction_emoji(**data['emoji']) try:
reaction = utils.get(message.reactions, emoji=emoji) reaction = message._remove_reaction(data)
except (AttributeError, ValueError) as e: # eventual consistency lol
# Eventual consistency means we can get out of order or duplicate removes. pass
if not reaction: else:
log.warning("Unexpected reaction remove {}".format(data)) user = self._get_reaction_user(message.channel, int(data['user_id']))
return self.dispatch('reaction_remove', reaction, user)
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('reaction_remove', reaction, member)
def parse_presence_update(self, data): def parse_presence_update(self, data):
guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id'))
@ -462,7 +454,7 @@ class ConnectionState:
def parse_guild_emojis_update(self, data): def parse_guild_emojis_update(self, data):
guild = self._get_guild(int(data['guild_id'])) guild = self._get_guild(int(data['guild_id']))
before_emojis = guild.emojis before_emojis = guild.emojis
guild.emojis = [Emoji(guild=guild, data=e, state=self.ctx) for e in data.get('emojis', [])] guild.emojis = tuple(map(lambda d: self.store_emoji(guild, d), data['emojis']))
self.dispatch('guild_emojis_update', before_emojis, guild.emojis) self.dispatch('guild_emojis_update', before_emojis, guild.emojis)
def _get_create_guild(self, data): def _get_create_guild(self, data):
@ -675,35 +667,26 @@ class ConnectionState:
if call is not None: if call is not None:
self.dispatch('call_remove', call) self.dispatch('call_remove', call)
def _get_member(self, channel, id): def _get_reaction_user(self, channel, user_id):
if channel.is_private: if isinstance(channel, DMChannel) and user_id == channel.recipient.id:
return utils.get(channel.recipients, id=id) return channel.recipient
elif isinstance(channel, TextChannel):
return channel.guild.get_member(user_id)
elif isinstance(channel, GroupChannel):
return utils.find(lambda m: m.id == user_id, channel.recipients)
else: else:
return channel.server.get_member(id) return None
def _create_message(self, **message):
"""Helper mostly for injecting reactions."""
reactions = [
self._create_reaction(**r) for r in message.pop('reactions', [])
]
return Message(channel=message.pop('channel'),
reactions=reactions, **message)
def _create_reaction(self, **reaction):
emoji = self._get_reaction_emoji(**reaction.pop('emoji'))
return Reaction(emoji=emoji, **reaction)
def _get_reaction_emoji(self, **data): def _get_reaction_emoji(self, data):
id = data['id'] emoji_id = utils._get_as_snowflake(data, 'id')
if not id: if not emoji_id:
return data['name'] return data['name']
for server in self.servers: try:
for emoji in server.emojis: return self._emojis[emoji_id]
if emoji.id == id: except KeyError:
return emoji return PartialEmoji(id=emoji_id, name=data['name'])
return Emoji(server=None, **data)
def get_channel(self, id): def get_channel(self, id):
if id is None: if id is None:

Loading…
Cancel
Save