From dd733e931b68a471888c40bd946926f683847efe Mon Sep 17 00:00:00 2001 From: dolfies Date: Fri, 7 Apr 2023 11:29:21 -0400 Subject: [PATCH] Rework profiles and notes, add note_update event --- discord/application.py | 3 +- discord/client.py | 53 ++++++---- discord/guild.py | 56 +++++----- discord/http.py | 23 ++-- discord/member.py | 52 +++++++++ discord/profile.py | 198 +++++++++++++++++++++++++++-------- discord/state.py | 16 ++- discord/types/application.py | 31 +++++- discord/types/gateway.py | 5 + discord/types/member.py | 19 ++-- discord/types/profile.py | 73 +++++++++++++ discord/types/user.py | 15 ++- discord/user.py | 114 +++++++++++--------- docs/api.rst | 15 +++ 14 files changed, 520 insertions(+), 153 deletions(-) create mode 100644 discord/types/profile.py diff --git a/discord/application.py b/discord/application.py index c3050b853..2526a0403 100644 --- a/discord/application.py +++ b/discord/application.py @@ -83,6 +83,7 @@ if TYPE_CHECKING: Achievement as AchievementPayload, ActivityStatistics as ActivityStatisticsPayload, Application as ApplicationPayload, + ApplicationInstallParams as ApplicationInstallParamsPayload, Asset as AssetPayload, BaseApplication as BaseApplicationPayload, Branch as BranchPayload, @@ -787,7 +788,7 @@ class ApplicationInstallParams: self.permissions: Permissions = permissions or Permissions(0) @classmethod - def from_application(cls, application: Snowflake, data: dict) -> ApplicationInstallParams: + def from_application(cls, application: Snowflake, data: ApplicationInstallParamsPayload) -> ApplicationInstallParams: return cls( application.id, scopes=data.get('scopes', []), diff --git a/discord/client.py b/discord/client.py index 0990690ef..4efb20804 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2162,13 +2162,20 @@ class Client: return User(state=self._connection, data=data) async def fetch_user_profile( - self, user_id: int, /, *, with_mutuals: bool = True, fetch_note: bool = True + self, + user_id: int, + /, + *, + with_mutual_guilds: bool = True, + with_mutual_friends_count: bool = False, + with_mutual_friends: bool = True, ) -> UserProfile: """|coro| - Gets an arbitrary user's profile. + Retrieves a :class:`.UserProfile` based on their user ID. - You must share a guild or be friends with this user to + You must share a guild, be friends with this user, + or have an incoming friend request from them to get this information (unless the user is a bot). .. versionchanged:: 2.0 @@ -2179,11 +2186,22 @@ class Client: ------------ user_id: :class:`int` The ID of the user to fetch their profile for. - with_mutuals: :class:`bool` - Whether to fetch mutual guilds and friends. - This fills in :attr:`.UserProfile.mutual_guilds` & :attr:`.UserProfile.mutual_friends`. - fetch_note: :class:`bool` - Whether to pre-fetch the user's note. + with_mutual_guilds: :class:`bool` + Whether to fetch mutual guilds. + This fills in :attr:`.UserProfile.mutual_guilds`. + + .. versionadded:: 2.0 + with_mutual_friends_count: :class:`bool` + Whether to fetch the number of mutual friends. + This fills in :attr:`.UserProfile.mutual_friends_count`. + + .. 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. + + .. versionadded:: 2.0 Raises ------- @@ -2200,19 +2218,14 @@ class Client: The profile of the user. """ state = self._connection - data = await state.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) - - if with_mutuals: - if not data['user'].get('bot', False): - data['mutual_friends'] = await state.http.get_mutual_friends(user_id) - else: - data['mutual_friends'] = [] - profile = UserProfile(state=state, data=data) - - if fetch_note: - await profile.note.fetch() + data = await state.http.get_user_profile( + user_id, with_mutual_guilds=with_mutual_guilds, with_mutual_friends_count=with_mutual_friends_count + ) + 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 profile + return UserProfile(state=state, data=data, mutual_friends=mutual_friends) async def fetch_channel(self, channel_id: int, /) -> Union[GuildChannel, PrivateChannel, Thread]: """|coro| diff --git a/discord/guild.py b/discord/guild.py index e6cbaaa1d..eb6c0ad46 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2333,32 +2333,45 @@ class Guild(Hashable): return Member(data=data, state=self._state, guild=self) async def fetch_member_profile( - self, member_id: int, /, *, with_mutuals: bool = True, fetch_note: bool = True + self, + member_id: int, + /, + *, + with_mutual_guilds: bool = True, + with_mutual_friends_count: bool = False, + with_mutual_friends: bool = True, ) -> MemberProfile: """|coro| - Gets an arbitrary member's profile. + Retrieves a :class:`.MemberProfile` from a guild ID, and a member ID. + + .. versionadded:: 2.0 Parameters ------------ member_id: :class:`int` The ID of the member to fetch their profile for. - with_mutuals: :class:`bool` - Whether to fetch mutual guilds and friends. - This fills in :attr:`.MemberProfile.mutual_guilds` & :attr:`.MemberProfile.mutual_friends`. - fetch_note: :class:`bool` - Whether to pre-fetch the user's note. + with_mutual_guilds: :class:`bool` + Whether to fetch mutual guilds. + This fills in :attr:`.MemberProfile.mutual_guilds`. + with_mutual_friends_count: :class:`bool` + Whether to fetch the number of mutual friends. + 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. Raises ------- NotFound A user with this ID does not exist. Forbidden - Not allowed to fetch this profile. + You do not have a mutual with this user, and and the user is not a bot. HTTPException Fetching the profile failed. InvalidData - The member is not in this guild. + The member is not in this guild or has blocked you. Returns -------- @@ -2366,23 +2379,18 @@ class Guild(Hashable): The profile of the member. """ state = self._state - data = await state.http.get_user_profile(member_id, self.id, with_mutual_guilds=with_mutuals) - + data = await state.http.get_user_profile( + member_id, self.id, with_mutual_guilds=with_mutual_guilds, with_mutual_friends_count=with_mutual_friends_count + ) + if 'guild_member_profile' not in data: + raise InvalidData('Member is not in this guild') if 'guild_member' not in data: - raise InvalidData('Member not in this guild') - - if with_mutuals: - if not data['user'].get('bot', False): - data['mutual_friends'] = await state.http.get_mutual_friends(member_id) - else: - data['mutual_friends'] = [] - - profile = MemberProfile(state=state, data=data, guild=self) - - if fetch_note: - await profile.note.fetch() + 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 profile + return MemberProfile(state=state, data=data, mutual_friends=mutual_friends, guild=self) async def fetch_ban(self, user: Snowflake) -> BanEntry: """|coro| diff --git a/discord/http.py b/discord/http.py index c0b30272b..5ad01dafa 100644 --- a/discord/http.py +++ b/discord/http.py @@ -97,6 +97,7 @@ if TYPE_CHECKING: member, message, payments, + profile, promotions, template, role, @@ -4069,14 +4070,22 @@ class HTTPClient: value = '{0}?encoding={1}&v={2}' return value.format(data['url'], encoding, INTERNAL_API_VERSION) - def get_user(self, user_id: Snowflake) -> Response[user.User]: + def get_user(self, user_id: Snowflake) -> Response[user.APIUser]: return self.request(Route('GET', '/users/{user_id}', user_id=user_id)) def get_user_profile( - self, user_id: Snowflake, guild_id: Snowflake = MISSING, *, with_mutual_guilds: bool = True - ): # TODO: return type - params: Dict[str, Any] = {'with_mutual_guilds': str(with_mutual_guilds).lower()} - if guild_id is not MISSING: + self, + user_id: Snowflake, + guild_id: Optional[Snowflake] = None, + *, + with_mutual_guilds: bool = True, + with_mutual_friends_count: bool = False, + ) -> Response[profile.Profile]: + params: Dict[str, Any] = { + 'with_mutual_guilds': str(with_mutual_guilds).lower(), + 'with_mutual_friends_count': str(with_mutual_friends_count).lower(), + } + if guild_id: params['guild_id'] = guild_id return self.request(Route('GET', '/users/{user_id}/profile', user_id=user_id), params=params) @@ -4084,10 +4093,10 @@ class HTTPClient: def get_mutual_friends(self, user_id: Snowflake): # TODO: return type return self.request(Route('GET', '/users/{user_id}/relationships', user_id=user_id)) - def get_notes(self): # TODO: return type + def get_notes(self) -> Response[Dict[Snowflake, str]]: return self.request(Route('GET', '/users/@me/notes')) - def get_note(self, user_id: Snowflake): # TODO: return type + def get_note(self, user_id: Snowflake) -> Response[user.Note]: return self.request(Route('GET', '/users/@me/notes/{user_id}', user_id=user_id)) def set_note(self, user_id: Snowflake, *, note: Optional[str] = None) -> Response[None]: diff --git a/discord/member.py b/discord/member.py index 642a1a3bf..fae6baa3b 100644 --- a/discord/member.py +++ b/discord/member.py @@ -58,6 +58,7 @@ if TYPE_CHECKING: from .channel import DMChannel, VoiceChannel, StageChannel, GroupChannel from .flags import PublicUserFlags from .guild import Guild + from .profile import MemberProfile from .types.activity import ( PartialPresenceUpdate, ) @@ -1107,3 +1108,54 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): Sending the friend request failed. """ await self._state.http.add_relationship(self._user.id, action=RelationshipAction.send_friend_request) + + async def profile( + self, + *, + with_mutual_guilds: bool = True, + with_mutual_friends_count: bool = False, + with_mutual_friends: bool = True, + ) -> MemberProfile: + """|coro| + + A shorthand method to retrieve a :class:`MemberProfile` for the member. + + Parameters + ------------ + with_mutual_guilds: :class:`bool` + Whether to fetch mutual guilds. + This fills in :attr:`MemberProfile.mutual_guilds`. + + .. versionadded:: 2.0 + with_mutual_friends_count: :class:`bool` + Whether to fetch the number of mutual friends. + This fills in :attr:`MemberProfile.mutual_friends_count`. + + .. 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. + + .. versionadded:: 2.0 + + Raises + ------- + Forbidden + Not allowed to fetch this profile. + HTTPException + Fetching the profile failed. + InvalidData + The member is not in this guild or has blocked you. + + Returns + -------- + :class:`MemberProfile` + The profile of the member. + """ + return await self.guild.fetch_member_profile( + self._user.id, + with_mutual_guilds=with_mutual_guilds, + with_mutual_friends_count=with_mutual_friends_count, + with_mutual_friends=with_mutual_friends, + ) diff --git a/discord/profile.py b/discord/profile.py index 06d1cb9c6..71e54cdac 100644 --- a/discord/profile.py +++ b/discord/profile.py @@ -34,16 +34,23 @@ from .enums import PremiumType, try_enum from .flags import ApplicationFlags from .member import Member from .mixins import Hashable -from .user import Note, User +from .user import User if TYPE_CHECKING: from datetime import datetime from .guild import Guild from .state import ConnectionState + from .types.profile import ( + Profile as ProfilePayload, + ProfileApplication as ProfileApplicationPayload, + MutualGuild as MutualGuildPayload, + ) + from .types.user import PartialUser as PartialUserPayload __all__ = ( 'ApplicationProfile', + 'MutualGuild', 'UserProfile', 'MemberProfile', ) @@ -52,23 +59,29 @@ __all__ = ( class Profile: if TYPE_CHECKING: id: int - application_id: Optional[int] + bot: bool _state: ConnectionState - def __init__(self, **kwargs) -> None: # TODO: type data - data = kwargs.pop('data') + def __init__(self, **kwargs) -> None: + data: ProfilePayload = kwargs.pop('data') user = data['user'] + mutual_friends: List[PartialUserPayload] = kwargs.pop('mutual_friends', None) - if (member := data.get('guild_member')) is not None: + member = data.get('guild_member') + if member is not None: member['user'] = user kwargs['data'] = member else: kwargs['data'] = user + # n.b. this class is subclassed by UserProfile and MemberProfile + # which subclass either User or Member respectively + # Because of this, we call super().__init__ here + # after ensuring the data kwarg is set correctly super().__init__(**kwargs) + state = self._state - self.bio: Optional[str] = user.pop('bio', None) or None - self.note: Note = Note(kwargs['state'], self.id, user=getattr(self, '_user', self)) # type: ignore + self.bio: Optional[str] = user['bio'] or None # We need to do a bit of a hack here because premium_since is massively overloaded guild_premium_since = getattr(self, 'premium_since', utils.MISSING) @@ -76,36 +89,42 @@ class Profile: self.guild_premium_since = guild_premium_since self.premium_type: Optional[PremiumType] = ( - try_enum(PremiumType, user.pop('premium_type')) if user.get('premium_type') else None + try_enum(PremiumType, data['premium_type']) if user.get('premium_type') else None ) self.premium_since: Optional[datetime] = utils.parse_time(data['premium_since']) - self.boosting_since: Optional[datetime] = utils.parse_time(data['premium_guild_since']) + self.premium_guild_since: Optional[datetime] = utils.parse_time(data['premium_guild_since']) self.connections: List[PartialConnection] = [PartialConnection(d) for d in data['connected_accounts']] - self.mutual_guilds: Optional[List[Guild]] = self._parse_mutual_guilds(data.get('mutual_guilds')) - self.mutual_friends: Optional[List[User]] = self._parse_mutual_friends(data.get('mutual_friends')) + self.mutual_guilds: Optional[List[MutualGuild]] = ( + [MutualGuild(state=state, data=d) for d in data['mutual_guilds']] if 'mutual_guilds' in data else None + ) + self.mutual_friends: Optional[List[User]] = self._parse_mutual_friends(mutual_friends) + self._mutual_friends_count: Optional[int] = data.get('mutual_friends_count') - application = data.get('application', {}) + application = data.get('application') self.application: Optional[ApplicationProfile] = ApplicationProfile(data=application) if application else None - def _parse_mutual_guilds(self, mutual_guilds) -> Optional[List[Guild]]: - if mutual_guilds is None: - return - - state = self._state - - def get_guild(guild): - return state._get_or_create_unavailable_guild(int(guild['id'])) - - return list(map(get_guild, mutual_guilds)) - - def _parse_mutual_friends(self, mutual_friends) -> Optional[List[User]]: + def _parse_mutual_friends(self, mutual_friends: List[PartialUserPayload]) -> Optional[List[User]]: + if self.bot: + # Bots don't have friends + return [] if mutual_friends is None: return state = self._state return [state.store_user(friend) for friend in mutual_friends] + @property + def mutual_friends_count(self) -> Optional[int]: + """Optional[:class:`int`]: The number of mutual friends the user has with the client user.""" + if self.bot: + # Bots don't have friends + return 0 + if self._mutual_friends_count is not None: + return self._mutual_friends_count + if self.mutual_friends is not None: + return len(self.mutual_friends) + @property def premium(self) -> bool: """:class:`bool`: Indicates if the user is a premium user.""" @@ -148,7 +167,17 @@ class ApplicationProfile(Hashable): The parameters to use for authorizing the application, if specified. """ - def __init__(self, data: dict) -> None: + __slots__ = ( + 'id', + 'verified', + 'popular_application_command_ids', + 'primary_sku_id', + '_flags', + 'custom_install_url', + 'install_params', + ) + + def __init__(self, data: ProfileApplicationPayload) -> None: self.id: int = int(data['id']) self.verified: bool = data.get('verified', False) self.popular_application_command_ids: List[int] = [int(id) for id in data.get('popular_application_command_ids', [])] @@ -181,6 +210,49 @@ class ApplicationProfile(Hashable): return f'https://discord.com/store/skus/{self.primary_sku_id}/unknown' +class MutualGuild(Hashable): + """Represents a mutual guild between a user and the client user. + + .. container:: operations + + .. describe:: x == y + + Checks if two guilds are equal. + + .. describe:: x != y + + Checks if two guilds are not equal. + + .. describe:: hash(x) + + Returns the guild's hash. + + .. versionadded:: 2.0 + + Attributes + ------------ + id: :class:`int` + The guild's ID. + nick: Optional[:class:`str`] + The guild specific nickname of the user. + """ + + __slots__ = ('id', 'nick', '_state') + + def __init__(self, *, state: ConnectionState, data: MutualGuildPayload) -> None: + self._state = state + self.id: int = int(data['id']) + self.nick: Optional[str] = data.get('nick') + + def __repr__(self) -> str: + return f'' + + @property + def guild(self) -> Guild: + """:class:`Guild`: The guild that the user is mutual with.""" + return self._state._get_or_create_unavailable_guild(self.id) + + class UserProfile(Profile, User): """Represents a Discord user's profile. @@ -209,7 +281,7 @@ class UserProfile(Profile, User): Attributes ----------- application: Optional[:class:`ApplicationProfile`] - The application profile of the user, if a bot. + The application profile of the user, if it is a bot. bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. premium_type: Optional[:class:`PremiumType`] @@ -217,23 +289,41 @@ class UserProfile(Profile, User): premium_since: Optional[:class:`datetime.datetime`] An aware datetime object that specifies how long a user has been premium (had Nitro). ``None`` if the user is not a premium user. - boosting_since: Optional[:class:`datetime.datetime`] - An aware datetime object that specifies when a user first boosted a guild. + premium_guild_since: Optional[:class:`datetime.datetime`] + An aware datetime object that specifies when a user first Nitro boosted a guild. connections: Optional[List[:class:`PartialConnection`]] The connected accounts that show up on the profile. - note: :class:`Note` - Represents the note on the profile. - mutual_guilds: Optional[List[:class:`Guild`]] + mutual_guilds: Optional[List[:class:`MutualGuild`]] A list of guilds that you share with the user. - ``None`` if you didn't fetch mutuals. + ``None`` if you didn't fetch mutual guilds. mutual_friends: Optional[List[:class:`User`]] A list of friends that you share with the user. - ``None`` if you didn't fetch mutuals. + ``None`` if you didn't fetch mutual friends. """ + __slots__ = ( + 'bio', + 'premium_type', + 'premium_since', + 'premium_guild_since', + 'connections', + 'mutual_guilds', + 'mutual_friends', + '_mutual_friends_count', + 'application', + ) + def __repr__(self) -> str: return f'' + @property + def display_bio(self) -> Optional[str]: + """Optional[:class:`str`]: Returns the user's display bio. + + This is the same as :attr:`bio` and is here for compatibility. + """ + return self.bio + class MemberProfile(Profile, Member): """Represents a Discord member's profile. @@ -265,7 +355,7 @@ class MemberProfile(Profile, Member): Attributes ----------- application: Optional[:class:`ApplicationProfile`] - The application profile of the user, if a bot. + The application profile of the user, if it is a bot. bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. guild_bio: Optional[:class:`str`] @@ -275,6 +365,7 @@ class MemberProfile(Profile, Member): "Nitro boost" on the guild, if available. This could be ``None``. .. note:: + This is renamed from :attr:`Member.premium_since` because of name collisions. premium_type: Optional[:class:`PremiumType`] Specifies the type of premium a user has (i.e. Nitro, Nitro Classic, or Nitro Basic). Could be ``None`` if the user is not premium. @@ -283,14 +374,13 @@ class MemberProfile(Profile, Member): ``None`` if the user is not a premium user. .. note:: + This is not the same as :attr:`Member.premium_since`. That is renamed to :attr:`guild_premium_since`. - boosting_since: Optional[:class:`datetime.datetime`] - An aware datetime object that specifies when a user first boosted any guild. + premium_guild_since: Optional[:class:`datetime.datetime`] + An aware datetime object that specifies when a user first Nitro boosted a guild. connections: Optional[List[:class:`PartialConnection`]] The connected accounts that show up on the profile. - note: :class:`Note` - Represents the note on the profile. - mutual_guilds: Optional[List[:class:`Guild`]] + mutual_guilds: Optional[List[:class:`MutualGuild`]] A list of guilds that you share with the user. ``None`` if you didn't fetch mutuals. mutual_friends: Optional[List[:class:`User`]] @@ -298,8 +388,24 @@ class MemberProfile(Profile, Member): ``None`` if you didn't fetch mutuals. """ - def __init__(self, *, state: ConnectionState, data: dict, guild: Guild): - super().__init__(state=state, guild=guild, data=data) + __slots__ = ( + 'bio', + 'guild_premium_since', + 'premium_type', + 'premium_since', + 'premium_guild_since', + 'connections', + 'mutual_guilds', + 'mutual_friends', + '_mutual_friends_count', + 'application', + '_banner', + 'guild_bio', + ) + + def __init__(self, **kwargs): + super().__init__(**kwargs) + data = kwargs['data'] member = data['guild_member'] self._banner: Optional[str] = member.get('banner') self.guild_bio: Optional[str] = member.get('bio') or None @@ -328,3 +434,13 @@ class MemberProfile(Profile, Member): if self._banner is None: return None return Asset._from_guild_banner(self._state, self.guild.id, self.id, self._banner) + + @property + def display_bio(self) -> Optional[str]: + """Optional[:class:`str`]: Returns the member's display bio. + + For regular members this is just their bio (if available), but + if they have a guild specific bio then that + is returned instead. + """ + return self.guild_bio or self.bio diff --git a/discord/state.py b/discord/state.py index 47273c57a..c1d7e5e4f 100644 --- a/discord/state.py +++ b/discord/state.py @@ -54,7 +54,7 @@ from discord_protos import UserSettingsType from .errors import ClientException, InvalidData, NotFound from .guild import ApplicationCommandCounts, Guild from .activity import BaseActivity, create_activity, Session -from .user import User, ClientUser +from .user import User, ClientUser, Note from .emoji import Emoji from .mentions import AllowedMentions from .partial_emoji import PartialEmoji @@ -1263,6 +1263,20 @@ class ConnectionState: if self.user: self.user._full_update(data) + def parse_user_note_update(self, data: gw.UserNoteUpdateEvent) -> None: + # The gateway does not provide note objects on READY anymore, + # so we cannot have (old, new) event dispatches + user_id = int(data['id']) + text = data['note'] + user = self.get_user(user_id) + if user: + note = user.note + note._value = text + else: + note = Note(self, user_id, note=text) + + self.dispatch('note_update', note) + # def parse_user_settings_update(self, data) -> None: # new_settings = self.settings # old_settings = copy.copy(new_settings) diff --git a/discord/types/application.py b/discord/types/application.py index f676fb1f2..f169fed9f 100644 --- a/discord/types/application.py +++ b/discord/types/application.py @@ -44,8 +44,11 @@ class BaseApplication(TypedDict): summary: NotRequired[Literal['']] -class IntegrationApplication(BaseApplication): +class MetadataApplication(BaseApplication): bot: NotRequired[PartialUser] + + +class IntegrationApplication(MetadataApplication): role_connections_verification_url: NotRequired[Optional[str]] @@ -76,6 +79,7 @@ class PartialApplication(BaseApplication): eula_id: NotRequired[Snowflake] embedded_activity_config: NotRequired[EmbeddedActivityConfig] guild: NotRequired[PartialGuild] + install_params: NotRequired[ApplicationInstallParams] class ApplicationDiscoverability(TypedDict): @@ -234,6 +238,11 @@ class EmbeddedActivityConfig(TypedDict): supported_platforms: List[EmbeddedActivityPlatform] +class ApplicationInstallParams(TypedDict): + scopes: List[str] + permissions: int + + class ActiveDeveloperWebhook(TypedDict): channel_id: Snowflake webhook_id: Snowflake @@ -241,3 +250,23 @@ class ActiveDeveloperWebhook(TypedDict): class ActiveDeveloperResponse(TypedDict): follower: ActiveDeveloperWebhook + + +class RoleConnectionMetadata(TypedDict): + type: Literal[1, 2, 3, 4, 5, 6, 7, 8] + key: str + name: str + description: str + name_localizations: NotRequired[Dict[str, str]] + description_localizations: NotRequired[Dict[str, str]] + + +class PartialRoleConnection(TypedDict): + platform_name: Optional[str] + platform_username: Optional[str] + metadata: Dict[str, str] + + +class RoleConnection(PartialRoleConnection): + application: MetadataApplication + application_metadata: List[RoleConnectionMetadata] diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 0414ca2fc..f227aa0e1 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -503,3 +503,8 @@ class PassiveUpdateEvent(TypedDict): class GuildApplicationCommandIndexUpdateEvent(TypedDict): guild_id: Snowflake application_command_counts: ApplicationCommandCounts + + +class UserNoteUpdateEvent(TypedDict): + id: Snowflake + note: str diff --git a/discord/types/member.py b/discord/types/member.py index ad9e49008..94297132e 100644 --- a/discord/types/member.py +++ b/discord/types/member.py @@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE. from typing import Optional, TypedDict from .snowflake import SnowflakeList -from .user import User +from .user import PartialUser class Nickname(TypedDict): @@ -40,27 +40,30 @@ class PartialMember(TypedDict): class Member(PartialMember, total=False): - avatar: str - user: User + avatar: Optional[str] + user: PartialUser nick: str premium_since: Optional[str] pending: bool - permissions: str communication_disabled_until: str class _OptionalMemberWithUser(PartialMember, total=False): - avatar: str + avatar: Optional[str] nick: str premium_since: Optional[str] pending: bool - permissions: str communication_disabled_until: str class MemberWithUser(_OptionalMemberWithUser): - user: User + user: PartialUser -class UserWithMember(User, total=False): +class PrivateMember(MemberWithUser): + bio: str + banner: Optional[str] + + +class UserWithMember(PartialUser, total=False): member: _OptionalMemberWithUser diff --git a/discord/types/profile.py b/discord/types/profile.py new file mode 100644 index 000000000..2eab6ad47 --- /dev/null +++ b/discord/types/profile.py @@ -0,0 +1,73 @@ +""" +The MIT License (MIT) + +Copyright (c) 2021-present Dolfies + +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 typing import List, Optional, TypedDict +from typing_extensions import NotRequired + +from .application import ApplicationInstallParams, RoleConnection +from .member import PrivateMember as ProfileMember +from .snowflake import Snowflake +from .user import APIUser, PartialConnection, PremiumType + + +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 MutualGuild(TypedDict): + id: Snowflake + nick: Optional[str] + + +class ProfileApplication(TypedDict): + id: Snowflake + verified: bool + popular_application_command_ids: NotRequired[List[Snowflake]] + primary_sku_id: NotRequired[Snowflake] + flags: int + custom_install_url: NotRequired[str] + install_params: NotRequired[ApplicationInstallParams] + + +class Profile(TypedDict): + user: ProfileUser + user_profile: Optional[ProfileMetadata] + guild_member: NotRequired[ProfileMember] + guild_member_profile: NotRequired[Optional[ProfileMetadata]] + mutual_guilds: NotRequired[List[MutualGuild]] + mutual_friends_count: NotRequired[int] + connected_accounts: List[PartialConnection] + application_role_connections: NotRequired[List[RoleConnection]] + premium_type: Optional[PremiumType] + premium_since: Optional[str] + premium_guild_since: Optional[str] + application: NotRequired[ProfileApplication] diff --git a/discord/types/user.py b/discord/types/user.py index a3a0a73e9..8490e601b 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -67,7 +67,12 @@ ConnectionVisibilty = Literal[0, 1] PremiumType = Literal[0, 1, 2, 3] -class User(PartialUser, total=False): +class APIUser(PartialUser): + banner: Optional[str] + accent_color: Optional[int] + + +class User(APIUser, total=False): mfa_enabled: bool locale: str verified: bool @@ -76,8 +81,6 @@ class User(PartialUser, total=False): purchased_flags: int premium_usage_flags: int premium_type: PremiumType - banner: Optional[str] - accent_color: Optional[int] bio: str analytics_token: str phone: Optional[str] @@ -147,3 +150,9 @@ class GuildAffinity(TypedDict): class GuildAffinities(TypedDict): guild_affinities: List[GuildAffinity] + + +class Note(TypedDict): + note: str + user_id: Snowflake + note_user_id: Snowflake diff --git a/discord/user.py b/discord/user.py index d5031700f..906b28ecf 100644 --- a/discord/user.py +++ b/discord/user.py @@ -40,7 +40,7 @@ from .enums import ( 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, copy_doc, snowflake_time, MISSING +from .utils import _bytes_to_base64_data, _get_as_snowflake, cached_slot_property, copy_doc, snowflake_time, MISSING from .voice_client import VoiceClient if TYPE_CHECKING: @@ -74,8 +74,6 @@ __all__ = ( class Note: """Represents a Discord note. - .. versionadded:: 2.0 - .. container:: operations .. describe:: x == y @@ -97,19 +95,21 @@ class Note: .. describe:: len(x) Returns the note's length. + .. versionadded:: 1.9 + Attributes ----------- user_id: :class:`int` The user ID the note is for. """ - __slots__ = ('_state', '_note', 'user_id', '_user') + __slots__ = ('_state', '_value', 'user_id', '_user') def __init__( self, state: ConnectionState, user_id: int, *, user: Optional[User] = None, note: Optional[str] = MISSING ) -> None: self._state = state - self._note: Optional[str] = note + self._value: Optional[str] = note self.user_id: int = user_id self._user: Optional[User] = user @@ -124,9 +124,9 @@ class Note: ClientException Attempted to access note without fetching it. """ - if self._note is MISSING: + if self._value is MISSING: raise ClientException('Note is not fetched') - return self._note + return self._value @property def value(self) -> Optional[str]: @@ -143,8 +143,8 @@ class Note: @property def user(self) -> Optional[User]: - """Optional[:class:`User`]: Returns the :class:`User` the note belongs to.""" - return self._user or self._state.get_user(self.user_id) + """Optional[:class:`User`]: Returns the user the note belongs to.""" + return self._state.get_user(self.user_id) or self._user async def fetch(self) -> Optional[str]: """|coro| @@ -163,16 +163,18 @@ class Note: """ try: data = await self._state.http.get_note(self.user_id) - self._note = data['note'] + self._value = data['note'] return data['note'] - except NotFound: # 404 = no note - self._note = None + except NotFound: + # A 404 means the note doesn't exist + # However, this is bad UX, so we just return None + self._value = None return None async def edit(self, note: Optional[str]) -> None: """|coro| - Changes the note. + Modifies the note. Can be at most 256 characters. Raises ------- @@ -180,7 +182,7 @@ class Note: Changing the note failed. """ await self._state.http.set_note(self.user_id, note=note) - self._note = note + self._value = note or '' async def delete(self) -> None: """|coro| @@ -196,14 +198,14 @@ class Note: def __repr__(self) -> str: base = f'' + return f'{base} note={note!r}>' + return f'{base}>' def __str__(self) -> str: - note = self._note + note = self._value if note is MISSING: raise ClientException('Note is not fetched') return note or '' @@ -212,15 +214,15 @@ class Note: return bool(str(self)) def __eq__(self, other: object) -> bool: - return isinstance(other, Note) and self._note == other._note and self.user_id == other.user_id + return isinstance(other, Note) and self.user_id == other.user_id def __ne__(self, other: object) -> bool: if isinstance(other, Note): - return self._note != other._note or self.user_id != other.user_id + return self._value != other._value or self.user_id != other.user_id return True def __hash__(self) -> int: - return hash((self._note, self.user_id)) + return hash((self._value, self.user_id)) def __len__(self) -> int: note = str(self) @@ -244,6 +246,7 @@ class BaseUser(_UserTag): 'bot', 'system', '_public_flags', + '_cs_note', '_state', ) @@ -313,7 +316,7 @@ class BaseUser(_UserTag): return self def _to_minimal_user_json(self) -> PartialUserPayload: - user: UserPayload = { + user: PartialUserPayload = { 'username': self.name, 'id': self.id, 'avatar': self._avatar, @@ -473,6 +476,18 @@ class BaseUser(_UserTag): """ return self.name + @cached_slot_property('_cs_note') + def note(self) -> Note: + """:class:`Note`: Returns an object representing the user's note. + + .. versionadded:: 2.0 + + .. note:: + + The underlying note is cached and updated from gateway events. + """ + return Note(self._state, self.id, user=self) # type: ignore + def mentioned_in(self, message: Message) -> bool: """Checks if the user is mentioned in the specified message. @@ -1012,18 +1027,35 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): """ await self._state.http.send_friend_request(self.name, self.discriminator) - async def profile(self, *, with_mutuals: bool = True, fetch_note: bool = True) -> UserProfile: + async def profile( + self, + *, + with_mutual_guilds: bool = True, + with_mutual_friends_count: bool = False, + with_mutual_friends: bool = True, + ) -> UserProfile: """|coro| - Gets the user's profile. + A shorthand method to retrieve a :class:`UserProfile` for the user. Parameters ------------ - with_mutuals: :class:`bool` - Whether to fetch mutual guilds and friends. - This fills in :attr:`.UserProfile.mutual_guilds` & :attr:`.UserProfile.mutual_friends`. - fetch_note: :class:`bool` - Whether to pre-fetch the user's note. + with_mutual_guilds: :class:`bool` + Whether to fetch mutual guilds. + This fills in :attr:`UserProfile.mutual_guilds`. + + .. versionadded:: 2.0 + with_mutual_friends_count: :class:`bool` + Whether to fetch the number of mutual friends. + This fills in :attr:`UserProfile.mutual_friends_count`. + + .. 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. + + .. versionadded:: 2.0 Raises ------- @@ -1037,21 +1069,9 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable): :class:`UserProfile` The profile of the user. """ - from .profile import UserProfile - - user_id = self.id - state = self._state - data = await state.http.get_user_profile(user_id, with_mutual_guilds=with_mutuals) - - if with_mutuals: - if not data['user'].get('bot', False): - data['mutual_friends'] = await self._state.http.get_mutual_friends(user_id) - else: - data['mutual_friends'] = [] - - profile = UserProfile(state=state, data=data) - - if fetch_note: - await profile.note.fetch() - - return profile + return await self._state.client.fetch_user_profile( + self.id, + with_mutual_guilds=with_mutual_guilds, + with_mutual_friends_count=with_mutual_friends_count, + with_mutual_friends=with_mutual_friends, + ) diff --git a/docs/api.rst b/docs/api.rst index 59cf2bf8d..2f14c878e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -665,6 +665,16 @@ Relationships :param after: The updated relationship. :type after: :class:`Relationship` +Notes +~~~~~~ + +.. function:: on_note_update(note) + + Called when a :class:`User`\'s note is updated. + + :param note: The note that was updated. + :type note: :class:`Note` + Calls ~~~~~ @@ -6787,6 +6797,11 @@ Guild .. autoclass:: UserGuild() :members: +.. attributetable:: MutualGuild + +.. autoclass:: MutualGuild() + :members: + .. class:: BanEntry A namedtuple which represents a ban returned from :meth:`~Guild.bans`.