From f88c1d37ce16d33d2554198991bc8cb58d3c4954 Mon Sep 17 00:00:00 2001 From: Dolfies Date: Thu, 2 Jan 2025 17:20:05 -0500 Subject: [PATCH] Add new user and profile fields --- discord/abc.py | 10 ++++++++++ discord/client.py | 15 +++++++------- discord/guild.py | 9 +++------ discord/http.py | 2 ++ discord/member.py | 4 ++-- discord/profile.py | 42 +++++++++++++++++++++++++++++++++------- discord/types/profile.py | 2 ++ discord/types/user.py | 1 + discord/user.py | 31 +++++++++++++++++++++++++---- discord/utils.py | 12 +++++++----- 10 files changed, 96 insertions(+), 32 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 02ad626fca29..bd8ae6b0fbbd 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -490,6 +490,16 @@ def avatar_decoration_sku_id(self) -> Optional[int]: """ raise NotImplementedError + @property + def avatar_decoration_expires_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: Returns the avatar decoration's expiration time. + + If the user does not have an expiring avatar decoration, ``None`` is returned. + + .. versionadded:: 2.1 + """ + raise NotImplementedError + @property def default_avatar(self) -> Asset: """:class:`~discord.Asset`: Returns the default avatar for a given user.""" diff --git a/discord/client.py b/discord/client.py index 4874a03bf9c4..057413239494 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2523,8 +2523,7 @@ async def fetch_user_profile( .. versionadded:: 2.0 with_mutual_friends: :class:`bool` Whether to fetch mutual friends. - This fills in :attr:`.UserProfile.mutual_friends` and :attr:`.UserProfile.mutual_friends_count`, - but requires an extra API call. + This fills in :attr:`.UserProfile.mutual_friends` and :attr:`.UserProfile.mutual_friends_count`. .. versionadded:: 2.0 @@ -2533,7 +2532,7 @@ async def fetch_user_profile( NotFound A user with this ID does not exist. Forbidden - You do not have a mutual with this user, and and the user is not a bot. + You do not have a mutual with this user, and the user is not a bot. HTTPException Fetching the profile failed. @@ -2544,13 +2543,13 @@ async def fetch_user_profile( """ state = self._connection data = await state.http.get_user_profile( - user_id, with_mutual_guilds=with_mutual_guilds, with_mutual_friends_count=with_mutual_friends_count + user_id, + with_mutual_guilds=with_mutual_guilds, + with_mutual_friends_count=with_mutual_friends_count, + with_mutual_friends=with_mutual_friends, ) - mutual_friends = None - if with_mutual_friends and not data['user'].get('bot', False): - mutual_friends = await state.http.get_mutual_friends(user_id) - return UserProfile(state=state, data=data, mutual_friends=mutual_friends) + return UserProfile(state=state, data=data) async def fetch_channel(self, channel_id: int, /) -> Union[GuildChannel, PrivateChannel, Thread]: """|coro| diff --git a/discord/guild.py b/discord/guild.py index b9554aad6a41..062cb1c4b3f0 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2702,8 +2702,7 @@ async def fetch_member_profile( This fills in :attr:`.MemberProfile.mutual_friends_count`. with_mutual_friends: :class:`bool` Whether to fetch mutual friends. - This fills in :attr:`.MemberProfile.mutual_friends` and :attr:`.MemberProfile.mutual_friends_count`, - but requires an extra API call. + This fills in :attr:`.MemberProfile.mutual_friends` and :attr:`.MemberProfile.mutual_friends_count`. Raises ------- @@ -2727,16 +2726,14 @@ async def fetch_member_profile( self.id, with_mutual_guilds=with_mutual_guilds, with_mutual_friends_count=with_mutual_friends_count, + with_mutual_friends=with_mutual_friends, ) if 'guild_member_profile' not in data: raise InvalidData('Member is not in this guild') if 'guild_member' not in data: raise InvalidData('Member has blocked you') - mutual_friends = None - if with_mutual_friends and not data['user'].get('bot', False): - mutual_friends = await state.http.get_mutual_friends(member_id) - return MemberProfile(state=state, data=data, mutual_friends=mutual_friends, guild=self) + return MemberProfile(state=state, data=data, guild=self) async def fetch_ban(self, user: Snowflake) -> BanEntry: """|coro| diff --git a/discord/http.py b/discord/http.py index ec40abea9174..bd960edf4908 100644 --- a/discord/http.py +++ b/discord/http.py @@ -4439,10 +4439,12 @@ def get_user_profile( guild_id: Optional[Snowflake] = None, *, with_mutual_guilds: bool = True, + with_mutual_friends: bool = False, with_mutual_friends_count: bool = False, ) -> Response[profile.Profile]: params: Dict[str, Any] = { 'with_mutual_guilds': str(with_mutual_guilds).lower(), + 'with_mutual_friends': str(with_mutual_friends).lower(), 'with_mutual_friends_count': str(with_mutual_friends_count).lower(), } if guild_id: diff --git a/discord/member.py b/discord/member.py index 9a641c73f3d5..66137b441737 100644 --- a/discord/member.py +++ b/discord/member.py @@ -291,6 +291,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): avatar: Optional[Asset] avatar_decoration: Optional[Asset] avatar_decoration_sku_id: Optional[int] + avatar_decoration_expires_at: Optional[datetime.datetime] note: Note relationship: Optional[Relationship] is_friend: Callable[[], bool] @@ -1131,8 +1132,7 @@ async def profile( .. versionadded:: 2.0 with_mutual_friends: :class:`bool` Whether to fetch mutual friends. - This fills in :attr:`MemberProfile.mutual_friends` and :attr:`MemberProfile.mutual_friends_count`, - but requires an extra API call. + This fills in :attr:`MemberProfile.mutual_friends` and :attr:`MemberProfile.mutual_friends_count`. .. versionadded:: 2.0 diff --git a/discord/profile.py b/discord/profile.py index f78b5fce7689..87b6bc4834b8 100644 --- a/discord/profile.py +++ b/discord/profile.py @@ -24,7 +24,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional, Tuple +from typing import TYPE_CHECKING, Collection, List, Optional, Tuple from . import utils from .application import ApplicationInstallParams @@ -72,7 +72,7 @@ def __init__(self, **kwargs) -> None: data: ProfilePayload = kwargs.pop('data') user = data['user'] profile = data.get('user_profile') - mutual_friends: List[PartialUserPayload] = kwargs.pop('mutual_friends', None) + mutual_friends = data.get('mutual_friends') member = data.get('guild_member') member_profile = data.get('guild_member_profile') @@ -89,7 +89,9 @@ def __init__(self, **kwargs) -> None: super().__init__(**kwargs) state = self._state + # All metadata will be missing on a blocked profile self.metadata = ProfileMetadata(id=self.id, state=state, data=profile) + self._blocked = profile is None if member is not None: self.guild_metadata = ProfileMetadata(id=self.id, state=state, data=member_profile) @@ -118,7 +120,7 @@ def __init__(self, **kwargs) -> None: application = data.get('application') self.application: Optional[ApplicationProfile] = ApplicationProfile(data=application) if application else None - def _parse_mutual_friends(self, mutual_friends: List[PartialUserPayload]) -> Optional[List[User]]: + def _parse_mutual_friends(self, mutual_friends: Optional[Collection[PartialUserPayload]]) -> Optional[List[User]]: if self.bot: # Bots don't have friends return [] @@ -144,6 +146,13 @@ def premium(self) -> bool: """:class:`bool`: Indicates if the user is a premium user.""" return self.premium_since is not None + def is_blocked_by_user(self) -> bool: + """:class:`bool`: Indicates if the user has blocked the client user. + + .. versionadded:: 2.1 + """ + return self._blocked + class ProfileMetadata: """Represents global or per-user Discord profile metadata. @@ -156,8 +165,6 @@ class ProfileMetadata: 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__ = ( @@ -167,11 +174,12 @@ class ProfileMetadata: 'pronouns', 'emoji', 'popout_animation_particle_type', - 'effect_id', '_banner', '_accent_colour', '_theme_colours', '_guild_id', + '_effect_id', + '_effect_expires_at', ) def __init__(self, *, id: int, state: ConnectionState, data: Optional[ProfileMetadataPayload]) -> None: @@ -186,12 +194,15 @@ def __init__(self, *, id: int, state: ConnectionState, data: Optional[ProfileMet 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') + effect_data = data.get('profile_effect') + self._effect_id: Optional[int] = utils._get_as_snowflake(effect_data, 'id') if effect_data else None + self._effect_expires_at = effect_data.get('expires_at') if effect_data else None + def __repr__(self) -> str: return f'' @@ -248,6 +259,23 @@ def theme_colors(self) -> Optional[Tuple[Colour, Colour]]: """ return self.theme_colours + @property + def effect_id(self) -> Optional[int]: + """Optional[:class:`int`]: Returns the ID of the profile effect the user has, if any..""" + return self._effect_id + + @property + def effect_expires_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: Returns the profile effect's expiration time. + + If the user does not have an expiring profile effect, ``None`` is returned. + + .. versionadded:: 2.1 + """ + if self._effect_expires_at is None: + return None + return utils.parse_timestamp(self._effect_expires_at, ms=False) + class ApplicationProfile(Hashable): """Represents a Discord application profile. diff --git a/discord/types/profile.py b/discord/types/profile.py index e85763e87e70..1f4c39ec865b 100644 --- a/discord/types/profile.py +++ b/discord/types/profile.py @@ -38,6 +38,7 @@ class ProfileUser(APIUser): class ProfileEffect(TypedDict): id: Snowflake + expires_at: Optional[int] class ProfileMetadata(TypedDict, total=False): @@ -82,6 +83,7 @@ class Profile(TypedDict): guild_member_profile: NotRequired[Optional[ProfileMetadata]] guild_badges: List[ProfileBadge] mutual_guilds: NotRequired[List[MutualGuild]] + mutual_friends: NotRequired[List[APIUser]] mutual_friends_count: NotRequired[int] connected_accounts: List[PartialConnection] application_role_connections: NotRequired[List[RoleConnection]] diff --git a/discord/types/user.py b/discord/types/user.py index 7999aab7f353..aeaea4625354 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -96,6 +96,7 @@ class User(APIUser, total=False): class UserAvatarDecorationData(TypedDict): asset: str sku_id: NotRequired[Snowflake] + expires_at: Optional[int] class PomeloAttempt(TypedDict): diff --git a/discord/user.py b/discord/user.py index 26a4e8bc5b6a..db773f6921a0 100644 --- a/discord/user.py +++ b/discord/user.py @@ -40,7 +40,15 @@ from .errors import ClientException, NotFound from .flags import PublicUserFlags, PrivateUserFlags, PremiumUsageFlags, PurchasedFlags from .relationship import Relationship -from .utils import _bytes_to_base64_data, _get_as_snowflake, cached_slot_property, copy_doc, snowflake_time, MISSING +from .utils import ( + _bytes_to_base64_data, + _get_as_snowflake, + cached_slot_property, + copy_doc, + parse_timestamp, + snowflake_time, + MISSING, +) from .voice_client import VoiceClient if TYPE_CHECKING: @@ -245,6 +253,7 @@ class BaseUser(_UserTag): '_avatar', '_avatar_decoration', '_avatar_decoration_sku_id', + '_avatar_decoration_expires_at', '_banner', '_accent_colour', 'bot', @@ -267,6 +276,7 @@ class BaseUser(_UserTag): _avatar: Optional[str] _avatar_decoration: Optional[str] _avatar_decoration_sku_id: Optional[int] + _avatar_decoration_expires_at: Optional[int] _banner: Optional[str] _accent_colour: Optional[int] _public_flags: int @@ -311,6 +321,7 @@ def _update(self, data: Union[UserPayload, PartialUserPayload]) -> None: decoration_data = data.get('avatar_decoration_data') self._avatar_decoration = decoration_data.get('asset') if decoration_data else None self._avatar_decoration_sku_id = _get_as_snowflake(decoration_data, 'sku_id') if decoration_data else None + self._avatar_decoration_expires_at = decoration_data.get('expires_at') if decoration_data else None @classmethod def _copy(cls, user: Self) -> Self: @@ -323,6 +334,7 @@ def _copy(cls, user: Self) -> Self: self._avatar = user._avatar self._avatar_decoration = user._avatar_decoration self._avatar_decoration_sku_id = user._avatar_decoration_sku_id + self._avatar_decoration_expires_at = user._avatar_decoration_expires_at self._banner = user._banner self._accent_colour = user._accent_colour self._public_flags = user._public_flags @@ -335,7 +347,7 @@ def _copy(cls, user: Self) -> Self: def _to_minimal_user_json(self) -> APIUserPayload: decoration: Optional[UserAvatarDecorationData] = None if self._avatar_decoration is not None: - decoration = {'asset': self._avatar_decoration} + decoration = {'asset': self._avatar_decoration, 'expires_at': self._avatar_decoration_expires_at} if self._avatar_decoration_sku_id is not None: decoration['sku_id'] = self._avatar_decoration_sku_id @@ -425,6 +437,18 @@ def avatar_decoration_sku_id(self) -> Optional[int]: """ return self._avatar_decoration_sku_id + @property + def avatar_decoration_expires_at(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: Returns the avatar decoration's expiration time. + + If the user does not have an expiring avatar decoration, ``None`` is returned. + + .. versionadded:: 2.1 + """ + if self._avatar_decoration_expires_at is None: + return None + return parse_timestamp(self._avatar_decoration_expires_at, ms=False) + @property def banner(self) -> Optional[Asset]: """Optional[:class:`Asset`]: Returns the user's banner asset, if available. @@ -608,8 +632,7 @@ async def profile( .. versionadded:: 2.0 with_mutual_friends: :class:`bool` Whether to fetch mutual friends. - This fills in :attr:`UserProfile.mutual_friends` and :attr:`UserProfile.mutual_friends_count`, - but requires an extra API call. + This fills in :attr:`UserProfile.mutual_friends` and :attr:`UserProfile.mutual_friends_count`. .. versionadded:: 2.0 diff --git a/discord/utils.py b/discord/utils.py index 1ad21648c2cb..911c460b3dbb 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -330,23 +330,25 @@ def parse_date(date: Optional[str]) -> Optional[datetime.date]: @overload -def parse_timestamp(timestamp: None) -> None: +def parse_timestamp(timestamp: None, *, ms: bool = True) -> None: ... @overload -def parse_timestamp(timestamp: float) -> datetime.datetime: +def parse_timestamp(timestamp: float, *, ms: bool = True) -> datetime.datetime: ... @overload -def parse_timestamp(timestamp: Optional[float]) -> Optional[datetime.datetime]: +def parse_timestamp(timestamp: Optional[float], *, ms: bool = True) -> Optional[datetime.datetime]: ... -def parse_timestamp(timestamp: Optional[float]) -> Optional[datetime.datetime]: +def parse_timestamp(timestamp: Optional[float], *, ms: bool = True) -> Optional[datetime.datetime]: if timestamp: - return datetime.datetime.fromtimestamp(timestamp / 1000.0, tz=datetime.timezone.utc) + if ms: + timestamp /= 1000 + return datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc) def copy_doc(original: Callable[..., Any]) -> Callable[[T], T]: