From f8f8f418f3c51b6a885a1b6b7cd46c38c070b3bc Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 5 Mar 2018 11:01:46 -0500 Subject: [PATCH] Split Game object to separate Activity subtypes for Rich Presences. This is a massive breaking change. * All references to "game" have been renamed to "activity" * Activity objects contain a majority of the rich presence information * Game and Streaming are subtypes for memory optimisation purposes for the more common cases. * Introduce a more specialised read-only type, Spotify, for the official Spotify integration to make it easier to use. --- discord/__init__.py | 2 +- discord/activity.py | 565 ++++++++++++++++++++++++++++++++++++++++++++ discord/client.py | 39 +-- discord/enums.py | 11 +- discord/game.py | 87 ------- discord/gateway.py | 18 +- discord/guild.py | 5 +- discord/member.py | 23 +- discord/message.py | 25 +- discord/shard.py | 24 +- discord/state.py | 12 +- docs/api.rst | 47 +++- 12 files changed, 708 insertions(+), 150 deletions(-) create mode 100644 discord/activity.py delete mode 100644 discord/game.py diff --git a/discord/__init__.py b/discord/__init__.py index 20c81853e..3f605a0a6 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -19,8 +19,8 @@ __version__ = '1.0.0a' from .client import Client, AppInfo from .user import User, ClientUser, Profile -from .game import Game from .emoji import Emoji, PartialEmoji +from .activity import * from .channel import * from .guild import Guild from .relationship import Relationship diff --git a/discord/activity.py b/discord/activity.py new file mode 100644 index 000000000..db508a4e4 --- /dev/null +++ b/discord/activity.py @@ -0,0 +1,565 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2017 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 ActivityType, try_enum +import datetime + +__all__ = ('Activity', 'Streaming', 'Game', 'Spotify') + +"""If curious, this is the current schema for an activity. + +It's fairly long so I will document it here: + +All keys are optional. + +state: str (max: 128), +details: str (max: 128) +timestamps: dict + start: int (min: 1) + end: int (min: 1) +assets: dict + large_image: str (max: 32) + large_text: str (max: 128) + small_image: str (max: 32) + small_text: str (max: 128) +party: dict + id: str (max: 128), + size: List[int] (max-length: 2) + elem: int (min: 1) +secrets: dict + match: str (max: 128) + join: str (max: 128) + spectate: str (max: 128) +instance: bool +application_id: str +name: str (max: 128) +url: str +type: int +sync_id: str +session_id: str +flags: int + +There are also activity flags which are mostly uninteresting for the library atm. + +t.ActivityFlags = { + INSTANCE: 1, + JOIN: 2, + SPECTATE: 4, + JOIN_REQUEST: 8, + SYNC: 16, + PLAY: 32 +} +""" + +class _ActivityTag: + __slots__ = () + +class Activity(_ActivityTag): + """Represents an activity in Discord. + + This could be an activity such as streaming, playing, listening + or watching. + + For memory optimisation purposes, some activities are offered in slimmed + down versions: + + - :class:`Game` + - :class:`Streaming` + + Attributes + ------------ + application_id: :class:`str` + The application ID of the game. + name: :class:`str` + The name of the activity. + url: :class:`str` + A stream URL that the activity could be doing. + type: :class:`ActivityType` + The type of activity currently being done. + state: :class:`str` + The user's current state. For example, "In Game". + details: :class:`str` + The detail of the user's current activity. + timestamps: :class:`dict` + A dictionary of timestamps. It contains the following optional keys: + + - ``start``: Corresponds to when the user started doing the + activity in milliseconds since Unix epoch. + - ``end``: Corresponds to when the user will finish doing the + activity in milliseconds since Unix epoch. + + assets: :class:`dict` + A dictionary representing the images and their hover text of an activity. + It contains the following optional keys: + + - ``large_image``: A string representing the ID for the large image asset. + - ``large_text``: A string representing the text when hovering over the large image asset. + - ``small_image``: A string representing the ID for the small image asset. + - ``small_text``: A string representing the text when hovering over the small image asset. + + party: :class:`dict` + A dictionary representing the activity party. It contains the following optional keys: + + - ``id``: A string representing the party ID. + - ``size``: A list of up to two integer elements denoting (current_size, maximum_size). + """ + + __slots__ = ('state', 'details', 'timestamps', 'assets', 'party', + 'flags', 'sync_id', 'session_id', 'type', 'name', 'url', 'application_id') + + def __init__(self, **kwargs): + self.state = kwargs.pop('state', None) + self.details = kwargs.pop('details', None) + self.timestamps = kwargs.pop('timestamps', {}) + self.assets = kwargs.pop('assets', {}) + self.party = kwargs.pop('party', {}) + self.application_id = kwargs.pop('application_id', None) + self.name = kwargs.pop('name', None) + self.url = kwargs.pop('url', None) + self.flags = kwargs.pop('flags', 0) + self.sync_id = kwargs.pop('sync_id', None) + self.session_id = kwargs.pop('session_id', None) + self.type = try_enum(ActivityType, kwargs.pop('type', -1)) + + def to_dict(self): + ret = {} + for attr in self.__slots__: + value = getattr(self, attr, None) + if value is None: + continue + + if isinstance(value, dict) and len(value) == 0: + continue + + ret[attr] = value + ret['type'] = int(self.type) + return ret + + @property + def start(self): + """Optional[:class:`datetime.datetime`]: When the user started doing this activity in UTC, if applicable.""" + try: + return datetime.datetime.utcfromtimestamp(self.timestamps['start'] / 1000) + except KeyError: + return None + + @property + def end(self): + """Optional[:class:`datetime.datetime`]: When the user will stop doing this activity in UTC, if applicable.""" + try: + return datetime.datetime.utcfromtimestamp(self.timestamps['end'] / 1000) + except KeyError: + return None + + @property + def large_image_url(self): + """Optional[:class:`str`]: Returns a URL pointing to the large image asset of this activity if applicable.""" + if self.application_id is None: + return None + + try: + large_image = self.assets['large_image'] + except KeyError: + return None + else: + return 'htps://cdn.discordapp.com/app-assets/{0}/{1}.png'.format(self.application_id, large_image) + + @property + def small_image_url(self): + """Optional[:class:`str`]: Returns a URL pointing to the small image asset of this activity if applicable.""" + if self.application_id is None: + return None + + try: + small_image = self.assets['small_image'] + except KeyError: + return None + else: + return 'htps://cdn.discordapp.com/app-assets/{0}/{1}.png'.format(self.application_id, small_image) + @property + def large_image_text(self): + """Optional[:class:`str`]: Returns the large image asset hover text of this activity if applicable.""" + return self.assets.get('large_text', None) + + @property + def small_image_text(self): + """Optional[:class:`str`]: Returns the small image asset hover text of this activity if applicable.""" + return self.assets.get('small_text', None) + + +class Game(_ActivityTag): + """A slimmed down version of :class:`Activity` that represents a Discord game. + + This is typically displayed via **Playing** on the official Discord client. + + .. container:: operations + + .. describe:: x == y + + Checks if two games are equal. + + .. describe:: x != y + + Checks if two games are not equal. + + .. describe:: hash(x) + + Returns the game's hash. + + .. describe:: str(x) + + Returns the game's name. + + Parameters + ----------- + name: :class:`str` + The game's name. + start: Optional[:class:`datetime.datetime`] + A naive UTC timestamp representing when the game started. Keyword-only parameter. Ignored for bots. + end: Optional[:class:`datetime.datetime`] + A naive UTC timestamp representing when the game started. Keyword-only parameter. Ignored for bots. + + Attributes + ----------- + name: :class:`str` + The game's name. + """ + + __slots__ = ('name', '_end', '_start') + + def __init__(self, name, **extra): + self.name = name + + try: + timestamps = extra['timestamps'] + except KeyError: + self._extract_timestamp(extra, 'start') + self._extract_timestamp(extra, 'end') + else: + self._start = timestamps.get('start', 0) + self._end = timestamps.get('end', 0) + + def _extract_timestamp(self, data, key): + try: + dt = data[key] + except KeyError: + setattr(self, '_' + key, 0) + else: + setattr(self, '_' + key, dt.timestamp() * 1000.0) + + @property + def type(self): + """Returns the game's type. This is for compatibility with :class:`Activity`. + + It always returns :attr:`ActivityType.playing`. + """ + return ActivityType.playing + + @property + def start(self): + """Optional[:class:`datetime.datetime`]: When the user started playing this game in UTC, if applicable.""" + if self._start: + return datetime.datetime.utcfromtimestamp(self._start / 1000) + return None + + @property + def end(self): + """Optional[:class:`datetime.datetime`]: When the user will stop playing this game in UTC, if applicable.""" + if self._end: + return datetime.datetime.utcfromtimestamp(self._end / 1000) + return None + + def __str__(self): + return str(self.name) + + def __repr__(self): + return ''.format(self) + + def to_dict(self): + timestamps = {} + if self._start: + timestamps['start'] = self._start + + if self._end: + timestamps['end'] = self._end + + return { + 'type': ActivityType.playing.value, + 'name': str(self.name), + 'timestamps': timestamps + } + + def __eq__(self, other): + return isinstance(other, Game) and other.name == self.name + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.name) + +class Streaming(_ActivityTag): + """A slimmed down version of :class:`Activity` that represents a Discord streaming status. + + This is typically displayed via **Streaming** on the official Discord client. + + .. container:: operations + + .. describe:: x == y + + Checks if two streams are equal. + + .. describe:: x != y + + Checks if two streams are not equal. + + .. describe:: hash(x) + + Returns the stream's hash. + + .. describe:: str(x) + + Returns the stream's name. + + Attributes + ----------- + name: :class:`str` + The stream's name. + url: :class:`str` + The stream's URL. Currently only twitch.tv URLs are supported. Anything else is silently + discarded. + details: Optional[:class:`str`] + If provided, typically the game the streamer is playing. + assets: :class:`dict` + A dictionary comprising of similar keys than those in :attr:`Activity.assets`. + """ + + __slots__ = ('name', 'url', 'details', 'assets') + + def __init__(self, *, name, url, **extra): + self.name = name + self.url = url + self.details = extra.pop('details', None) + self.assets = extra.pop('assets', {}) + + @property + def type(self): + """Returns the game's type. This is for compatibility with :class:`Activity`. + + It always returns :attr:`ActivityType.streaming`. + """ + return ActivityType.streaming + + def __str__(self): + return str(self.name) + + def __repr__(self): + return ''.format(self) + + @property + def twitch_name(self): + """Optional[:class:`str`]: If provided, the twitch name of the user streaming. + + This corresponds to the ``large_image`` key of the :attr:`Streaming.assets` + dictionary if it starts with ``twitch:``. Typically set by the Discord client. + """ + + try: + name = self.assets['large_image'] + except KeyError: + return None + else: + return name[7:] if name[:7] == 'twitch:' else None + + def to_dict(self): + ret = { + 'type': ActivityType.streaming.value, + 'name': str(self.name), + 'url': str(self.url), + 'assets': self.assets + } + if self.details: + ret['details'] = self.details + return ret + + def __eq__(self, other): + return isinstance(other, Streaming) and other.name == self.name and other.url == self.url + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self.name) + +class Spotify: + """Represents a Spotify listening activity from Discord. This is a special case of + :class:`Activity` that makes it easier to work with the Spotify integration. + + .. container:: operations + + .. describe:: x == y + + Checks if two activities are equal. + + .. describe:: x != y + + Checks if two activities are not equal. + + .. describe:: hash(x) + + Returns the activity's hash. + + .. describe:: str(x) + + Returns the string 'Spotify'. + """ + + __slots__ = ('_state', '_details', '_timestamps', '_assets', '_party', '_sync_id', '_session_id') + + def __init__(self, **data): + self._state = data.pop('state', None) + self._details = data.pop('details', None) + self._timestamps = data.pop('timestamps', {}) + self._assets = data.pop('assets', {}) + self._party = data.pop('party', {}) + self._sync_id = data.pop('sync_id') + self._session_id = data.pop('session_id') + + @property + def type(self): + """Returns the activity's type. This is for compatibility with :class:`Activity`. + + It always returns :attr:`ActivityType.listening`. + """ + return ActivityType.listening + + def to_dict(self): + return { + 'flags': 48, # SYNC | PLAY + 'name': 'Spotify', + 'assets': self._assets, + 'party': self._party, + 'sync_id': self._sync_id, + 'session_id': self.session_id, + 'timestamps': self._timestamps, + 'details': self._details, + 'state': self._state + } + + @property + def name(self): + """:class:`str`: The activity's name. This will always return "Spotify".""" + return 'Spotify' + + def __eq__(self, other): + return isinstance(other, Spotify) and other._session_id == self._session_id + + def __ne__(self, other): + return not self.__eq__(other) + + def __hash__(self): + return hash(self._session_id) + + def __str__(self): + return 'Spotify' + + def __repr__(self): + return ''.format(self) + + @property + def title(self): + """:class:`str`: The title of the song being played.""" + return self._details + + @property + def artists(self): + """List[:class:`str`]: The artists of the song being played.""" + return self._state.split(';') + + @property + def artist(self): + """:class:`str`: The artist of the song being played. + + This does not attempt to split the artist information into + multiple artists. Useful if there's only a single artist. + """ + return self._state + + @property + def album(self): + """:class:`str`: The album that the song being played belongs to.""" + return self._assets.get('large_text', '') + + @property + def album_cover_url(self): + """:class:`str`: The album cover image URL from Spotify's CDN.""" + large_image = self._assets.get('large_image', '') + if large_image[:8] != 'spotify:': + return '' + album_image_id = large_image[8:] + return 'https://i.scdn.co/image/' + album_image_id + + @property + def track_id(self): + """:class:`str`: The track ID used by Spotify to identify this song.""" + return self._sync_id + + @property + def start(self): + """:class:`datetime.datetime`: When the user started playing this song in UTC.""" + return datetime.datetime.utcfromtimestamp(self._timestamps['start'] / 1000) + + @property + def end(self): + """:class:`datetime.datetime`: When the user will stop playing this song in UTC.""" + return datetime.datetime.utcfromtimestamp(self._timestamps['end'] / 1000) + + @property + def duration(self): + """:class:`datetime.timedelta`: The duration of the song being played.""" + return self.end - self.start + + @property + def party_id(self): + """:class:`str`: The party ID of the listening party.""" + return self._party.get('id', '') + +def create_activity(data): + if not data: + return None + + game_type = try_enum(ActivityType, data.get('type', -1)) + if game_type is ActivityType.playing: + if 'application_id' in data or 'session_id' in data: + return Activity(**data) + return Game(**data) + elif game_type is ActivityType.streaming: + if 'url' in data: + return Streaming(**data) + return Activity(**data) + elif game_type is ActivityType.listening and 'sync_id' in data and 'session_id' in data: + return Spotify(**data) + return Activity(**data) diff --git a/discord/client.py b/discord/client.py index d2a4951e6..bd4c35e9b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -72,8 +72,8 @@ class Client: .. _ProxyConnector: http://aiohttp.readthedocs.org/en/stable/client_reference.html#proxyconnector Parameters - ---------- - max_messages : Optional[int] + ----------- + max_messages : Optional[:class:`int`] The maximum number of messages to store in the internal message cache. This defaults to 5000. Passing in `None` or a value less than 100 will use the default instead of the passed in value. @@ -82,24 +82,24 @@ class Client: in which case the default event loop is used via ``asyncio.get_event_loop()``. connector : aiohttp.BaseConnector The `connector`_ to use for connection pooling. - proxy : Optional[str] + proxy : Optional[:class:`str`] Proxy URL. proxy_auth : Optional[aiohttp.BasicAuth] An object that represents proxy HTTP Basic Authorization. - shard_id : Optional[int] + shard_id : Optional[:class:`int`] Integer starting at 0 and less than shard_count. - shard_count : Optional[int] + shard_count : Optional[:class:`int`] The total number of shards. - fetch_offline_members: bool + fetch_offline_members: :class:`bool` Indicates if :func:`on_ready` should be delayed to fetch all offline members from the guilds the bot belongs to. If this is ``False``\, then no offline members are received and :meth:`request_offline_members` must be used to fetch the offline members of the guild. - game: Optional[:class:`Game`] - A game to start your presence with upon logging on to Discord. status: Optional[:class:`Status`] A status to start your presence with upon logging on to Discord. - heartbeat_timeout: float + activity: Optional[Union[:class:`Activity`, :class:`Game`, :class:`Streaming`]] + An activity to start your presence with upon logging on to Discord. + heartbeat_timeout: :class:`float` The maximum numbers of seconds before timing out and restarting the WebSocket in the case of not receiving a HEARTBEAT_ACK. Useful if processing the initial packets take too long to the point of disconnecting @@ -794,23 +794,24 @@ class Client: return self.event(coro) @asyncio.coroutine - def change_presence(self, *, game=None, status=None, afk=False): + def change_presence(self, *, activity=None, status=None, afk=False): """|coro| Changes the client's presence. - The game parameter is a Game object (not a string) that represents - a game being played currently. + The activity parameter is a :class:`Activity` object (not a string) that represents + the activity being done currently. This could also be the slimmed down versions, + :class:`Game` and :class:`Streaming`. Example: :: - game = discord.Game(name="with the API") - await client.change_presence(status=discord.Status.idle, game=game) + game = discord.Game("with the API") + await client.change_presence(status=discord.Status.idle, activity=game) Parameters ---------- - game: Optional[:class:`Game`] - The game being played. None if no game is being played. + activity: Optional[Union[:class:`Game`, :class:`Streaming`, :class:`Activity`]] + The activity being done. ``None`` if no currently active activity is done. status: Optional[:class:`Status`] Indicates what status to change to. If None, then :attr:`Status.online` is used. @@ -822,7 +823,7 @@ class Client: Raises ------ InvalidArgument - If the ``game`` parameter is not :class:`Game` or None. + If the ``activity`` parameter is not the proper type. """ if status is None: @@ -835,14 +836,14 @@ class Client: status_enum = status status = str(status) - yield from self.ws.change_presence(game=game, status=status, afk=afk) + yield from self.ws.change_presence(activity=activity, status=status, afk=afk) for guild in self._connection.guilds: me = guild.me if me is None: continue - me.game = game + me.activity = activity me.status = status_enum # Guild stuff diff --git a/discord/enums.py b/discord/enums.py index e215e4824..f243359e2 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -28,7 +28,8 @@ from enum import Enum, IntEnum __all__ = ['ChannelType', 'MessageType', 'VoiceRegion', 'VerificationLevel', 'ContentFilter', 'Status', 'DefaultAvatar', 'RelationshipType', - 'AuditLogAction', 'AuditLogActionCategory', 'UserFlags', ] + 'AuditLogAction', 'AuditLogActionCategory', 'UserFlags', + 'ActivityType', ] class ChannelType(Enum): text = 0 @@ -212,6 +213,14 @@ class UserFlags(Enum): partner = 2 hypesquad = 4 +class ActivityType(IntEnum): + unknown = -1 + playing = 0 + streaming = 1 + listening = 2 + watching = 3 + + def try_enum(cls, val): """A function that tries to turn the value into enum ``cls``. diff --git a/discord/game.py b/discord/game.py deleted file mode 100644 index 8ca83c1dd..000000000 --- a/discord/game.py +++ /dev/null @@ -1,87 +0,0 @@ -# -*- coding: utf-8 -*- - -""" -The MIT License (MIT) - -Copyright (c) 2015-2017 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 Game: - """Represents a Discord game. - - .. container:: operations - - .. describe:: x == y - - Checks if two games are equal. - - .. describe:: x != y - - Checks if two games are not equal. - - .. describe:: hash(x) - - Returns the game's hash. - - .. describe:: str(x) - - Returns the game's name. - - Attributes - ----------- - name: :class:`str` - The game's name. - url: :class:`str` - The game's URL. Usually used for twitch streaming. - type: :class:`int` - The type of game being played. 1 indicates "Streaming". - """ - - __slots__ = ('name', 'type', 'url') - - def __init__(self, **kwargs): - self.name = kwargs.get('name') - self.url = kwargs.get('url') - self.type = kwargs.get('type', 0) - - def __str__(self): - return str(self.name) - - def __repr__(self): - return ''.format(self) - - def _iterator(self): - for attr in self.__slots__: - value = getattr(self, attr, None) - if value is not None: - yield (attr, value) - - def __iter__(self): - return self._iterator() - - def __eq__(self, other): - return isinstance(other, Game) and other.name == self.name - - def __ne__(self, other): - return not self.__eq__(other) - - def __hash__(self): - return hash(self.name) diff --git a/discord/gateway.py b/discord/gateway.py index 6d535a380..7bd7c8dee 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -30,7 +30,7 @@ import websockets import asyncio from . import utils, compat -from .game import Game +from .activity import create_activity, _ActivityTag from .errors import ConnectionClosed, InvalidArgument import logging import zlib, json @@ -283,10 +283,10 @@ class DiscordWebSocket(websockets.client.WebSocketClientProtocol): payload['d']['shard'] = [self.shard_id, self.shard_count] state = self._connection - if state._game is not None or state._status is not None: + if state._activity is not None or state._status is not None: payload['d']['presence'] = { 'status': state._status, - 'game': state._game, + 'game': state._activity, 'since': 0, 'afk': False } @@ -469,19 +469,19 @@ class DiscordWebSocket(websockets.client.WebSocketClientProtocol): raise ConnectionClosed(e, shard_id=self.shard_id) from e @asyncio.coroutine - def change_presence(self, *, game=None, status=None, afk=False, since=0.0): - if game is not None and not isinstance(game, Game): - raise InvalidArgument('game must be of type Game or None') + def change_presence(self, *, activity=None, status=None, afk=False, since=0.0): + if activity is not None: + if not isinstance(activity, _ActivityTag): + raise InvalidArgument('activity must be one of Game, Streaming, or Activity.') + activity = activity.to_dict() if status == 'idle': since = int(time.time() * 1000) - sent_game = dict(game) if game else None - payload = { 'op': self.PRESENCE, 'd': { - 'game': sent_game, + 'game': activity, 'afk': afk, 'since': since, 'status': status diff --git a/discord/guild.py b/discord/guild.py index 37f0c60c0..6141d0f2d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -32,7 +32,7 @@ from collections import namedtuple, defaultdict from . import utils from .role import Role from .member import Member, VoiceState -from .game import Game +from .activity import create_activity from .permissions import PermissionOverwrite from .colour import Colour from .errors import InvalidArgument, ClientException @@ -243,8 +243,7 @@ class Guild(Hashable): member = self.get_member(user_id) if member is not None: member.status = try_enum(Status, presence['status']) - game = presence.get('game', {}) - member.game = Game(**game) if game else None + member.activity = create_activity(presence.get('game')) if 'channels' in data: channels = data['channels'] diff --git a/discord/member.py b/discord/member.py index 319d252b4..11bbfcf4f 100644 --- a/discord/member.py +++ b/discord/member.py @@ -32,7 +32,7 @@ import discord.abc from . import utils from .user import BaseUser, User -from .game import Game +from .activity import create_activity from .permissions import Permissions from .enums import Status, try_enum from .colour import Colour @@ -137,25 +137,25 @@ class Member(discord.abc.Messageable, _BaseUser): Attributes ---------- - roles + roles: List[:class:`Role`] A :class:`list` of :class:`Role` that the member belongs to. Note that the first element of this list is always the default '@everyone' role. These roles are sorted by their position in the role hierarchy. - joined_at : `datetime.datetime` + joined_at: `datetime.datetime` A datetime object that specifies the date and time in UTC that the member joined the guild for the first time. status : :class:`Status` The member's status. There is a chance that the status will be a :class:`str` if it is a value that is not recognised by the enumerator. - game : :class:`Game` - The game that the user is currently playing. Could be None if no game is being played. - guild : :class:`Guild` + activity: Union[:class:`Game`, :class:`Streaming`, :class:`Activity`] + The activity that the user is currently doing. Could be None if no activity is being done. + guild: :class:`Guild` The guild that the member belongs to. - nick : Optional[:class:`str`] + nick: Optional[:class:`str`] The guild specific nickname of the user. """ - __slots__ = ('roles', 'joined_at', 'status', 'game', 'guild', 'nick', '_user', '_state') + __slots__ = ('roles', 'joined_at', 'status', 'activity', 'guild', 'nick', '_user', '_state') def __init__(self, *, data, guild, state): self._state = state @@ -164,8 +164,7 @@ class Member(discord.abc.Messageable, _BaseUser): self.joined_at = utils.parse_time(data.get('joined_at')) self._update_roles(data) self.status = Status.offline - game = data.get('game', {}) - self.game = Game(**game) if game else None + self.activity = create_activity(data.get('game')) self.nick = data.get('nick', None) def __str__(self): @@ -218,8 +217,8 @@ class Member(discord.abc.Messageable, _BaseUser): def _presence_update(self, data, user): self.status = try_enum(Status, data['status']) - game = data.get('game', {}) - self.game = Game(**game) if game else None + self.activity = create_activity(data.get('game')) + u = self._user u.name = user.get('username', u.name) u.avatar = user.get('avatar', u.avatar) diff --git a/discord/message.py b/discord/message.py index aef1cb1a6..68842a116 100644 --- a/discord/message.py +++ b/discord/message.py @@ -175,6 +175,24 @@ class Message: 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. + activity: Optional[:class:`dict`] + The activity associated with this message. Sent with Rich-Presence related messages that for + example, request joining, spectating, or listening to or with another member. + + It is a dictionary with the following optional keys: + + - ``type``: An integer denoting the type of message activity being requested. + - ``party_id``: The party ID associated with the party. + application: Optional[:class:`dict`] + The rich presence enabled application associated with this message. + + It is a dictionary with the following keys: + + - ``id``: A string representing the application's ID. + - ``name``: A string representing the application's name. + - ``description``: A string representing the application's description. + - ``icon``: A string representing the icon ID of the application. + - ``cover_image``: A string representing the embed's image asset ID. """ __slots__ = ( '_edited_timestamp', 'tts', 'content', 'channel', 'webhook_id', @@ -182,13 +200,16 @@ class Message: '_cs_channel_mentions', '_cs_raw_mentions', 'attachments', '_cs_clean_content', '_cs_raw_channel_mentions', 'nonce', 'pinned', 'role_mentions', '_cs_raw_role_mentions', 'type', 'call', - '_cs_system_content', '_cs_guild', '_state', 'reactions' ) + '_cs_system_content', '_cs_guild', '_state', 'reactions', + 'application', 'activity' ) def __init__(self, *, state, channel, data): self._state = state self.id = int(data['id']) self.webhook_id = utils._get_as_snowflake(data, 'webhook_id') self.reactions = [Reaction(message=self, data=d) for d in data.get('reactions', [])] + self.application = data.get('application') + self.activity = data.get('activity') self._update(channel, data) def __repr__(self): @@ -242,6 +263,8 @@ class Message: self.channel = channel self._edited_timestamp = utils.parse_time(data.get('edited_timestamp')) self._try_patch(data, 'pinned') + self._try_patch(data, 'application') + self._try_patch(data, 'activity') self._try_patch(data, 'mention_everyone') self._try_patch(data, 'tts') self._try_patch(data, 'type', lambda x: try_enum(MessageType, x)) diff --git a/discord/shard.py b/discord/shard.py index b29e45d0c..284f5cdca 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -307,18 +307,24 @@ class AutoShardedClient(Client): yield from self.http.close() @asyncio.coroutine - def change_presence(self, *, game=None, status=None, afk=False, shard_id=None): + def change_presence(self, *, activity=None, status=None, afk=False, shard_id=None): """|coro| Changes the client's presence. - The game parameter is a Game object (not a string) that represents - a game being played currently. + The activity parameter is a :class:`Activity` object (not a string) that represents + the activity being done currently. This could also be the slimmed down versions, + :class:`Game` and :class:`Streaming`. + + Example: :: + + game = discord.Game("with the API") + await client.change_presence(status=discord.Status.idle, activity=game) Parameters ---------- - game: Optional[:class:`Game`] - The game being played. None if no game is being played. + activity: Optional[Union[:class:`Game`, :class:`Streaming`, :class:`Activity`]] + The activity being done. ``None`` if no currently active activity is done. status: Optional[:class:`Status`] Indicates what status to change to. If None, then :attr:`Status.online` is used. @@ -334,7 +340,7 @@ class AutoShardedClient(Client): Raises ------ InvalidArgument - If the ``game`` parameter is not :class:`Game` or None. + If the ``activity`` parameter is not of proper type. """ if status is None: @@ -349,12 +355,12 @@ class AutoShardedClient(Client): if shard_id is None: for shard in self.shards.values(): - yield from shard.ws.change_presence(game=game, status=status, afk=afk) + yield from shard.ws.change_presence(activity=activity, status=status, afk=afk) guilds = self._connection.guilds else: shard = self.shards[shard_id] - yield from shard.ws.change_presence(game=game, status=status, afk=afk) + yield from shard.ws.change_presence(activity=activity, status=status, afk=afk) guilds = [g for g in self._connection.guilds if g.shard_id == shard_id] for guild in guilds: @@ -362,5 +368,5 @@ class AutoShardedClient(Client): if me is None: continue - me.game = game + me.activity = activity me.status = status_enum diff --git a/discord/state.py b/discord/state.py index d9a538862..731cb0962 100644 --- a/discord/state.py +++ b/discord/state.py @@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE. """ from .guild import Guild +from .activity import _ActivityTag from .user import User, ClientUser from .emoji import Emoji, PartialEmoji from .message import Message @@ -67,9 +68,12 @@ class ConnectionState: self.heartbeat_timeout = options.get('heartbeat_timeout', 60.0) self._listeners = [] - game = options.get('game', None) - if game: - game = dict(game) + activity = options.get('activity', None) + if activity: + if not isinstance(activity, _ActivityTag): + raise TypeError('activity parameter must be one of Game, Streaming, or Activity.') + + activity = activity.to_dict() status = options.get('status', None) if status: @@ -78,7 +82,7 @@ class ConnectionState: else: status = str(status) - self._game = game + self._activity = activity self._status = status self.clear() diff --git a/docs/api.rst b/docs/api.rst index 30b683742..fc08aee2e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -687,6 +687,27 @@ All enumerations are subclasses of `enum`_. The system message denoting that a new member has joined a Guild. +.. class:: ActivityType + + Specifies the type of :class:`Activity`. This is used to check how to + interpret the activity itself. + + .. attribute:: unknown + + An unknown activity type. This should generally not happen. + .. attribute:: playing + + A "Playing" activity type. + .. attribute:: streaming + + A "Streaming" activity type. + .. attribute:: listening + + A "Listening" activity type. + .. attribute:: watching + + A "Watching" activity type. + .. class:: VoiceRegion Specifies the region a voice server belongs to. @@ -698,7 +719,7 @@ All enumerations are subclasses of `enum`_. The US East region. .. attribute:: us_south - + The US South region. .. attribute:: us_central @@ -729,10 +750,10 @@ All enumerations are subclasses of `enum`_. The Brazil region. .. attribute:: hongkong - + The Hong Kong region. .. attribute:: russia - + The Russia region. .. attribute:: vip_us_east @@ -1880,6 +1901,12 @@ Member .. autocomethod:: typing :async-with: +Spotify +~~~~~~~~ + +.. autoclass:: Spotify() + :members: + VoiceState ~~~~~~~~~~~ @@ -2011,12 +2038,24 @@ Colour .. autoclass:: Colour :members: +Activity +~~~~~~~~~ + +.. autoclass:: Activity + :members: + Game -~~~~ +~~~~~ .. autoclass:: Game :members: +Streaming +~~~~~~~~~~~ + +.. autoclass:: Streaming + :members: + Permissions ~~~~~~~~~~~~