diff --git a/discord/abc.py b/discord/abc.py index 71eaff6ab62c..7843161d38b8 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -248,6 +248,22 @@ def avatar(self) -> Optional[Asset]: """Optional[:class:`~discord.Asset`]: Returns an Asset that represents the user's avatar, if present.""" raise NotImplementedError + @property + def avatar_decoration(self) -> Optional[Asset]: + """Optional[:class:`~discord.Asset`]: Returns an Asset that represents the user's avatar decoration, if present. + + .. versionadded:: 2.4 + """ + raise NotImplementedError + + @property + def avatar_decoration_sku_id(self) -> Optional[int]: + """Optional[:class:`int`]: Returns an integer that represents the user's avatar decoration SKU ID, if present. + + .. versionadded:: 2.4 + """ + raise NotImplementedError + @property def default_avatar(self) -> Asset: """:class:`~discord.Asset`: Returns the default avatar for a given user.""" diff --git a/discord/asset.py b/discord/asset.py index d88ebb945d44..d08635632015 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -246,6 +246,15 @@ def _from_guild_avatar(cls, state: _State, guild_id: int, member_id: int, avatar animated=animated, ) + @classmethod + def _from_avatar_decoration(cls, state: _State, avatar_decoration: str) -> Self: + return cls( + state, + url=f'{cls.BASE}/avatar-decoration-presets/{avatar_decoration}.png?size=96', + key=avatar_decoration, + animated=True, + ) + @classmethod def _from_icon(cls, state: _State, object_id: int, icon_hash: str, path: str) -> Self: return cls( diff --git a/discord/member.py b/discord/member.py index 04ce645c9bef..4e7c5875f51c 100644 --- a/discord/member.py +++ b/discord/member.py @@ -67,7 +67,7 @@ UserWithMember as UserWithMemberPayload, ) from .types.gateway import GuildMemberUpdateEvent - from .types.user import User as UserPayload + from .types.user import User as UserPayload, AvatarDecorationData from .abc import Snowflake from .state import ConnectionState from .message import Message @@ -323,6 +323,7 @@ class Member(discord.abc.Messageable, _UserTag): '_state', '_avatar', '_flags', + '_avatar_decoration_data', ) if TYPE_CHECKING: @@ -342,6 +343,8 @@ class Member(discord.abc.Messageable, _UserTag): banner: Optional[Asset] accent_color: Optional[Colour] accent_colour: Optional[Colour] + avatar_decoration: Optional[Asset] + avatar_decoration_sku_id: Optional[int] def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state @@ -357,6 +360,7 @@ def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: Connecti self._avatar: Optional[str] = data.get('avatar') self._permissions: Optional[int] self._flags: int = data['flags'] + self._avatar_decoration_data: Optional[AvatarDecorationData] = data.get('avatar_decoration_data') try: self._permissions = int(data['permissions']) except KeyError: @@ -425,6 +429,7 @@ def _copy(cls, member: Self) -> Self: self._permissions = member._permissions self._state = member._state self._avatar = member._avatar + self._avatar_decoration_data = member._avatar_decoration_data # Reference will not be copied unless necessary by PRESENCE_UPDATE # See below @@ -453,6 +458,7 @@ def _update(self, data: GuildMemberUpdateEvent) -> None: self._roles = utils.SnowflakeList(map(int, data['roles'])) self._avatar = data.get('avatar') self._flags = data.get('flags', 0) + self._avatar_decoration_data = data.get('avatar_decoration_data') def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> Optional[Tuple[User, User]]: self.activities = tuple(create_activity(d, self._state) for d in data['activities']) @@ -464,7 +470,16 @@ def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> Op def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: u = self._user - original = (u.name, u.discriminator, u._avatar, u.global_name, u._public_flags) + original = ( + u.name, + u.discriminator, + u._avatar, + u.global_name, + u._public_flags, + u._avatar_decoration_data['sku_id'] if u._avatar_decoration_data is not None else None, + ) + + decoration_payload = user.get('avatar_decoration_data') # These keys seem to always be available modified = ( user['username'], @@ -472,10 +487,18 @@ def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: user['avatar'], user.get('global_name'), user.get('public_flags', 0), + decoration_payload['sku_id'] if decoration_payload is not None else None, ) if original != modified: to_return = User._copy(self._user) - u.name, u.discriminator, u._avatar, u.global_name, u._public_flags = modified + u.name, u.discriminator, u._avatar, u.global_name, u._public_flags, u._avatar_decoration_data = ( + user['username'], + user['discriminator'], + user['avatar'], + user.get('global_name'), + user.get('public_flags', 0), + decoration_payload, + ) # Signal to dispatch on_user_update return to_return, u diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 3175fd9f0744..866c32946cc3 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -40,7 +40,7 @@ from .sticker import GuildSticker from .appinfo import GatewayAppInfo, PartialAppInfo from .guild import Guild, UnavailableGuild -from .user import User +from .user import User, AvatarDecorationData from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .audit_log import AuditLogEntry @@ -224,6 +224,7 @@ class GuildMemberUpdateEvent(TypedDict): mute: NotRequired[bool] pending: NotRequired[bool] communication_disabled_until: NotRequired[str] + avatar_decoration_data: NotRequired[AvatarDecorationData] class GuildEmojisUpdateEvent(TypedDict): diff --git a/discord/types/member.py b/discord/types/member.py index ad9e49008a12..6968edb6f47f 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -24,7 +24,8 @@ from typing import Optional, TypedDict from .snowflake import SnowflakeList -from .user import User +from .user import User, AvatarDecorationData +from typing_extensions import NotRequired class Nickname(TypedDict): @@ -47,6 +48,7 @@ class Member(PartialMember, total=False): pending: bool permissions: str communication_disabled_until: str + avatar_decoration_data: NotRequired[AvatarDecorationData] class _OptionalMemberWithUser(PartialMember, total=False): @@ -56,6 +58,7 @@ class _OptionalMemberWithUser(PartialMember, total=False): pending: bool permissions: str communication_disabled_until: str + avatar_decoration_data: NotRequired[AvatarDecorationData] class MemberWithUser(_OptionalMemberWithUser): diff --git a/discord/types/user.py b/discord/types/user.py index 7a34e44bb786..1f027ce9d9ac 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -24,6 +24,12 @@ from .snowflake import Snowflake from typing import Literal, Optional, TypedDict +from typing_extensions import NotRequired + + +class AvatarDecorationData(TypedDict): + asset: str + sku_id: Snowflake class PartialUser(TypedDict): @@ -32,6 +38,7 @@ class PartialUser(TypedDict): discriminator: str avatar: Optional[str] global_name: Optional[str] + avatar_decoration_data: NotRequired[AvatarDecorationData] PremiumType = Literal[0, 1, 2, 3] diff --git a/discord/user.py b/discord/user.py index cc836374a40b..b0ba869cbd37 100644 --- a/discord/user.py +++ b/discord/user.py @@ -31,7 +31,7 @@ from .colour import Colour from .enums import DefaultAvatar from .flags import PublicUserFlags -from .utils import snowflake_time, _bytes_to_base64_data, MISSING +from .utils import snowflake_time, _bytes_to_base64_data, MISSING, _get_as_snowflake if TYPE_CHECKING: from typing_extensions import Self @@ -43,10 +43,7 @@ from .message import Message from .state import ConnectionState from .types.channel import DMChannel as DMChannelPayload - from .types.user import ( - PartialUser as PartialUserPayload, - User as UserPayload, - ) + from .types.user import PartialUser as PartialUserPayload, User as UserPayload, AvatarDecorationData __all__ = ( @@ -73,6 +70,7 @@ class BaseUser(_UserTag): 'system', '_public_flags', '_state', + '_avatar_decoration_data', ) if TYPE_CHECKING: @@ -87,6 +85,7 @@ class BaseUser(_UserTag): _banner: Optional[str] _accent_colour: Optional[int] _public_flags: int + _avatar_decoration_data: Optional[AvatarDecorationData] def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUserPayload]) -> None: self._state = state @@ -123,6 +122,7 @@ def _update(self, data: Union[UserPayload, PartialUserPayload]) -> None: self._public_flags = data.get('public_flags', 0) self.bot = data.get('bot', False) self.system = data.get('system', False) + self._avatar_decoration_data = data.get('avatar_decoration_data') @classmethod def _copy(cls, user: Self) -> Self: @@ -138,6 +138,7 @@ def _copy(cls, user: Self) -> Self: self.bot = user.bot self._state = user._state self._public_flags = user._public_flags + self._avatar_decoration_data = user._avatar_decoration_data return self @@ -187,6 +188,30 @@ def display_avatar(self) -> Asset: """ return self.avatar or self.default_avatar + @property + def avatar_decoration(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns an :class:`Asset` for the avatar decoration the user has. + + If the user has not set an avatar decoration, ``None`` is returned. + + .. versionadded:: 2.4 + """ + if self._avatar_decoration_data is not None: + return Asset._from_avatar_decoration(self._state, self._avatar_decoration_data['asset']) + return None + + @property + def avatar_decoration_sku_id(self) -> Optional[int]: + """Optional[:class:`int`]: Returns the SKU ID of the avatar decoration the user has. + + If the user has not set an avatar decoration, ``None`` is returned. + + .. versionadded:: 2.4 + """ + if self._avatar_decoration_data is not None: + return _get_as_snowflake(self._avatar_decoration_data, 'sku_id') + return None + @property def banner(self) -> Optional[Asset]: """Optional[:class:`Asset`]: Returns the user's banner asset, if available. diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 9ce18bbe49d8..2834aedcea24 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -1304,9 +1304,10 @@ def _as_follower(cls, data, *, channel, user) -> Self: 'user': { 'username': user.name, 'discriminator': user.discriminator, - 'global_name': user.global_name, 'id': user.id, 'avatar': user._avatar, + 'avatar_decoration_data': user._avatar_decoration_data, + 'global_name': user.global_name, }, }