From cbff6ddef9891401c53ad338315158ff8aa090c9 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Tue, 26 Aug 2025 09:34:18 +0200 Subject: [PATCH] Add support for user collectibles --- discord/__init__.py | 1 + discord/asset.py | 10 ++++ discord/collectible.py | 109 +++++++++++++++++++++++++++++++++++++++++ discord/enums.py | 20 ++++++++ discord/member.py | 2 + discord/types/user.py | 41 +++++++++++----- discord/user.py | 16 ++++++ docs/api.rst | 70 +++++++++++++++++++++++++- 8 files changed, 256 insertions(+), 13 deletions(-) create mode 100644 discord/collectible.py diff --git a/discord/__init__.py b/discord/__init__.py index f4d7af42e..3279f8b8c 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -75,6 +75,7 @@ from .subscription import * from .presences import * from .primary_guild import * from .onboarding import * +from .collectible import * class VersionInfo(NamedTuple): diff --git a/discord/asset.py b/discord/asset.py index a3ed53c6b..41bcba3cf 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -355,6 +355,16 @@ class Asset(AssetMixin): animated=False, ) + @classmethod + def _from_user_collectible(cls, state: _State, asset: str, animated: bool = False) -> Self: + name = 'static.png' if not animated else 'asset.webm' + return cls( + state, + url=f'{cls.BASE}/assets/collectibles/{asset}{name}', + key=asset, + animated=animated, + ) + def __str__(self) -> str: return self._url diff --git a/discord/collectible.py b/discord/collectible.py new file mode 100644 index 000000000..b2ad7e4e0 --- /dev/null +++ b/discord/collectible.py @@ -0,0 +1,109 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present 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 __future__ import annotations + +from typing import Optional, TYPE_CHECKING + + +from .asset import Asset +from .enums import NameplatePalette, CollectibleType, try_enum +from .utils import parse_time + + +if TYPE_CHECKING: + from datetime import datetime + + from .state import ConnectionState + from .types.user import ( + Collectible as CollectiblePayload, + ) + + +__all__ = ('Collectible',) + + +class Collectible: + """Represents a user's collectible. + + .. versionadded:: 2.7 + + Attributes + ---------- + label: :class:`str` + The label of the collectible. + palette: Optional[:class:`NameplatePalette`] + The palette of the collectible. + This is only available if ``type`` is + :class:`CollectibleType.nameplate`. + sku_id: :class:`int` + The SKU ID of the collectible. + type: :class:`CollectibleType` + The type of the collectible. + expires_at: Optional[:class:`datetime.datetime`] + The expiration date of the collectible. If applicable. + """ + + __slots__ = ( + 'type', + 'sku_id', + 'label', + 'expires_at', + 'palette', + '_state', + '_asset', + ) + + def __init__(self, *, state: ConnectionState, type: str, data: CollectiblePayload) -> None: + self._state: ConnectionState = state + self.type: CollectibleType = try_enum(CollectibleType, type) + self._asset: str = data['asset'] + self.sku_id: int = int(data['sku_id']) + self.label: str = data['label'] + self.expires_at: Optional[datetime] = parse_time(data.get('expires_at')) + + # nameplate + self.palette: Optional[NameplatePalette] + try: + self.palette = try_enum(NameplatePalette, data['palette']) # type: ignore + except KeyError: + self.palette = None + + @property + def static(self) -> Asset: + """:class:`Asset`: The static asset of the collectible.""" + return Asset._from_user_collectible(self._state, self._asset) + + @property + def animated(self) -> Asset: + """:class:`Asset`: The animated asset of the collectible.""" + return Asset._from_user_collectible(self._state, self._asset, animated=True) + + def __repr__(self) -> str: + attrs = ['sku_id'] + if self.palette: + attrs.append('palette') + + joined_attrs = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in attrs) + return f'<{self.type.name.title()} {joined_attrs}>' diff --git a/discord/enums.py b/discord/enums.py index 7dc4bccd0..28b99ab03 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -83,6 +83,8 @@ __all__ = ( 'OnboardingMode', 'SeparatorSpacing', 'MediaItemLoadingState', + 'CollectibleType', + 'NameplatePalette', ) @@ -968,6 +970,24 @@ class MediaItemLoadingState(Enum): not_found = 3 +class CollectibleType(Enum): + nameplate = 'nameplate' + + +class NameplatePalette(Enum): + crimson = 'crimson' + berry = 'berry' + sky = 'sky' + teal = 'teal' + forest = 'forest' + bubble_gum = 'bubble_gum' + violet = 'violet' + cobalt = 'cobalt' + clover = 'clover' + lemon = 'lemon' + white = 'white' + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/member.py b/discord/member.py index 9f6b9daf2..fd2cf7edb 100644 --- a/discord/member.py +++ b/discord/member.py @@ -75,6 +75,7 @@ if TYPE_CHECKING: VoiceState as VoiceStatePayload, ) from .primary_guild import PrimaryGuild + from .collectible import Collectible VocalGuildChannel = Union[VoiceChannel, StageChannel] @@ -311,6 +312,7 @@ class Member(discord.abc.Messageable, _UserTag): avatar_decoration: Optional[Asset] avatar_decoration_sku_id: Optional[int] primary_guild: PrimaryGuild + collectibles: List[Collectible] def __init__(self, *, data: MemberWithUserPayload, guild: Guild, state: ConnectionState): self._state: ConnectionState = state diff --git a/discord/types/user.py b/discord/types/user.py index b2b213ecf..639384a56 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -27,11 +27,38 @@ from typing import Literal, Optional, TypedDict from typing_extensions import NotRequired -class AvatarDecorationData(TypedDict): +PremiumType = Literal[0, 1, 2, 3] +NameplatePallete = Literal['crimson', 'berry', 'sky', 'teal', 'forest', 'bubble_gum', 'violet', 'cobalt', 'clover'] + + +class _UserSKU(TypedDict): asset: str sku_id: Snowflake +AvatarDecorationData = _UserSKU + + +class PrimaryGuild(TypedDict): + identity_guild_id: Optional[int] + identity_enabled: Optional[bool] + tag: Optional[str] + badge: Optional[str] + + +class Collectible(_UserSKU): + label: str + expires_at: Optional[str] + + +class NameplateCollectible(Collectible): + palette: str + + +class UserCollectibles(TypedDict): + nameplate: NameplateCollectible + + class PartialUser(TypedDict): id: Snowflake username: str @@ -39,9 +66,8 @@ class PartialUser(TypedDict): avatar: Optional[str] global_name: Optional[str] avatar_decoration_data: NotRequired[AvatarDecorationData] - - -PremiumType = Literal[0, 1, 2, 3] + primary_guild: NotRequired[PrimaryGuild] + collectibles: NotRequired[UserCollectibles] class User(PartialUser, total=False): @@ -54,10 +80,3 @@ class User(PartialUser, total=False): flags: int premium_type: PremiumType public_flags: int - - -class PrimaryGuild(TypedDict): - identity_guild_id: Optional[int] - identity_enabled: Optional[bool] - tag: Optional[str] - badge: Optional[str] diff --git a/discord/user.py b/discord/user.py index 751437532..32edb1dc7 100644 --- a/discord/user.py +++ b/discord/user.py @@ -33,6 +33,7 @@ from .enums import DefaultAvatar from .flags import PublicUserFlags from .utils import snowflake_time, _bytes_to_base64_data, MISSING, _get_as_snowflake from .primary_guild import PrimaryGuild +from .collectible import Collectible if TYPE_CHECKING: from typing_extensions import Self @@ -49,6 +50,7 @@ if TYPE_CHECKING: User as UserPayload, AvatarDecorationData, PrimaryGuild as PrimaryGuildPayload, + UserCollectibles as UserCollectiblesPayload, ) @@ -78,6 +80,7 @@ class BaseUser(_UserTag): '_state', '_avatar_decoration_data', '_primary_guild', + '_collectibles', ) if TYPE_CHECKING: @@ -94,6 +97,7 @@ class BaseUser(_UserTag): _public_flags: int _avatar_decoration_data: Optional[AvatarDecorationData] _primary_guild: Optional[PrimaryGuildPayload] + _collectibles: Optional[UserCollectiblesPayload] def __init__(self, *, state: ConnectionState, data: Union[UserPayload, PartialUserPayload]) -> None: self._state = state @@ -132,6 +136,7 @@ class BaseUser(_UserTag): self.system = data.get('system', False) self._avatar_decoration_data = data.get('avatar_decoration_data') self._primary_guild = data.get('primary_guild', None) + self._collectibles = data.get('collectibles', None) @classmethod def _copy(cls, user: Self) -> Self: @@ -149,6 +154,7 @@ class BaseUser(_UserTag): self._public_flags = user._public_flags self._avatar_decoration_data = user._avatar_decoration_data self._primary_guild = user._primary_guild + self._collectibles = user._collectibles return self @@ -324,6 +330,16 @@ class BaseUser(_UserTag): return PrimaryGuild(state=self._state, data=self._primary_guild) return PrimaryGuild._default(self._state) + @property + def collectibles(self) -> List[Collectible]: + """List[:class:`Collectible`]: Returns a list of the user's collectibles. + + .. versionadded:: 2.7 + """ + if self._collectibles is None: + return [] + return [Collectible(state=self._state, type=key, data=value) for key, value in self._collectibles.items() if value] # type: ignore + def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. diff --git a/docs/api.rst b/docs/api.rst index 53ddec06e..1a564ddca 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4052,8 +4052,6 @@ of :class:`enum.Enum`. Default channels and questions count towards onboarding constraints. - - .. class:: MediaItemLoadingState Represents a :class:`UnfurledMediaItem` load state. @@ -4074,6 +4072,66 @@ of :class:`enum.Enum`. The media item was not found. +.. class:: CollectibleType + + Represents the type of a :class:`Collectible`. + + .. versionadded:: 2.7 + + .. attribute:: nameplate + + The collectible is a nameplate. + +.. class:: NameplatePalette + + Represents the available palettes for a nameplate. + + .. versionadded:: 2.7 + + .. attribute:: crimson + + The collectible nameplate palette is crimson. + + .. attribute:: berry + + The collectible nameplate palette is berry. + + .. attribute:: sky + + The collectible nameplate palette is sky. + + .. attribute:: teal + + The collectible nameplate palette is teal. + + .. attribute:: forest + + The collectible nameplate palette is forest. + + .. attribute:: bubble_gum + + The collectible nameplate palette is bubble gum. + + .. attribute:: violet + + The collectible nameplate palette is violet. + + .. attribute:: cobalt + + The collectible nameplate palette is cobalt. + + .. attribute:: clover + + The collectible nameplate palette is clover. + + .. attribute:: lemon + + The collectible nameplate palette is lemon. + + .. attribute:: white + + The collectible nameplate palette is white. + .. _discord-api-audit-logs: Audit Log Data @@ -5770,6 +5828,14 @@ PrimaryGuild .. autoclass:: PrimaryGuild() :members: +Collectible +~~~~~~~~~~~ + +.. attributetable:: Collectible + +.. autoclass:: Collectible() + :members: + CallMessage ~~~~~~~~~~~~~~~~~~~