Skip to content

Commit

Permalink
Add new user and profile fields
Browse files Browse the repository at this point in the history
  • Loading branch information
dolfies committed Jan 2, 2025
1 parent 25a66d7 commit f88c1d3
Show file tree
Hide file tree
Showing 10 changed files with 96 additions and 32 deletions.
10 changes: 10 additions & 0 deletions discord/abc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
15 changes: 7 additions & 8 deletions discord/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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|
Expand Down
9 changes: 3 additions & 6 deletions discord/guild.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
-------
Expand All @@ -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|
Expand Down
2 changes: 2 additions & 0 deletions discord/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions discord/member.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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
Expand Down
42 changes: 35 additions & 7 deletions discord/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand All @@ -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)

Expand Down Expand Up @@ -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 []
Expand All @@ -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.
Expand All @@ -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__ = (
Expand All @@ -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:
Expand All @@ -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'<ProfileMetadata bio={self.bio!r} pronouns={self.pronouns!r}>'

Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions discord/types/profile.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class ProfileUser(APIUser):

class ProfileEffect(TypedDict):
id: Snowflake
expires_at: Optional[int]


class ProfileMetadata(TypedDict, total=False):
Expand Down Expand Up @@ -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]]
Expand Down
1 change: 1 addition & 0 deletions discord/types/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
31 changes: 27 additions & 4 deletions discord/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -245,6 +253,7 @@ class BaseUser(_UserTag):
'_avatar',
'_avatar_decoration',
'_avatar_decoration_sku_id',
'_avatar_decoration_expires_at',
'_banner',
'_accent_colour',
'bot',
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down
12 changes: 7 additions & 5 deletions discord/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down

0 comments on commit f88c1d3

Please sign in to comment.