From 8a315c48e6699d65d50a344a451e9b105516cf80 Mon Sep 17 00:00:00 2001 From: dolfies Date: Sun, 25 Sep 2022 14:37:01 -0400 Subject: [PATCH] Finish profile and application install param implementations, fix miscallenous related things --- discord/appinfo.py | 124 ++++++++++++++++++++++++++++++++----- discord/asset.py | 11 ++++ discord/member.py | 34 ++++++++++- discord/profile.py | 148 ++++++++++++++++++++++++++++++++++++++------- discord/user.py | 81 +++++++++++++------------ docs/api.rst | 9 ++- 6 files changed, 327 insertions(+), 80 deletions(-) diff --git a/discord/appinfo.py b/discord/appinfo.py index c9659cbcc..19ff73598 100644 --- a/discord/appinfo.py +++ b/discord/appinfo.py @@ -48,6 +48,7 @@ __all__ = ( 'ApplicationBot', 'ApplicationCompany', 'ApplicationExecutable', + 'ApplicationInstallParams', 'Application', 'PartialApplication', 'InteractionApplication', @@ -61,6 +62,24 @@ class ApplicationBot(User): .. versionadded:: 2.0 + .. container:: operations + + .. describe:: x == y + + Checks if two bots are equal. + + .. describe:: x != y + + Checks if two bots are not equal. + + .. describe:: hash(x) + + Return the bot's hash. + + .. describe:: str(x) + + Returns the bot's name with discriminator. + Attributes ----------- application: :class:`Application` @@ -188,6 +207,12 @@ class ApplicationExecutable: .. versionadded:: 2.0 + .. container:: operations + + .. describe:: str(x) + + Returns the executable's name. + Attributes ----------- name: :class:`str` @@ -213,6 +238,53 @@ class ApplicationExecutable: self.launcher: bool = data['is_launcher'] self.application = application + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.name + + +class ApplicationInstallParams: + """Represents an application's authorization parameters. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: str(x) + + Returns the authorization URL. + + Attributes + ---------- + id: :class:`int` + The application's ID. + scopes: List[:class:`str`] + The list of `OAuth2 scopes `_ + to add the application with. + permissions: :class:`Permissions` + The permissions to grant to the added bot. + """ + + __slots__ = ('id', 'scopes', 'permissions') + + def __init__(self, id: int, data: dict): + self.id: int = id + self.scopes: List[str] = data.get('scopes', []) + self.permissions: Permissions = Permissions(int(data.get('permissions', 0))) + + def __repr__(self) -> str: + return f'' + + def __str__(self) -> str: + return self.url + + @property + def url(self) -> str: + """:class:`str`: The URL to add the application with the parameters.""" + return utils.oauth_url(self.id, permissions=self.permissions, scopes=self.scopes) + class PartialApplication(Hashable): """Represents a partial Application. @@ -280,6 +352,10 @@ class PartialApplication(Hashable): A list of publishers that published the application. Only available for specific applications. executables: List[:class:`ApplicationExecutable`] A list of executables that are the application's. Only available for specific applications. + custom_install_url: Optional[:class:`str`] + The custom URL to use for authorizing the application, if specified. + install_params: Optional[:class:`ApplicationInstallParams`] + The parameters to use for authorizing the application, if specified. """ __slots__ = ( @@ -302,13 +378,14 @@ class PartialApplication(Hashable): 'premium_tier_level', 'tags', 'max_participants', - 'install_url', 'overlay', 'overlay_compatibility_hook', 'aliases', 'developers', 'publishers', 'executables', + 'custom_install_url', + 'install_params', ) def __init__(self, *, state: ConnectionState, data: PartialAppInfoPayload): @@ -351,15 +428,10 @@ class PartialApplication(Hashable): self.overlay: bool = data.get('overlay', False) self.overlay_compatibility_hook: bool = data.get('overlay_compatibility_hook', False) - install_params = data.get('install_params', {}) - self.install_url = ( - data.get('custom_install_url') - if not install_params - else utils.oauth_url( - self.id, - permissions=Permissions(int(install_params.get('permissions', 0))), - scopes=install_params.get('scopes', utils.MISSING), - ) + params = data.get('install_params') + self.custom_install_url: Optional[str] = data.get('custom_install_url') + self.install_params: Optional[ApplicationInstallParams] = ( + ApplicationInstallParams(self.id, params) if params else None ) self.public: bool = data.get( @@ -401,12 +473,35 @@ class PartialApplication(Hashable): """:class:`ApplicationFlags`: The flags of this application.""" return ApplicationFlags._from_value(self._flags) + @property + def install_url(self) -> Optional[str]: + """:class:`str`: The URL to install the application.""" + return self.custom_install_url or self.install_params.url if self.install_params else None + class Application(PartialApplication): """Represents application info for an application you own. .. versionadded:: 2.0 + .. container:: operations + + .. describe:: x == y + + Checks if two applications are equal. + + .. describe:: x != y + + Checks if two applications are not equal. + + .. describe:: hash(x) + + Return the application's hash. + + .. describe:: str(x) + + Returns the application's name. + Attributes ------------- owner: :class:`abc.User` @@ -416,12 +511,9 @@ class Application(PartialApplication): bot: Optional[:class:`ApplicationBot`] The bot attached to the application, if any. guild_id: Optional[:class:`int`] - If this application is a game sold on Discord, - this field will be the guild to which it has been linked to. + The guild ID this application is linked to, if any. primary_sku_id: Optional[:class:`int`] - If this application is a game sold on Discord, - this field will be the id of the "Game SKU" that is created, - if it exists. + The application's primary SKU ID, if any. slug: Optional[:class:`str`] If this application is a game sold on Discord, this field will be the URL slug that links to the store page. @@ -678,6 +770,8 @@ class InteractionApplication(Hashable): The application description. type: Optional[:class:`ApplicationType`] The type of application. + primary_sku_id: Optional[:class:`int`] + The application's primary SKU ID, if any. """ __slots__ = ( diff --git a/discord/asset.py b/discord/asset.py index 071e7766c..9613b488b 100644 --- a/discord/asset.py +++ b/discord/asset.py @@ -235,6 +235,17 @@ class Asset(AssetMixin): animated=animated, ) + @classmethod + def _from_guild_banner(cls, state: _State, guild_id: int, member_id: int, avatar: str) -> Self: + animated = avatar.startswith('a_') + format = 'gif' if animated else 'png' + return cls( + state, + url=f"{cls.BASE}/guilds/{guild_id}/users/{member_id}/banners/{avatar}.{format}?size=512", + key=avatar, + animated=animated, + ) + @classmethod def _from_icon(cls, state: _State, object_id: int, icon_hash: str, path: str) -> Self: return cls( diff --git a/discord/member.py b/discord/member.py index 730cebe02..c01b377f1 100644 --- a/discord/member.py +++ b/discord/member.py @@ -215,9 +215,9 @@ class _ClientStatus: def flatten_user(cls: Any) -> Type[Member]: for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): - # Ignore private/special methods (or not) - # if attr.startswith('_'): - # continue + # Ignore private/special methods + if attr.startswith('_'): + continue # Don't override what we already have if attr in cls.__dict__: @@ -461,6 +461,16 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): # Signal to dispatch user_update return to_return, u + def _get_voice_client_key(self) -> Tuple[int, str]: + return self._state.self_id, 'self_id' # type: ignore # self_id is always set at this point + + def _get_voice_state_pair(self) -> Tuple[int, int]: + return self._state.self_id, self.dm_channel.id # type: ignore # self_id is always set at this point + + async def _get_channel(self) -> DMChannel: + ch = await self.create_dm() + return ch + @property def status(self) -> Status: """:class:`Status`: The member's overall status. If the value is unknown, then it will be a :class:`str` instead.""" @@ -733,6 +743,8 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): voice_channel: Optional[VocalGuildChannel] = MISSING, timed_out_until: Optional[datetime.datetime] = MISSING, avatar: Optional[bytes] = MISSING, + banner: Optional[bytes] = MISSING, + bio: Optional[str] = MISSING, reason: Optional[str] = None, ) -> Optional[Member]: """|coro| @@ -798,6 +810,16 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): The member's new guild avatar. Pass ``None`` to remove the avatar. You can only change your own guild avatar. + .. versionadded:: 2.0 + banner: Optional[:class:`bytes`] + The member's new guild banner. Pass ``None`` to remove the banner. + You can only change your own guild banner. + + .. versionadded:: 2.0 + bio: Optional[:class:`str`] + The member's new guild "about me". Pass ``None`` to remove the bio. + You can only change your own guild bio. + .. versionadded:: 2.0 reason: Optional[:class:`str`] The reason for editing this member. Shows up on the audit log. @@ -829,6 +851,12 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag): if avatar is not MISSING: payload['avatar'] = utils._bytes_to_base64_data(avatar) if avatar is not None else None + if banner is not MISSING: + payload['banner'] = utils._bytes_to_base64_data(banner) if banner is not None else None + + if bio is not MISSING: + payload['bio'] = bio or '' + if me and payload: data = await http.edit_me(self.guild.id, **payload) payload = {} diff --git a/discord/profile.py b/discord/profile.py index afbc8392a..4ae824420 100644 --- a/discord/profile.py +++ b/discord/profile.py @@ -24,14 +24,18 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -from typing import List, Optional, TYPE_CHECKING +from typing import TYPE_CHECKING, List, Optional +from . import utils +from .appinfo import ApplicationInstallParams +from .asset import Asset from .connections import PartialConnection +from .enums import PremiumType, try_enum +from .flags import ApplicationFlags from .member import Member +from .mixins import Hashable from .object import Object -from .permissions import Permissions from .user import Note, User -from . import utils if TYPE_CHECKING: from datetime import datetime @@ -40,14 +44,13 @@ if TYPE_CHECKING: from .state import ConnectionState __all__ = ( + 'ApplicationProfile', 'UserProfile', 'MemberProfile', ) class Profile: - """Represents a Discord profile.""" - if TYPE_CHECKING: id: int application_id: Optional[int] @@ -65,10 +68,17 @@ class Profile: super().__init__(**kwargs) - self._flags: int = user.pop('flags', 0) - self.bio: Optional[str] = user.pop('bio') or None - self.note: Note = Note(kwargs['state'], self.id, user=self) + 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 + # 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) + if guild_premium_since is not utils.MISSING: + 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 + ) self.premium_since: Optional[datetime] = utils.parse_time(data['premium_since']) self.boosting_since: Optional[datetime] = utils.parse_time(data['premium_guild_since']) self.connections: List[PartialConnection] = [PartialConnection(d) for d in data['connected_accounts']] @@ -77,9 +87,7 @@ class Profile: self.mutual_friends: Optional[List[User]] = self._parse_mutual_friends(data.get('mutual_friends')) application = data.get('application', {}) - install_params = application.get('install_params', {}) - self.application_id = app_id = utils._get_as_snowflake(application, 'id') - self.install_url = application.get('custom_install_url') if not install_params else utils.oauth_url(app_id, permissions=Permissions(int(install_params.get('permissions', 0))), scopes=install_params.get('scopes', utils.MISSING)) # type: ignore # app_id is always present here + 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: @@ -90,7 +98,7 @@ class Profile: def get_guild(guild): return state._get_guild(int(guild['id'])) or Object(id=int(guild['id'])) - return list(filter(None, map(get_guild, mutual_guilds))) # type: ignore # Lying for better developer UX + return list(map(get_guild, mutual_guilds)) # type: ignore # Lying for better developer UX def _parse_mutual_friends(self, mutual_friends) -> Optional[List[User]]: if mutual_friends is None: @@ -105,17 +113,79 @@ class Profile: return self.premium_since is not None +class ApplicationProfile(Hashable): + """Represents a Discord application profile. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two applications are equal. + + .. describe:: x != y + + Checks if two applications are not equal. + + .. describe:: hash(x) + + Return the applications's hash. + + Attributes + ------------ + id: :class:`int` + The application's ID. + verified: :class:`bool` + Indicates if the application is verified. + popular_application_command_ids: List[:class:`int`] + A list of the IDs of the application's popular commands. + primary_sku_id: Optional[:class:`int`] + The application's primary SKU ID, if any. + custom_install_url: Optional[:class:`str`] + The custom URL to use for authorizing the application, if specified. + install_params: Optional[:class:`ApplicationInstallParams`] + The parameters to use for authorizing the application, if specified. + """ + + def __init__(self, data: dict) -> 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', [])] + self.primary_sku_id: Optional[int] = utils._get_as_snowflake(data, 'primary_sku_id') + self._flags: int = data.get('flags', 0) + + params = data.get('install_params') + self.custom_install_url: Optional[str] = data.get('custom_install_url') + self.install_params: Optional[ApplicationInstallParams] = ( + ApplicationInstallParams(self.id, params) if params else None + ) + + def __repr__(self) -> str: + return f'' + + @property + def flags(self) -> ApplicationFlags: + """:class:`ApplicationFlags`: The flags of this application.""" + return ApplicationFlags._from_value(self._flags) + + @property + def install_url(self) -> Optional[str]: + """:class:`str`: The URL to install the application.""" + return self.custom_install_url or self.install_params.url if self.install_params else None + + class UserProfile(Profile, User): """Represents a Discord user's profile. This is a :class:`User` with extended attributes. Attributes ----------- - application_id: Optional[:class:`int`] - The ID of the application that this user is attached to, if applicable. - install_url: Optional[:class:`str`] - The URL to invite the application to your guild with. + application: Optional[:class:`ApplicationProfile`] + The application profile of the user, if a bot. bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. + 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. 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. @@ -142,17 +212,28 @@ class MemberProfile(Profile, Member): Attributes ----------- - application_id: Optional[:class:`int`] - The ID of the application that this user is attached to, if applicable. - install_url: Optional[:class:`str`] - The URL to invite the application to your guild with. + application: Optional[:class:`ApplicationProfile`] + The application profile of the user, if a bot. bio: Optional[:class:`str`] The user's "about me" field. Could be ``None``. + guild_bio: Optional[:class:`str`] + The user's "about me" field for the guild. Could be ``None``. + guild_premium_since: Optional[:class:`datetime.datetime`] + An aware datetime object that specifies the date and time in UTC when the member used their + "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. 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. + + .. 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 a guild. + An aware datetime object that specifies when a user first boosted any guild. connections: Optional[List[:class:`PartialConnection`]] The connected accounts that show up on the profile. note: :class:`Note` @@ -165,8 +246,33 @@ 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) + member = data['guild_member'] + self._banner: Optional[str] = member.get('banner') + self.guild_bio: Optional[str] = member.get('bio') or None + def __repr__(self) -> str: return ( f'' ) + + @property + def display_banner(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the member's display banner. + + For regular members this is just their banner (if available), but + if they have a guild specific banner then that + is returned instead. + """ + return self.guild_banner or self._user.banner + + @property + def guild_banner(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns an :class:`Asset` for the guild banner + the member has. If unavailable, ``None`` is returned. + """ + if self._banner is None: + return None + return Asset._from_guild_banner(self._state, self.guild.id, self.id, self._banner) diff --git a/discord/user.py b/discord/user.py index 8c04f2388..3ffe4dc55 100644 --- a/discord/user.py +++ b/discord/user.py @@ -75,6 +75,8 @@ __all__ = ( class Note: """Represents a Discord note. + .. versionadded:: 2.0 + .. container:: operations .. describe:: x == y @@ -95,22 +97,26 @@ class Note: .. describe:: len(x) Returns the note's length. + + Attributes + ----------- + user_id: :class:`int` + The user ID the note is for. """ - __slots__ = ('_state', '_note', '_user_id', '_user') + __slots__ = ('_state', '_note', 'user_id', '_user') def __init__( - self, state: ConnectionState, user_id: int, *, user: _Snowflake = MISSING, note: Optional[str] = MISSING + self, state: ConnectionState, user_id: int, *, user: Optional[User] = None, note: Optional[str] = MISSING ) -> None: self._state = state - self._user_id = user_id - self._note = note - if user is not MISSING: - self._user = user + self._note: Optional[str] = note + self.user_id: int = user_id + self._user: Optional[User] = user @property def note(self) -> Optional[str]: - """Returns the note. + """Optional[:class:`str`]: Returns the note. There is an alias for this called :attr:`value`. @@ -125,7 +131,7 @@ class Note: @property def value(self) -> Optional[str]: - """Returns the note. + """Optional[:class:`str`]: Returns the note. This is an alias of :attr:`note`. @@ -136,15 +142,10 @@ class Note: """ return self.note - @cached_slot_property('_user') - def user(self) -> _Snowflake: - """:class:`~abc.Snowflake`: Returns the :class:`User` or :class:`Object` the note belongs to.""" - user_id = self._user_id - - user = self._state.get_user(user_id) - if user is None: - user = Object(user_id) - return user + @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) async def fetch(self) -> Optional[str]: """|coro| @@ -162,7 +163,7 @@ class Note: The note or ``None`` if it doesn't exist. """ try: - data = await self._state.http.get_note(self.user.id) + data = await self._state.http.get_note(self.user_id) self._note = data['note'] return data['note'] except NotFound: # 404 = no note @@ -179,7 +180,7 @@ class Note: HTTPException Changing the note failed. """ - await self._state.http.set_note(self._user_id, note=note) + await self._state.http.set_note(self.user_id, note=note) self._note = note async def delete(self) -> None: @@ -206,32 +207,25 @@ class Note: note = self._note if note is MISSING: raise ClientException('Note is not fetched') - elif note is None: - return '' - else: - return note + return note or '' def __bool__(self) -> bool: - try: - return bool(self._note) - except TypeError: - return False + 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._note == 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._note != other._note or self.user_id != other.user_id return True def __hash__(self) -> int: - return hash((self._note, self._user_id)) + return hash((self._note, self.user_id)) def __len__(self) -> int: - if note := self._note: - return len(note) - return 0 + note = str(self) + return len(note) if note else 0 class _UserTag: @@ -367,7 +361,6 @@ class BaseUser(_UserTag): .. versionadded:: 2.0 - .. note:: This information is only available via :meth:`Client.fetch_user`. """ @@ -375,6 +368,16 @@ class BaseUser(_UserTag): return None return Asset._from_user_banner(self._state, self.id, self._banner) + @property + def display_banner(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the user's banner asset, if available. + + This is the same as :attr:`banner` and is here for compatibility. + + .. versionadded:: 2.0 + """ + return self.banner + @property def accent_colour(self) -> Optional[Colour]: """Optional[:class:`Colour`]: Returns the user's accent colour, if applicable. @@ -518,7 +521,7 @@ class ClientUser(BaseUser): mfa_enabled: :class:`bool` Specifies if the user has MFA turned on and working. premium_type: Optional[:class:`PremiumType`] - Specifies the type of premium a user has (i.e. Nitro or Nitro Classic). Could be None if the user is not premium. + 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. note: :class:`Note` The user's note. Not pre-fetched. @@ -579,7 +582,7 @@ class ClientUser(BaseUser): self._premium_usage_flags = data.get('premium_usage_flags', 0) self.mfa_enabled = data.get('mfa_enabled', False) self.premium_type = try_enum(PremiumType, data['premium_type']) if 'premium_type' in data else None - self.bio = data.get('bio') + self.bio = data.get('bio') or None self.nsfw_allowed = data.get('nsfw_allowed') def get_relationship(self, user_id: int) -> Optional[Relationship]: @@ -721,8 +724,8 @@ class ClientUser(BaseUser): accent_colour/_color: :class:`Colour` A :class:`Colour` object of the colour you want to set your profile to. bio: :class:`str` - Your 'about me' section. - Could be ``None`` to represent no 'about me'. + Your "about me" section. + Could be ``None`` to represent no bio. date_of_birth: :class:`datetime.datetime` Your date of birth. Can only ever be set once. @@ -743,7 +746,7 @@ class ClientUser(BaseUser): """ args: Dict[str, Any] = {} - if any(x is not MISSING for x in ('new_password', 'email', 'username', 'discriminator')): + if any(x is not MISSING for x in (new_password, email, username, discriminator)): if password is MISSING: raise ValueError('Password is required') args['password'] = password diff --git a/docs/api.rst b/docs/api.rst index a739a826a..f49506414 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -4266,6 +4266,11 @@ Application .. autoclass:: PartialApplication() :members: +.. attributetable:: InteractionApplication + +.. autoclass:: InteractionApplication() + :members: + .. attributetable:: ApplicationCompany .. autoclass:: ApplicationCompany() @@ -4276,9 +4281,9 @@ Application .. autoclass:: ApplicationExecutable() :members: -.. attributetable:: InteractionApplication +.. attributetable:: ApplicationInstallParams -.. autoclass:: InteractionApplication() +.. autoclass:: ApplicationInstallParams() :members: Team