diff --git a/discord/__init__.py b/discord/__init__.py index dfde548f9..8b52d375e 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -20,7 +20,8 @@ __version__ = '1.0.0a' from collections import namedtuple import logging -from .client import Client, AppInfo +from .client import Client +from .appinfo import AppInfo from .user import User, ClientUser, Profile from .emoji import Emoji, PartialEmoji from .activity import * @@ -29,6 +30,7 @@ from .guild import Guild from .relationship import Relationship from .member import Member, VoiceState from .message import Message, Attachment +from .asset import Asset from .errors import * from .calls import CallMessage, GroupCall from .permissions import Permissions, PermissionOverwrite diff --git a/discord/appinfo.py b/discord/appinfo.py new file mode 100644 index 000000000..c93113ce2 --- /dev/null +++ b/discord/appinfo.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2019 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 +from .asset import Asset + + +class AppInfo: + """Represents the application info for the bot provided by Discord. + + + Attributes + ------------- + id: :class:`int` + The application ID. + name: :class:`str` + The application name. + owner: :class:`User` + The application's owner. + icon: Optional[:class:`str`] + The icon hash. + description: Optional[:class:`str`] + The application description. + bot_public: :class:`bool` + Whether the bot is public. + bot_require_code_grant: :class:`bool` + Whether the bot requires the completion of the full oauth2 code + grant flow to join. + rpc_origins: Optional[List[:class:`str`]] + A list of RPC origin URLs, if RPC is enabled. + """ + __slots__ = ('_state', 'description', 'id', 'name', 'rpc_origins', + 'bot_public', 'bot_require_code_grant', 'owner', 'icon') + + def __init__(self, state, data): + self._state = state + + self.id = int(data['id']) + self.name = data['name'] + self.description = data['description'] + self.icon = data['icon'] + self.rpc_origins = data['rpc_origins'] + self.bot_public = data['bot_public'] + self.bot_require_code_grant = data['bot_require_code_grant'] + self.owner = User(state=self._state, data=data['owner']) + + @property + def icon_url(self): + """:class:`.Asset`: Retrieves the application's icon asset.""" + return Asset._from_icon(self._state, self, 'app') diff --git a/discord/asset.py b/discord/asset.py new file mode 100644 index 000000000..4e592f678 --- /dev/null +++ b/discord/asset.py @@ -0,0 +1,157 @@ +# -*- coding: utf-8 -*- + +""" +The MIT License (MIT) + +Copyright (c) 2015-2019 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 io +from .errors import DiscordException +from .errors import InvalidArgument +from . import utils + +VALID_STATIC_FORMATS = frozenset({"jpeg", "jpg", "webp", "png"}) +VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} + +class Asset: + """Represents a CDN asset on Discord. + + .. container:: operations + + .. describe:: str(x) + + Returns the URL of the CDN asset. + + .. describe:: len(x) + + Returns the length of the CDN asset's URL. + + .. describe:: bool(x) + + Checks if the Asset has a URL. + """ + __slots__ = ('_state', '_url') + + def __init__(self, state, url=None): + self._state = state + self._url = url + + @classmethod + def _from_avatar(cls, state, user, *, format=None, static_format='webp', size=1024): + if not utils.valid_icon_size(size): + raise InvalidArgument("size must be a power of 2 between 16 and 1024") + if format is not None and format not in VALID_AVATAR_FORMATS: + raise InvalidArgument("format must be None or one of {}".format(VALID_AVATAR_FORMATS)) + if format == "gif" and not user.is_avatar_animated(): + raise InvalidArgument("non animated avatars do not support gif format") + if static_format not in VALID_STATIC_FORMATS: + raise InvalidArgument("static_format must be one of {}".format(VALID_STATIC_FORMATS)) + + if user.avatar is None: + return user.default_avatar_url + + if format is None: + format = 'gif' if user.is_avatar_animated() else static_format + + return cls(state, 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(user, format, size)) + + @classmethod + def _from_icon(cls, state, object, path): + if object.icon is None: + return cls(state) + + url = 'https://cdn.discordapp.com/{0}-icons/{1.id}/{1.icon}.jpg'.format(path, object) + return cls(state, url) + + @classmethod + def _from_guild_image(cls, state, id, hash, key, *, format='webp', size=1024): + if not utils.valid_icon_size(size): + raise InvalidArgument("size must be a power of 2 between 16 and 4096") + if format not in VALID_STATIC_FORMATS: + raise InvalidArgument("format must be one of {}".format(VALID_STATIC_FORMATS)) + + if hash is None: + return Asset(state) + + url = 'https://cdn.discordapp.com/{key}/{0}/{1}.{2}?size={3}' + return cls(state, url.format(id, hash, format, size, key=key)) + + def __str__(self): + return self._url + + def __len__(self): + return len(self._url) + + def __bool__(self): + return self._url is not None + + def __repr__(self): + return ''.format(self) + + async def save(self, fp, *, seek_begin=True): + """|coro| + + Saves this asset into a file-like object. + + Parameters + ----------- + fp: Union[BinaryIO, :class:`os.PathLike`] + Same as in :meth:`Attachment.save`. + seek_begin: :class:`bool` + Same as in :meth:`Attachment.save`. + + Raises + -------- + DiscordException + There was no valid URL or internal connection state. + + .. note:: + + :class:`PartialEmoji` will not have a state if you make + your own instance via ``PartialEmoji(animated=False, name='x', id=2345678)``. + + The URL will not be provided if there is no custom image. + HTTPException + Saving the asset failed. + NotFound + The asset was deleted. + + Returns + -------- + :class:`int` + The number of bytes written. + """ + if not self._url: + raise DiscordException('Invalid asset (no URL provided)') + + if self._state is None: + raise DiscordException('Invalid state (no ConnectionState provided)') + + data = await self._state.http.get_from_cdn(self._url) + if isinstance(fp, io.IOBase) and fp.writable(): + written = fp.write(data) + if seek_begin: + fp.seek(0) + return written + else: + with open(fp, 'wb') as f: + return f.write(data) diff --git a/discord/channel.py b/discord/channel.py index a64dd5ceb..67b8981ce 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -32,6 +32,7 @@ from .permissions import Permissions from .enums import ChannelType, try_enum from .mixins import Hashable from . import utils +from .asset import Asset from .errors import ClientException, NoMoreItems from .webhook import Webhook @@ -996,11 +997,8 @@ class GroupChannel(discord.abc.Messageable, Hashable): @property def icon_url(self): - """Returns the channel's icon URL if available or an empty string otherwise.""" - if self.icon is None: - return '' - - return 'https://cdn.discordapp.com/channel-icons/{0.id}/{0.icon}.jpg'.format(self) + """:class:`Asset`: Returns the channel's icon asset if available.""" + return Asset._from_icon(self._state, self, 'channel') @property def created_at(self): diff --git a/discord/client.py b/discord/client.py index f8ac3b84f..3ad4f7ae0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -35,6 +35,7 @@ import aiohttp import websockets from .user import User, Profile +from .asset import Asset from .invite import Invite from .widget import Widget from .guild import Guild @@ -50,21 +51,10 @@ from . import utils from .backoff import ExponentialBackoff from .webhook import Webhook from .iterators import GuildIterator +from .appinfo import AppInfo log = logging.getLogger(__name__) -AppInfo = namedtuple('AppInfo', - 'id name description rpc_origins bot_public bot_require_code_grant icon owner') - -def app_info_icon_url(self): - """Retrieves the application's icon_url if it exists. Empty string otherwise.""" - if not self.icon: - return '' - - return 'https://cdn.discordapp.com/app-icons/{0.id}/{0.icon}.jpg'.format(self) - -AppInfo.icon_url = property(app_info_icon_url) - class Client: r"""Represents a client connection that connects to Discord. This class is used to interact with the Discord WebSocket and API. @@ -1060,11 +1050,7 @@ class Client: data = await self.http.application_info() if 'rpc_origins' not in data: data['rpc_origins'] = None - return AppInfo(id=int(data['id']), name=data['name'], - description=data['description'], icon=data['icon'], - rpc_origins=data['rpc_origins'], bot_public=data['bot_public'], - bot_require_code_grant=data['bot_require_code_grant'], - owner=User(state=self._connection, data=data['owner'])) + return AppInfo(self._connection, data) async def fetch_user(self, user_id): """|coro| diff --git a/discord/emoji.py b/discord/emoji.py index b87a7fd90..5a31f4183 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -24,11 +24,10 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from collections import namedtuple - +from .asset import Asset from . import utils -class PartialEmoji(namedtuple('PartialEmoji', 'animated name id')): +class PartialEmoji: """Represents a "partial" emoji. This model will be given in two scenarios: @@ -65,7 +64,19 @@ class PartialEmoji(namedtuple('PartialEmoji', 'animated name id')): The ID of the custom emoji, if applicable. """ - __slots__ = () + __slots__ = ('animated', 'name', 'id', '_state') + + def __init__(self, *, animated, name, id=None): + self.animated = animated + self.name = name + self.id = id + self._state = None + + @classmethod + def with_state(cls, state, *, animated, name, id=None): + self = cls(animated=animated, name=name, id=id) + self._state = state + return self def __str__(self): if self.id is None: @@ -81,6 +92,9 @@ class PartialEmoji(namedtuple('PartialEmoji', 'animated name id')): if isinstance(other, (PartialEmoji, Emoji)): return self.id == other.id + def __ne__(self, other): + return not self == other + def __hash__(self): return hash((self.id, self.name)) @@ -99,12 +113,13 @@ class PartialEmoji(namedtuple('PartialEmoji', 'animated name id')): @property def url(self): - """Returns a URL version of the emoji, if it is custom.""" + """:class:`Asset`:Returns an asset of the emoji, if it is custom.""" if self.is_unicode_emoji(): - return None + return Asset(self._state) _format = 'gif' if self.animated else 'png' - return "https://cdn.discordapp.com/emojis/{0.id}.{1}".format(self, _format) + url = "https://cdn.discordapp.com/emojis/{0.id}.{1}".format(self, _format) + return Asset(self._state, url) class Emoji: """Represents a custom emoji. @@ -186,6 +201,9 @@ class Emoji: def __eq__(self, other): return isinstance(other, (PartialEmoji, Emoji)) and self.id == other.id + def __ne__(self, other): + return not self == other + def __hash__(self): return self.id >> 22 @@ -198,7 +216,8 @@ class Emoji: def url(self): """Returns a URL version of the emoji.""" _format = 'gif' if self.animated else 'png' - return "https://cdn.discordapp.com/emojis/{0.id}.{1}".format(self, _format) + url = "https://cdn.discordapp.com/emojis/{0.id}.{1}".format(self, _format) + return Asset(self._state, url) @property def roles(self): diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 13d7e41ca..98d601fdc 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -400,7 +400,8 @@ class PartialEmojiConverter(Converter): emoji_name = match.group(2) emoji_id = int(match.group(3)) - return discord.PartialEmoji(animated=emoji_animated, name=emoji_name, id=emoji_id) + return discord.PartialEmoji.with_state(ctx.bot._connection, animated=emoji_animated, name=emoji_name, + id=emoji_id) raise BadArgument('Couldn\'t convert "{}" to PartialEmoji.'.format(argument)) diff --git a/discord/guild.py b/discord/guild.py index 698235684..ddaf241c0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -37,14 +37,12 @@ from .errors import InvalidArgument, ClientException from .channel import * from .enums import VoiceRegion, Status, ChannelType, try_enum, VerificationLevel, ContentFilter, NotificationLevel from .mixins import Hashable -from .utils import valid_icon_size from .user import User from .invite import Invite from .iterators import AuditLogIterator from .webhook import Webhook from .widget import Widget - -VALID_ICON_FORMATS = {"jpeg", "jpg", "webp", "png"} +from .asset import Asset BanEntry = namedtuple('BanEntry', 'reason user') @@ -441,18 +439,10 @@ class Guild(Hashable): Returns -------- - :class:`str` - The resulting CDN URL. + :class:`Asset` + The resulting CDN asset. """ - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.icon is None: - return '' - - return 'https://cdn.discordapp.com/icons/{0.id}/{0.icon}.{1}?size={2}'.format(self, format, size) + return Asset._from_guild_image(self._state, self.id, self.icon, 'icons', format=format, size=size) @property def banner_url(self): @@ -479,18 +469,10 @@ class Guild(Hashable): Returns -------- - :class:`str` - The resulting CDN URL. + :class:`Asset` + The resulting CDN asset. """ - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.banner is None: - return '' - - return 'https://cdn.discordapp.com/banners/{0.id}/{0.banner}.{1}?size={2}'.format(self, format, size) + return Asset._from_guild_image(self._state, self.id, self.banner, 'banners', format=format, size=size) @property def splash_url(self): @@ -517,18 +499,10 @@ class Guild(Hashable): Returns -------- - :class:`str` - The resulting CDN URL. + :class:`Asset` + The resulting CDN asset. """ - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.splash is None: - return '' - - return 'https://cdn.discordapp.com/splashes/{0.id}/{0.splash}.{1}?size={2}'.format(self, format, size) + return Asset._from_guild_image(self._state, self.id, self.splash, 'splashes', format=format, size=size) @property def member_count(self): diff --git a/discord/http.py b/discord/http.py index 4c270895b..3fe82d5c3 100644 --- a/discord/http.py +++ b/discord/http.py @@ -224,16 +224,16 @@ class HTTPClient: # We've run out of retries, raise. raise HTTPException(r, data) - async def get_attachment(self, url): + async def get_from_cdn(self, url): async with self.__session.get(url) as resp: if resp.status == 200: return await resp.read() elif resp.status == 404: - raise NotFound(resp, 'attachment not found') + raise NotFound(resp, 'asset not found') elif resp.status == 403: - raise Forbidden(resp, 'cannot retrieve attachment') + raise Forbidden(resp, 'cannot retrieve asset') else: - raise HTTPException(resp, 'failed to get attachment') + raise HTTPException(resp, 'failed to get asset') # state management diff --git a/discord/invite.py b/discord/invite.py index 43d6a29c3..e79eb8951 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -24,14 +24,12 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from .utils import parse_time, valid_icon_size, snowflake_time +from .asset import Asset +from .utils import parse_time, snowflake_time from .mixins import Hashable -from .errors import InvalidArgument from .enums import ChannelType, VerificationLevel, try_enum from collections import namedtuple -VALID_ICON_FORMATS = {"jpeg", "jpg", "webp", "png"} - class PartialInviteChannel(namedtuple('PartialInviteChannel', 'id name type')): """Represents a "partial" invite channel. @@ -81,7 +79,7 @@ class PartialInviteChannel(namedtuple('PartialInviteChannel', 'id name type')): """Returns the channel's creation time in UTC.""" return snowflake_time(self.id) -class PartialInviteGuild(namedtuple('PartialInviteGuild', 'features icon banner id name splash verification_level description')): +class PartialInviteGuild: """Represents a "partial" invite guild. This model will be given when the user is not part of the @@ -125,7 +123,19 @@ class PartialInviteGuild(namedtuple('PartialInviteGuild', 'features icon banner The partial guild's description. """ - __slots__ = () + __slots__ = ('_state', 'features', 'icon', 'banner', 'id', 'name', 'splash', + 'verification_level', 'description') + + def __init__(self, state, data, id): + self._state = state + self.id = id + self.name = data['name'] + self.features = data.get('features', []) + self.icon = data.get('icon') + self.banner = data.get('banner') + self.splash = data.get('splash') + self.verification_level = try_enum(VerificationLevel, data.get('verification_level')) + self.description = data.get('description') def __str__(self): return self.name @@ -141,16 +151,8 @@ class PartialInviteGuild(namedtuple('PartialInviteGuild', 'features icon banner return self.icon_url_as() def icon_url_as(self, *, format='webp', size=1024): - """:class:`str`: The same operation as :meth:`Guild.icon_url_as`.""" - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.icon is None: - return '' - - return 'https://cdn.discordapp.com/icons/{0.id}/{0.icon}.{1}?size={2}'.format(self, format, size) + """:class:`Asset`: The same operation as :meth:`Guild.icon_url_as`.""" + return Asset._from_guild_image(self._state, self.id, self.icon, 'icons', format=format, size=size) @property def banner_url(self): @@ -158,16 +160,8 @@ class PartialInviteGuild(namedtuple('PartialInviteGuild', 'features icon banner return self.banner_url_as() def banner_url_as(self, *, format='webp', size=2048): - """:class:`str`: The same operation as :meth:`Guild.banner_url_as`.""" - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.banner is None: - return '' - - return 'https://cdn.discordapp.com/banners/{0.id}/{0.banner}.{1}?size={2}'.format(self, format, size) + """:class:`Asset`: The same operation as :meth:`Guild.banner_url_as`.""" + return Asset._from_guild_image(self._state, self.id, self.banner, 'banners', format=format, size=size) @property def splash_url(self): @@ -175,16 +169,8 @@ class PartialInviteGuild(namedtuple('PartialInviteGuild', 'features icon banner return self.splash_url_as() def splash_url_as(self, *, format='webp', size=2048): - """:class:`str`: The same operation as :meth:`Guild.splash_url_as`.""" - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 4096") - if format not in VALID_ICON_FORMATS: - raise InvalidArgument("format must be one of {}".format(VALID_ICON_FORMATS)) - - if self.splash is None: - return '' - - return 'https://cdn.discordapp.com/splashes/{0.id}/{0.splash}.{1}?size={2}'.format(self, format, size) + """:class:`Asset`: The same operation as :meth:`Guild.splash_url_as`.""" + return Asset._from_guild_image(self._state, self.id, self.splash, 'splashes', format=format, size=size) class Invite(Hashable): """Represents a Discord :class:`Guild` or :class:`abc.GuildChannel` invite. @@ -240,7 +226,6 @@ class Invite(Hashable): The channel the invite is for. """ - __slots__ = ('max_age', 'code', 'guild', 'revoked', 'created_at', 'uses', 'temporary', 'max_uses', 'inviter', 'channel', '_state', 'approximate_member_count', 'approximate_presence_count' ) @@ -274,14 +259,7 @@ class Invite(Hashable): guild_data = data['guild'] channel_type = try_enum(ChannelType, channel_data['type']) channel = PartialInviteChannel(id=channel_id, name=channel_data['name'], type=channel_type) - guild = PartialInviteGuild(id=guild_id, - name=guild_data['name'], - features=guild_data.get('features', []), - icon=guild_data.get('icon'), - banner=guild_data.get('banner'), - splash=guild_data.get('splash'), - verification_level=try_enum(VerificationLevel, guild_data.get('verification_level')), - description=guild_data.get('description')) + guild = PartialInviteGuild(state, guild_data, guild_id) data['guild'] = guild data['channel'] = channel return cls(state=state, data=data) diff --git a/discord/message.py b/discord/message.py index ae0f48019..e095ff590 100644 --- a/discord/message.py +++ b/discord/message.py @@ -112,7 +112,7 @@ class Attachment: The number of bytes written. """ url = self.proxy_url if use_cached else self.url - data = await self._http.get_attachment(url) + data = await self._http.get_from_cdn(url) if isinstance(fp, io.IOBase) and fp.writable(): written = fp.write(data) if seek_begin: diff --git a/discord/state.py b/discord/state.py index 5614375a6..ebde9ec07 100644 --- a/discord/state.py +++ b/discord/state.py @@ -402,7 +402,7 @@ class ConnectionState: def parse_message_reaction_add(self, data): emoji_data = data['emoji'] emoji_id = utils._get_as_snowflake(emoji_data, 'id') - emoji = PartialEmoji(animated=emoji_data['animated'], id=emoji_id, name=emoji_data['name']) + emoji = PartialEmoji.with_state(self, animated=emoji_data['animated'], id=emoji_id, name=emoji_data['name']) raw = RawReactionActionEvent(data, emoji) self.dispatch('raw_reaction_add', raw) @@ -428,7 +428,7 @@ class ConnectionState: def parse_message_reaction_remove(self, data): emoji_data = data['emoji'] emoji_id = utils._get_as_snowflake(emoji_data, 'id') - emoji = PartialEmoji(animated=emoji_data['animated'], id=emoji_id, name=emoji_data['name']) + emoji = PartialEmoji.with_state(self, animated=emoji_data['animated'], id=emoji_id, name=emoji_data['name']) raw = RawReactionActionEvent(data, emoji) self.dispatch('raw_reaction_remove', raw) diff --git a/discord/user.py b/discord/user.py index fcce10a55..d5000ffdd 100644 --- a/discord/user.py +++ b/discord/user.py @@ -27,13 +27,11 @@ DEALINGS IN THE SOFTWARE. from collections import namedtuple import discord.abc -from .utils import snowflake_time, _bytes_to_base64_data, parse_time, valid_icon_size +from .utils import snowflake_time, _bytes_to_base64_data, parse_time from .enums import DefaultAvatar, RelationshipType, UserFlags, HypeSquadHouse, PremiumType, try_enum -from .errors import ClientException, InvalidArgument +from .errors import ClientException from .colour import Colour - -VALID_STATIC_FORMATS = {"jpeg", "jpg", "webp", "png"} -VALID_AVATAR_FORMATS = VALID_STATIC_FORMATS | {"gif"} +from .asset import Asset class Profile(namedtuple('Profile', 'flags user mutual_guilds connected_accounts premium_since')): __slots__ = () @@ -158,28 +156,10 @@ class BaseUser(_BaseUser): Returns -------- - :class:`str` - The resulting CDN URL. + :class:`Asset` + The resulting CDN asset. """ - if not valid_icon_size(size): - raise InvalidArgument("size must be a power of 2 between 16 and 1024") - if format is not None and format not in VALID_AVATAR_FORMATS: - raise InvalidArgument("format must be None or one of {}".format(VALID_AVATAR_FORMATS)) - if format == "gif" and not self.is_avatar_animated(): - raise InvalidArgument("non animated avatars do not support gif format") - if static_format not in VALID_STATIC_FORMATS: - raise InvalidArgument("static_format must be one of {}".format(VALID_STATIC_FORMATS)) - - if self.avatar is None: - return self.default_avatar_url - - if format is None: - if self.is_avatar_animated(): - format = 'gif' - else: - format = static_format - - return 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size) + return Asset._from_avatar(self._state, self, format=format, static_format=static_format, size=size) @property def default_avatar(self): @@ -189,7 +169,7 @@ class BaseUser(_BaseUser): @property def default_avatar_url(self): """Returns a URL for a user's default avatar.""" - return 'https://cdn.discordapp.com/embed/avatars/{}.png'.format(self.default_avatar.value) + return Asset(self._state, 'https://cdn.discordapp.com/embed/avatars/{}.png'.format(self.default_avatar.value)) @property def colour(self): diff --git a/discord/webhook.py b/discord/webhook.py index cb8679d4e..d44a5e94f 100644 --- a/discord/webhook.py +++ b/discord/webhook.py @@ -34,6 +34,7 @@ import aiohttp from . import utils from .errors import InvalidArgument, HTTPException, Forbidden, NotFound from .user import BaseUser, User +from .asset import Asset __all__ = ['WebhookAdapter', 'AsyncWebhookAdapter', 'RequestsWebhookAdapter', 'Webhook'] @@ -548,12 +549,12 @@ class Webhook: Returns -------- - :class:`str` - The resulting CDN URL. + :class:`Asset` + The resulting CDN asset. """ if self.avatar is None: # Default is always blurple apparently - return 'https://cdn.discordapp.com/embed/avatars/0.png' + return Asset(self._state, 'https://cdn.discordapp.com/embed/avatars/0.png') if not utils.valid_icon_size(size): raise InvalidArgument("size must be a power of 2 between 16 and 1024") @@ -563,7 +564,8 @@ class Webhook: if format not in ('png', 'jpg', 'jpeg'): raise InvalidArgument("format must be one of 'png', 'jpg', or 'jpeg'.") - return 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size) + url = 'https://cdn.discordapp.com/avatars/{0.id}/{0.avatar}.{1}?size={2}'.format(self, format, size) + return Asset(self._state, url) def delete(self): """|maybecoro| @@ -661,7 +663,7 @@ class Webhook: username: :class:`str` The username to send with this message. If no username is provided then the default username for the webhook is used. - avatar_url: :class:`str` + avatar_url: Union[:class:`str`, :class:`Asset`] The avatar URL to send with this message. If no avatar URL is provided then the default avatar for the webhook is used. tts: :class:`bool` @@ -716,7 +718,7 @@ class Webhook: payload['tts'] = tts if avatar_url: - payload['avatar_url'] = avatar_url + payload['avatar_url'] = str(avatar_url) if username: payload['username'] = username diff --git a/discord/widget.py b/discord/widget.py index 40b5bda88..8d55801ee 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -31,8 +31,6 @@ from .invite import Invite from .enums import Status, try_enum from collections import namedtuple -VALID_ICON_FORMATS = {"jpeg", "jpg", "webp", "png"} - class WidgetChannel(namedtuple('WidgetChannel', 'id name position')): """Represents a "partial" widget channel. diff --git a/docs/api.rst b/docs/api.rst index e83d8b8f5..0c36ffe2f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -40,6 +40,9 @@ Client .. autoclass:: AutoShardedClient :members: +.. autoclass:: AppInfo + :members: + Voice ------ @@ -1918,6 +1921,12 @@ Attachment .. autoclass:: Attachment() :members: +Asset +~~~~~ + +.. autoclass:: Asset() + :members: + Message ~~~~~~~