diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index c02be6ba45ab..5c72c56e6ec7 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -115,6 +115,14 @@ def from_dict(cls, data: Union[PartialEmojiPayload, ActivityEmoji, Dict[str, Any name=data.get('name') or '', ) + @classmethod + def from_dict_stateful( + cls, data: Union[PartialEmojiPayload, ActivityEmoji, Dict[str, Any]], state: ConnectionState + ) -> Self: + self = cls.from_dict(data) + self._state = state + return self + @classmethod def from_str(cls, value: str) -> Self: """Converts a Discord string representation of an emoji to a :class:`PartialEmoji`. diff --git a/discord/profile.py b/discord/profile.py index e0ca090c4aeb..f78b5fce7689 100644 --- a/discord/profile.py +++ b/discord/profile.py @@ -24,16 +24,18 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, List, Optional, Tuple from . import utils from .application import ApplicationInstallParams from .asset import Asset, AssetMixin +from .colour import Colour from .connections import PartialConnection from .enums import PremiumType, try_enum from .flags import ApplicationFlags from .member import Member from .mixins import Hashable +from .partial_emoji import PartialEmoji from .user import User if TYPE_CHECKING: @@ -45,11 +47,13 @@ Profile as ProfilePayload, ProfileApplication as ProfileApplicationPayload, ProfileBadge as ProfileBadgePayload, + ProfileMetadata as ProfileMetadataPayload, MutualGuild as MutualGuildPayload, ) from .types.user import PartialUser as PartialUserPayload __all__ = ( + 'ProfileMetadata', 'ApplicationProfile', 'MutualGuild', 'ProfileBadge', @@ -71,6 +75,7 @@ def __init__(self, **kwargs) -> None: mutual_friends: List[PartialUserPayload] = kwargs.pop('mutual_friends', None) member = data.get('guild_member') + member_profile = data.get('guild_member_profile') if member is not None: member['user'] = user kwargs['data'] = member @@ -84,6 +89,11 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) state = self._state + self.metadata = ProfileMetadata(id=self.id, state=state, data=profile) + if member is not None: + self.guild_metadata = ProfileMetadata(id=self.id, state=state, data=member_profile) + + self.legacy_username: Optional[str] = data.get('legacy_username') self.bio: Optional[str] = user['bio'] or None # We need to do a bit of a hack here because premium_since is massively overloaded @@ -91,9 +101,7 @@ def __init__(self, **kwargs) -> None: if guild_premium_since is not utils.MISSING: self.guild_premium_since = guild_premium_since - self.premium_type: Optional[PremiumType] = ( - try_enum(PremiumType, data.get('premium_type') or 0) if profile else None - ) + self.premium_type: Optional[PremiumType] = try_enum(PremiumType, data.get('premium_type') or 0) if profile else None self.premium_since: Optional[datetime] = utils.parse_time(data.get('premium_since')) self.premium_guild_since: Optional[datetime] = utils.parse_time(data.get('premium_guild_since')) self.connections: List[PartialConnection] = [PartialConnection(d) for d in data['connected_accounts']] @@ -137,6 +145,110 @@ def premium(self) -> bool: return self.premium_since is not None +class ProfileMetadata: + """Represents global or per-user Discord profile metadata. + + .. versionadded:: 2.1 + + Attributes + ------------ + bio: Optional[:class:`str`] + The profile's "about me" field. Could be ``None``. + pronouns: Optional[:class:`str`] + The profile's pronouns, if any. + effect_id: Optional[:class:`int`] + The ID of the profile effect the user has, if any. + """ + + __slots__ = ( + '_id', + '_state', + 'bio', + 'pronouns', + 'emoji', + 'popout_animation_particle_type', + 'effect_id', + '_banner', + '_accent_colour', + '_theme_colours', + '_guild_id', + ) + + def __init__(self, *, id: int, state: ConnectionState, data: Optional[ProfileMetadataPayload]) -> None: + self._id = id + self._state = state + + # user_profile is null if blocked + if data is None: + data = {'pronouns': ''} + + self.bio: Optional[str] = data.get('bio') or None + self.pronouns: Optional[str] = data.get('pronouns') or None + self.emoji: Optional[PartialEmoji] = PartialEmoji.from_dict_stateful(data['emoji'], state) if data.get('emoji') else None # type: ignore + self.popout_animation_particle_type: Optional[int] = utils._get_as_snowflake(data, 'popout_animation_particle_type') + self.effect_id: Optional[int] = utils._get_as_snowflake(data['profile_effect'], 'id') if data.get('profile_effect') else None # type: ignore + self._banner: Optional[str] = data.get('banner') + self._accent_colour: Optional[int] = data.get('accent_color') + self._theme_colours: Optional[Tuple[int, int]] = tuple(data['theme_colors']) if data.get('theme_colors') else None # type: ignore + self._guild_id: Optional[int] = utils._get_as_snowflake(data, 'guild_id') + + def __repr__(self) -> str: + return f'' + + @property + def banner(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the user's banner asset, if available.""" + if self._banner is None: + return None + return Asset._from_user_banner(self._state, self._id, self._banner) + + @property + def accent_colour(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Returns the profile's accent colour, if applicable. + + A user's accent colour is only shown if they do not have a banner. + This will only be available if the user explicitly sets a colour. + + There is an alias for this named :attr:`accent_color`. + """ + if self._accent_colour is None: + return None + return Colour(self._accent_colour) + + @property + def accent_color(self) -> Optional[Colour]: + """Optional[:class:`Colour`]: Returns the profile's accent color, if applicable. + + A user's accent color is only shown if they do not have a banner. + This will only be available if the user explicitly sets a color. + + There is an alias for this named :attr:`accent_colour`. + """ + return self.accent_colour + + @property + def theme_colours(self) -> Optional[Tuple[Colour, Colour]]: + """Optional[Tuple[:class:`Colour`, :class:`Colour`]]: Returns the profile's theme colours, if applicable. + + The first colour is the user's background colour and the second is the user's foreground colour. + + There is an alias for this named :attr:`theme_colors`. + """ + if self._theme_colours is None: + return None + return tuple(Colour(c) for c in self._theme_colours) # type: ignore + + @property + def theme_colors(self) -> Optional[Tuple[Colour, Colour]]: + """Optional[Tuple[:class:`Colour`, :class:`Colour`]]: Returns the profile's theme colors, if applicable. + + The first color is the user's background color and the second is the user's foreground color. + + There is an alias for this named :attr:`theme_colours`. + """ + return self.theme_colours + + class ApplicationProfile(Hashable): """Represents a Discord application profile. @@ -362,6 +474,14 @@ class UserProfile(Profile, User): ----------- application: Optional[:class:`ApplicationProfile`] The application profile of the user, if it is a bot. + metadata: :class:`ProfileMetadata` + The global profile metadata of the user. + + .. versionadded:: 2.1 + legacy_username: Optional[:class:`str`] + The user's legacy username (Username#Discriminator), if public. + + .. versionadded:: 2.1 bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. premium_type: Optional[:class:`PremiumType`] @@ -449,6 +569,14 @@ class MemberProfile(Profile, Member): ----------- application: Optional[:class:`ApplicationProfile`] The application profile of the user, if it is a bot. + metadata: :class:`ProfileMetadata` + The global profile metadata of the user. + + .. versionadded:: 2.1 + legacy_username: Optional[:class:`str`] + The user's legacy username (Username#Discriminator), if public. + + .. versionadded:: 2.1 bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. guild_bio: Optional[:class:`str`] diff --git a/discord/types/profile.py b/discord/types/profile.py index 92d3d635ab4b..e85763e87e70 100644 --- a/discord/types/profile.py +++ b/discord/types/profile.py @@ -22,10 +22,11 @@ DEALINGS IN THE SOFTWARE. """ -from typing import List, Optional, TypedDict -from typing_extensions import NotRequired +from typing import List, Optional, Tuple, TypedDict +from typing_extensions import NotRequired, Required from .application import ApplicationInstallParams, RoleConnection +from .emoji import Emoji from .member import PrivateMember as ProfileMember from .snowflake import Snowflake from .user import APIUser, PartialConnection, PremiumType @@ -35,12 +36,20 @@ class ProfileUser(APIUser): bio: str -class ProfileMetadata(TypedDict): - guild_id: NotRequired[int] - bio: NotRequired[str] - banner: NotRequired[Optional[str]] - accent_color: NotRequired[Optional[int]] - theme_colors: NotRequired[List[int]] +class ProfileEffect(TypedDict): + id: Snowflake + + +class ProfileMetadata(TypedDict, total=False): + guild_id: int + bio: str + banner: Optional[str] + accent_color: Optional[int] + theme_colors: Optional[Tuple[int, int]] + emoji: Optional[Emoji] + popout_animation_particle_type: Optional[Snowflake] + profile_effect: Optional[ProfileEffect] + pronouns: Required[str] class MutualGuild(TypedDict): @@ -79,4 +88,5 @@ class Profile(TypedDict): premium_type: Optional[PremiumType] premium_since: Optional[str] premium_guild_since: Optional[str] + legacy_username: Optional[str] application: NotRequired[ProfileApplication] diff --git a/docs/api.rst b/docs/api.rst index cb542905fa94..0b7d12f00539 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6826,6 +6826,11 @@ User :members: :inherited-members: +.. attributetable:: ProfileMetadata + +.. autoclass:: ProfileMetadata() + :members: + .. attributetable:: ProfileBadge .. autoclass:: ProfileBadge()