From 3ecca7293d1f15dc1f0c5c6dde538fdde5a6db93 Mon Sep 17 00:00:00 2001 From: dolfies Date: Wed, 14 Sep 2022 19:50:35 -0400 Subject: [PATCH] Expose missing invite data, implement friend invites, improve invite methods --- discord/client.py | 140 ++++++++++++++++++++++++++++++++------------ discord/http.py | 16 ++++- discord/invite.py | 101 ++++++++++++++++++++++---------- discord/tracking.py | 10 +++- 4 files changed, 194 insertions(+), 73 deletions(-) diff --git a/discord/client.py b/discord/client.py index 173e3b6cc..a2a4d5a53 100644 --- a/discord/client.py +++ b/discord/client.py @@ -54,7 +54,7 @@ from .widget import Widget from .guild import Guild from .emoji import Emoji from .channel import _private_channel_factory, _threaded_channel_factory, GroupChannel, PartialMessageable -from .enums import ActivityType, ChannelType, ConnectionLinkType, ConnectionType, Status, InviteType, try_enum +from .enums import ActivityType, ChannelType, ConnectionLinkType, ConnectionType, Status, try_enum from .mentions import AllowedMentions from .errors import * from .enums import Status @@ -1749,6 +1749,27 @@ class Client: # Invite management + async def invites(self) -> List[Invite]: + r"""|coro| + + Gets a list of the user's friend :class:`.Invite`\s. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Getting the invites failed. + + Returns + -------- + List[:class:`.Invite`] + The list of invites. + """ + state = self._connection + data = await state.http.get_friend_invites() + return [Invite.from_incomplete(state=state, data=d) for d in data] + async def fetch_invite( self, url: Union[Invite, str], @@ -1825,46 +1846,34 @@ class Client: ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /) -> None: + async def create_invite(self) -> Invite: """|coro| - Revokes an :class:`.Invite`, URL, or ID to an invite. - - You must have the :attr:`~.Permissions.manage_channels` permission in - the associated guild to do this. - - .. versionchanged:: 2.0 - - ``invite`` parameter is now positional-only. + Creates a new friend :class:`.Invite`. - Parameters - ---------- - invite: Union[:class:`.Invite`, :class:`str`] - The invite to revoke. + .. versionadded:: 2.0 Raises - ------- - Forbidden - You do not have permissions to revoke invites. - NotFound - The invite is invalid or expired. + ------ HTTPException - Revoking the invite failed. + Creating the invite failed. + + Returns + -------- + :class:`.Invite` + The created friend invite. """ - resolved = utils.resolve_invite(invite) - await self.http.delete_invite(resolved.code) + state = self._connection + data = await state.http.create_friend_invite() + return Invite.from_incomplete(state=state, data=data) - async def accept_invite(self, invite: Union[Invite, str], /) -> Union[Guild, User, GroupChannel]: + async def accept_invite(self, invite: Union[Invite, str], /) -> Invite: """|coro| Uses an invite. Either joins a guild, joins a group DM, or adds a friend. - .. versionadded:: 1.9 - - .. versionchanged:: 2.0 - - ``invite`` parameter is now positional-only. + .. versionadded:: 2.0 Parameters ---------- @@ -1878,9 +1887,8 @@ class Client: Returns ------- - :class:`.Guild` - The guild joined. This is not the same guild that is - added to cache. + :class:`.Invite` + The accepted invite. """ if not isinstance(invite, Invite): invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False) @@ -1896,14 +1904,68 @@ class Client: 'channel_type': getattr(invite.channel, 'type', MISSING), } data = await state.http.accept_invite(invite.code, type, **kwargs) - if type is InviteType.guild: - guild = Guild(data=data['guild'], state=state) - guild._cs_joined = True - return guild - elif type is InviteType.group_dm: - return GroupChannel(data=data['channel'], state=state, me=state.user) # type: ignore - else: - return User(data=data['inviter'], state=state) + return Invite.from_incomplete(state=state, data=data, message=invite._message) + + async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: + """|coro| + + Revokes an :class:`.Invite`, URL, or ID to an invite. + + You must have the :attr:`~.Permissions.manage_channels` permission in + the associated guild to do this. + + .. versionchanged:: 2.0 + + ``invite`` parameter is now positional-only. + + .. versionchanged:: 2.0 + + The function now returns the deleted invite. + + Parameters + ---------- + invite: Union[:class:`.Invite`, :class:`str`] + The invite to revoke. + + Raises + ------- + Forbidden + You do not have permissions to revoke invites. + NotFound + The invite is invalid or expired. + HTTPException + Revoking the invite failed. + + Returns + -------- + :class:`.Invite` + The deleted invite. + """ + resolved = utils.resolve_invite(invite) + state = self._connection + data = await state.http.delete_invite(resolved.code) + return Invite.from_incomplete(state=state, data=data) + + async def revoke_invites(self) -> List[Invite]: + r"""|coro| + + Revokes all of the user's friend :class:`.Invite`\s. + + .. versionadded:: 2.0 + + Raises + ------ + HTTPException + Revoking the invites failed. + + Returns + -------- + List[:class:`.Invite`] + The revoked invites. + """ + state = self._connection + data = await state.http.delete_friend_invites() + return [Invite(state=state, data=d) for d in data] # Miscellaneous stuff diff --git a/discord/http.py b/discord/http.py index 41696e1fe..f55bbd7fb 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1721,8 +1721,14 @@ class HTTPClient: payload = { 'max_age': max_age, } + props = ContextProperties._from_group_dm_invite() - return self.request(Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id), json=payload) + return self.request( + Route('POST', '/channels/{channel_id}/invites', channel_id=channel_id), json=payload, context_properties=props + ) + + def create_friend_invite(self) -> Response[invite.Invite]: + return self.request(Route('POST', '/users/@me/invites'), json={}, context_properties=ContextProperties._empty()) def get_invite( self, @@ -1748,9 +1754,15 @@ class HTTPClient: def invites_from_channel(self, channel_id: Snowflake) -> Response[List[invite.Invite]]: return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) - def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Response[None]: + def get_friend_invites(self) -> Response[List[invite.Invite]]: + return self.request(Route('GET', '/users/@me/invites'), context_properties=ContextProperties._empty()) + + def delete_invite(self, invite_id: str, *, reason: Optional[str] = None) -> Response[invite.Invite]: return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) + def delete_friend_invites(self) -> Response[List[invite.Invite]]: + return self.request(Route('DELETE', '/users/@me/invites'), context_properties=ContextProperties._empty()) + # Role management def get_roles(self, guild_id: Snowflake) -> Response[List[role.Role]]: diff --git a/discord/invite.py b/discord/invite.py index 3ddb57c24..8e017ffdd 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -56,7 +56,6 @@ if TYPE_CHECKING: from .user import User from .appinfo import PartialApplication from .message import Message - from .channel import GroupChannel InviteGuildType = Union[Guild, 'PartialInviteGuild', Object] InviteChannelType = Union[GuildChannel, 'PartialInviteChannel', Object, PrivateChannel] @@ -68,7 +67,7 @@ class PartialInviteChannel: """Represents a "partial" invite channel. This model will be given when the user is not part of the - guild the :class:`Invite` resolves to. + guild or group channel the :class:`Invite` resolves to. .. container:: operations @@ -98,19 +97,21 @@ class PartialInviteChannel: The partial channel's type. """ - __slots__ = ('id', 'name', 'type') + __slots__ = ('_state', 'id', 'name', 'type', '_icon') - def __new__(cls, data: Optional[InviteChannelPayload]): + def __new__(cls, data: Optional[InviteChannelPayload], *args, **kwargs): if data is None: return return super().__new__(cls) - def __init__(self, data: Optional[InviteChannelPayload]): + def __init__(self, data: Optional[InviteChannelPayload], state: ConnectionState): if data is None: return + self._state = state self.id: int = int(data['id']) self.name: str = data['name'] self.type: ChannelType = try_enum(ChannelType, data['type']) + self._icon: Optional[str] = data.get('icon') def __str__(self) -> str: return self.name @@ -128,6 +129,18 @@ class PartialInviteChannel: """:class:`datetime.datetime`: Returns the channel's creation time in UTC.""" return snowflake_time(self.id) + @property + def icon(self) -> Optional[Asset]: + """Optional[:class:`Asset`]: Returns the channel's icon asset if available. + + Only applicable to channels of type :attr:`ChannelType.group`. + + .. versionadded:: 2.0 + """ + if self._icon is None: + return None + return Asset._from_icon(self._state, self.id, self._icon, path='channel') + class PartialInviteGuild: """Represents a "partial" invite guild. @@ -369,6 +382,22 @@ class Invite(Hashable): The guild's welcome screen, if available. .. versionadded:: 2.0 + new_member: :class:`bool` + Whether the user was not previously a member of the guild. + + .. versionadded:: 2.0 + + .. note:: + This is only possibly ``True`` in accepted invite objects + (i.e. the objects received from :meth:`accept` and :meth:`use`). + show_verification_form: :class:`bool` + Whether the user should be shown the guild's member verification form. + + .. versionadded:: 2.0 + + .. note:: + This is only possibly ``True`` in accepted invite objects + (i.e. the objects received from :meth:`accept` and :meth:`use`). """ __slots__ = ( @@ -394,6 +423,8 @@ class Invite(Hashable): '_message', 'welcome_screen', 'type', + 'new_member', + 'show_verification_form', ) BASE = 'https://discord.gg' @@ -421,6 +452,12 @@ class Invite(Hashable): self.approximate_member_count: Optional[int] = data.get('approximate_member_count') self._message: Optional[Message] = data.get('message') + # We inject some missing data here since we can assume it + if self.type in (InviteType.group_dm, InviteType.friend): + self.temporary = False + if self.max_uses is None: + self.max_uses = 5 if self.type is InviteType.friend else 0 + expires_at = data.get('expires_at', None) self.expires_at: Optional[datetime.datetime] = parse_time(expires_at) if expires_at else None @@ -454,6 +491,10 @@ class Invite(Hashable): ) self.scheduled_event_id: Optional[int] = self.scheduled_event.id if self.scheduled_event else None + # Only present on accepted invites + self.new_member: bool = data.get('new_member', False) + self.show_verification_form: bool = data.get('show_verification_form', False) + @classmethod def from_incomplete(cls, *, state: ConnectionState, data: InvitePayload, message: Optional[Message] = None) -> Self: guild: Optional[Union[Guild, PartialInviteGuild]] @@ -473,7 +514,7 @@ class Invite(Hashable): if welcome_screen is not None: welcome_screen = WelcomeScreen(data=welcome_screen, guild=guild) - channel = PartialInviteChannel(data.get('channel')) + channel = PartialInviteChannel(data.get('channel'), state) channel = state.get_channel(getattr(channel, 'id', None)) or channel if message is not None: @@ -519,7 +560,7 @@ class Invite(Hashable): if data is None: return None - return PartialInviteChannel(data) + return PartialInviteChannel(data, self._state) def __str__(self) -> str: return self.url @@ -570,7 +611,7 @@ class Invite(Hashable): return self - async def use(self) -> Union[Guild, User, GroupChannel]: + async def use(self) -> Invite: """|coro| Uses the invite. @@ -587,8 +628,8 @@ class Invite(Hashable): Returns ------- - Union[:class:`Guild`, :class:`User`, :class:`GroupChannel`] - The guild/group DM joined, or user added as a friend. + :class:`Invite` + The accepted invite. """ state = self._state type = self.type @@ -601,22 +642,9 @@ class Invite(Hashable): 'channel_type': getattr(self.channel, 'type', MISSING), } data = await state.http.accept_invite(self.code, type, **kwargs) - if type is InviteType.guild: - from .guild import Guild - - guild = Guild(data=data['guild'], state=state) - guild._cs_joined = True - return guild - elif type is InviteType.group_dm: - from .channel import GroupChannel + return Invite.from_incomplete(state=state, data=data, message=message) - return GroupChannel(data=data['channel'], state=state, me=state.user) # type: ignore - else: - from .user import User - - return User(data=data['inviter'], state=state) - - async def accept(self) -> Union[Guild, User, GroupChannel]: + async def accept(self) -> Invite: """|coro| Uses the invite. @@ -633,23 +661,29 @@ class Invite(Hashable): Returns ------- - Union[:class:`Guild`, :class:`User`, :class:`GroupChannel`] - The guild/group DM joined, or user added as a friend. + :class:`Invite` + The accepted invite. """ return await self.use() - async def delete(self, *, reason: Optional[str] = None) -> None: + async def delete(self, *, reason: Optional[str] = None) -> Invite: """|coro| Revokes the instant invite. - You must have the :attr:`~Permissions.manage_channels` permission to do this. + In a guild context, you must have the :attr:`~Permissions.manage_channels` permission to do this. + + .. versionchanged:: 2.0 + + The function now returns the deleted invite. Parameters ----------- reason: Optional[:class:`str`] The reason for deleting this invite. Shows up on the audit log. + Only applicable to guild invites. + Raises ------- Forbidden @@ -658,5 +692,12 @@ class Invite(Hashable): The invite is invalid or expired. HTTPException Revoking the invite failed. + + Returns + -------- + :class:`Invite` + The deleted invite. """ - await self._state.http.delete_invite(self.code, reason=reason) + state = self._state + data = await state.http.delete_invite(self.code, reason=reason) + return Invite.from_incomplete(state=state, data=data) diff --git a/discord/tracking.py b/discord/tracking.py index fb58d06b3..c45ac2934 100644 --- a/discord/tracking.py +++ b/discord/tracking.py @@ -84,7 +84,7 @@ class ContextProperties: # Thank you Discord-S.C.U.M def _encode_data(self, data) -> str: library = { - 'None': 'e30=', + None: 'e30=', # Locations 'Friends': 'eyJsb2NhdGlvbiI6IkZyaWVuZHMifQ==', 'ContextMenu': 'eyJsb2NhdGlvbiI6IkNvbnRleHRNZW51In0=', @@ -99,13 +99,14 @@ class ContextProperties: # Thank you Discord-S.C.U.M 'Verify Email': 'eyJsb2NhdGlvbiI6IlZlcmlmeSBFbWFpbCJ9', 'New Group DM': 'eyJsb2NhdGlvbiI6Ik5ldyBHcm91cCBETSJ9', 'Add Friends to DM': 'eyJsb2NhdGlvbiI6IkFkZCBGcmllbmRzIHRvIERNIn0=', + 'Group DM Invite Create': 'eyJsb2NhdGlvbiI6Ikdyb3VwIERNIEludml0ZSBDcmVhdGUifQ==', # Sources 'Chat Input Blocker - Lurker Mode': 'eyJzb3VyY2UiOiJDaGF0IElucHV0IEJsb2NrZXIgLSBMdXJrZXIgTW9kZSJ9', 'Notice - Lurker Mode': 'eyJzb3VyY2UiOiJOb3RpY2UgLSBMdXJrZXIgTW9kZSJ9', } try: - return library[self.target or 'None'] + return library[self.target] except KeyError: return b64encode(json.dumps(data, separators=(',', ':')).encode()).decode('utf-8') @@ -158,6 +159,11 @@ class ContextProperties: # Thank you Discord-S.C.U.M data = {'location': 'Add Friends to DM'} return cls(data) + @classmethod + def _from_group_dm_invite(cls) -> Self: + data = {'location': 'Group DM Invite Create'} + return cls(data) + @classmethod def _from_app(cls) -> Self: data = {'location': '/app'}