diff --git a/discord/client.py b/discord/client.py index e810af732..072a99a4d 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, Status, InviteType, try_enum +from .enums import ActivityType, ChannelType, ConnectionLinkType, ConnectionType, Status, InviteType, try_enum from .mentions import AllowedMentions from .errors import * from .enums import Status @@ -337,6 +337,20 @@ class Client: """ return utils.SequenceProxy(self._connection._messages or []) + @property + def connections(self) -> List[Connection]: + """List[:class:`.Connection`]: The connections that the connected client has. + + These connections don't have the :attr:`.Connection.metadata` attribute populated. + + .. versionadded:: 2.0 + + .. note:: + Due to a Discord limitation, removed connections may not be removed from this cache. + + """ + return list(self._connection.connections.values()) + @property def private_channels(self) -> List[PrivateChannel]: """List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on.""" @@ -2197,7 +2211,7 @@ class Client: await note.fetch() return note - async def connections(self) -> List[Connection]: + async def fetch_connections(self) -> List[Connection]: """|coro| Retrieves all of your connections. @@ -2218,6 +2232,87 @@ class Client: data = await state.http.get_connections() return [Connection(data=d, state=state) for d in data] + async def authorize_connection( + self, type: ConnectionType, two_way_link_type: Optional[ConnectionLinkType] = None, continuation: bool = False + ) -> str: + """|coro| + + Retrieves a URL to authorize a connection with a third-party service. + + .. versionadded:: 2.0 + + Parameters + ----------- + type: :class:`.ConnectionType` + The type of connection to authorize. + two_way_link_type: Optional[:class:`.ConnectionLinkType`] + The type of two-way link to use, if any. + continuation: :class:`bool` + Whether this is a continuation of a previous authorization. + + Raises + ------- + HTTPException + Authorizing the connection failed. + + Returns + -------- + :class:`str` + The URL to redirect the user to. + """ + data = await self.http.authorize_connection( + str(type), str(two_way_link_type) if two_way_link_type else None, continuation=continuation + ) + return data['url'] + + async def create_connection( + self, + type: ConnectionType, + code: str, + state: str, + *, + insecure: bool = True, + friend_sync: bool = MISSING, + ) -> None: + """|coro| + + Creates a new connection. + + This is a low-level method that requires data obtained from other APIs. + + .. versionadded:: 2.0 + + Parameters + ----------- + type: :class:`.ConnectionType` + The type of connection to add. + code: :class:`str` + The authorization code for the connection. + state: :class:`str` + The state used to authorize the connection. + insecure: :class:`bool` + Whether the authorization is insecure. Defaults to ``True``. + friend_sync: :class:`bool` + Whether friends are synced over the connection. + + Defaults to ``True`` for :attr:`.ConnectionType.facebook` and :attr:`.ConnectionType.contacts`, else ``False``. + + Raises + ------- + HTTPException + Creating the connection failed. + """ + friend_sync = ( + friend_sync if friend_sync is not MISSING else type in (ConnectionType.facebook, ConnectionType.contacts) + ) + await self.http.add_connection( + str(type), + code=code, + state=state, + insecure=insecure, + friend_sync=friend_sync, + ) + async def fetch_private_channels(self) -> List[PrivateChannel]: """|coro| diff --git a/discord/commands.py b/discord/commands.py index e7928dcd7..16f2f31b9 100644 --- a/discord/commands.py +++ b/discord/commands.py @@ -173,7 +173,7 @@ class ApplicationCommand(Protocol): def default_member_permissions(self) -> Optional[Permissions]: """Optional[:class:`~discord.Permissions`]: The default permissions required to use this command. - ..note:: + .. note:: This may be overrided on a guild-by-guild basis. """ perms = self._default_member_permissions diff --git a/discord/connections.py b/discord/connections.py index ced71bafb..b8a2283ea 100644 --- a/discord/connections.py +++ b/discord/connections.py @@ -23,7 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations -from typing import TYPE_CHECKING, List, Optional +from typing import TYPE_CHECKING, Any, Iterator, List, Optional, Tuple from .enums import ConnectionType, try_enum from .integrations import Integration @@ -38,6 +38,7 @@ if TYPE_CHECKING: __all__ = ( 'PartialConnection', 'Connection', + 'ConnectionMetadata', ) @@ -46,6 +47,8 @@ class PartialConnection: This is the info you get for other users' connections. + .. versionadded:: 2.0 + .. container:: operations .. describe:: x == y @@ -64,8 +67,6 @@ class PartialConnection: Returns the connection's name. - .. versionadded:: 2.0 - Attributes ---------- id: :class:`str` @@ -73,16 +74,14 @@ class PartialConnection: name: :class:`str` The connection's account name. type: :class:`ConnectionType` - The connection service (e.g. youtube, twitch, etc.). + The connection service type (e.g. youtube, twitch, etc.). verified: :class:`bool` Whether the connection is verified. - revoked: :class:`bool` - Whether the connection is revoked. visible: :class:`bool` Whether the connection is visible on the user's profile. """ - __slots__ = ('id', 'name', 'type', 'verified', 'revoked', 'visible') + __slots__ = ('id', 'name', 'type', 'verified', 'visible') def __init__(self, data: PartialConnectionPayload): self._update(data) @@ -94,7 +93,7 @@ class PartialConnection: return f'<{self.__class__.__name__} id={self.id!r} name={self.name!r} type={self.type!r} visible={self.visible}>' def __hash__(self) -> int: - return hash((self.name, self.id)) + return hash((self.type.value, self.id)) def __eq__(self, other: object) -> bool: if isinstance(other, PartialConnection): @@ -110,15 +109,15 @@ class PartialConnection: self.id: str = data['id'] self.name: str = data['name'] self.type: ConnectionType = try_enum(ConnectionType, data['type']) - self.verified: bool = data['verified'] - self.revoked: bool = data.get('revoked', False) - self.visible: bool = True + self.visible: bool = True # If we have a partial connection, it's visible class Connection(PartialConnection): """Represents a Discord profile connection. + .. versionadded:: 2.0 + .. container:: operations .. describe:: x == y @@ -137,32 +136,54 @@ class Connection(PartialConnection): Returns the connection's name. - .. versionadded:: 2.0 - Attributes ---------- + revoked: :class:`bool` + Whether the connection is revoked. friend_sync: :class:`bool` Whether friends are synced over the connection. show_activity: :class:`bool` Whether activities from this connection will be shown in presences. - access_token: :class:`str` + two_way_link: :class:`bool` + Whether the connection is authorized both ways (i.e. it's both a connection and an authorization). + metadata_visible: :class:`bool` + Whether the connection's metadata is visible. + metadata: Optional[:class:`ConnectionMetadata`] + Various metadata about the connection. + + The contents of this are always subject to change. + access_token: Optional[:class:`str`] The OAuth2 access token for the account, if applicable. integrations: List[:class:`Integration`] The integrations attached to the connection. """ - __slots__ = ('_state', 'visible', 'friend_sync', 'show_activity', 'access_token', 'integrations') + __slots__ = ( + '_state', + 'revoked', + 'friend_sync', + 'show_activity', + 'two_way_link', + 'metadata_visible', + 'metadata', + 'access_token', + 'integrations', + ) def __init__(self, *, data: ConnectionPayload, state: ConnectionState): - super().__init__(data) + self._update(data) self._state = state self.access_token: Optional[str] = None def _update(self, data: ConnectionPayload): super()._update(data) - self.visible: bool = bool(data.get('visibility', True)) + self.revoked: bool = data.get('revoked', False) + self.visible: bool = bool(data.get('visibility', False)) self.friend_sync: bool = data.get('friend_sync', False) self.show_activity: bool = data.get('show_activity', True) + self.two_way_link: bool = data.get('two_way_link', False) + self.metadata_visible: bool = bool(data.get('metadata_visibility', False)) + self.metadata: Optional[ConnectionMetadata] = ConnectionMetadata(data['metadata']) if 'metadata' in data else None # Only sometimes in the payload try: @@ -171,14 +192,16 @@ class Connection(PartialConnection): pass self.integrations: List[Integration] = [ - Integration(data=i, guild=self._resolve_guild(i)) for i in data.get('integrations', []) + Integration(data=i, guild=self._resolve_guild(i)) for i in data.get('integrations') or [] ] def _resolve_guild(self, data: IntegrationPayload) -> Guild: from .guild import Guild state = self._state - guild_data = data['guild'] + guild_data = data.get('guild') + if not guild_data: + return None # type: ignore guild_id = int(guild_data['id']) guild = state._get_guild(guild_id) @@ -187,8 +210,14 @@ class Connection(PartialConnection): return guild async def edit( - self, *, name: str = MISSING, visible: bool = MISSING, show_activity: bool = MISSING, friend_sync: bool = MISSING - ) -> None: + self, + *, + name: str = MISSING, + visible: bool = MISSING, + friend_sync: bool = MISSING, + show_activity: bool = MISSING, + metadata_visible: bool = MISSING, + ) -> Connection: """|coro| Edit the connection. @@ -201,27 +230,48 @@ class Connection(PartialConnection): The new name of the connection. Only editable for certain connection types. visible: :class:`bool` Whether the connection is visible on your profile. - friend_sync: :class:`bool` - Whether friends are synced over the connection. show_activity: :class:`bool` Whether activities from this connection will be shown in presences. + friend_sync: :class:`bool` + Whether friends are synced over the connection. + metadata_visible: :class:`bool` + Whether the connection's metadata is visible. Raises ------ HTTPException Editing the connection failed. + + Returns + ------- + :class:`Connection` + The edited connection. """ payload = {} if name is not MISSING: payload['name'] = name if visible is not MISSING: payload['visibility'] = visible - if friend_sync is not MISSING: - payload['friend_sync'] = friend_sync if show_activity is not MISSING: payload['show_activity'] = show_activity + if friend_sync is not MISSING: + payload['friend_sync'] = friend_sync + if metadata_visible is not MISSING: + payload['metadata_visibility'] = metadata_visible data = await self._state.http.edit_connection(self.type.value, self.id, **payload) - self._update(data) + return Connection(data=data, state=self._state) + + async def refresh(self) -> None: + """|coro| + + Refreshes the connection. This updates the connection's :attr:`metadata`. + + Raises + ------ + HTTPException + Refreshing the connection failed. + """ + await self._state.http.refresh_connection(self.type.value, self.id) async def delete(self) -> None: """|coro| @@ -251,5 +301,67 @@ class Connection(PartialConnection): The new access token. """ data = await self._state.http.get_connection_token(self.type.value, self.id) - self.access_token = token = data['access_token'] - return token + return data['access_token'] + + +class ConnectionMetadata: + """Represents a connection's metadata. + + Because of how unstable and wildly varying this metadata can be, this is a simple class that just + provides access ro the raw data using dot notation. This means if an attribute is not present, + ``None`` will be returned instead of raising an AttributeError. + + .. versionadded:: 2.0 + + .. container:: operations + + .. describe:: x == y + + Checks if two metadata objects are equal. + + .. describe:: x != y + + Checks if two metadata objects are not equal. + + .. describe:: x[key] + + Returns a metadata value if it is found, otherwise raises a :exc:`KeyError`. + + .. describe:: key in x + + Checks if a metadata value is present. + + .. describe:: iter(x) + Returns an iterator of ``(field, value)`` pairs. This allows this class + to be used as an iterable in list/dict/etc constructions. + """ + + __slots__ = () + + def __init__(self, data: Optional[dict]) -> None: + self.__dict__.update(data or {}) + + def __repr__(self) -> str: + return f'' + + def __eq__(self, other: object) -> bool: + if not isinstance(other, ConnectionMetadata): + return False + return self.__dict__ == other.__dict__ + + def __ne__(self, other: object) -> bool: + if not isinstance(other, ConnectionMetadata): + return True + return self.__dict__ != other.__dict__ + + def __iter__(self) -> Iterator[Tuple[str, Any]]: + yield from self.__dict__.items() + + def __getitem__(self, key: str) -> Any: + return self.__dict__[key] + + def __getattr__(self, attr: str) -> Any: + return None + + def __contains__(self, key: str) -> bool: + return key in self.__dict__ diff --git a/discord/enums.py b/discord/enums.py index 19bb79ee3..3edee1fe1 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -80,6 +80,7 @@ __all__ = ( 'ScheduledEventEntityType', 'ApplicationType', 'ConnectionType', + 'ConnectionLinkType', ) if TYPE_CHECKING: @@ -877,13 +878,16 @@ class AppCommandType(Enum): class ConnectionType(Enum): battle_net = 'battlenet' contacts = 'contacts' + ebay = 'ebay' epic_games = 'epicgames' facebook = 'facebook' github = 'github' league_of_legends = 'leagueoflegends' + paypal = 'paypal' playstation = 'playstation' reddit = 'reddit' - samsung = 'samsunggalaxy' + riot_games = 'riotgames' + samsung = 'samsung' spotify = 'spotify' skype = 'skype' steam = 'steam' @@ -892,6 +896,18 @@ class ConnectionType(Enum): youtube = 'youtube' xbox = 'xbox' + def __str__(self) -> str: + return self.value + + +class ConnectionLinkType(Enum): + web = 'web' + mobile = 'mobile' + desktop = 'desktop' + + def __str__(self) -> str: + return self.value + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below diff --git a/discord/http.py b/discord/http.py index cffc0c60e..616fb213e 100644 --- a/discord/http.py +++ b/discord/http.py @@ -2184,16 +2184,40 @@ class HTTPClient: # Connections - def get_connections(self): + def get_connections(self) -> Response[List[user.Connection]]: return self.request(Route('GET', '/users/@me/connections')) - def edit_connection(self, type: str, id: str, **payload): + def edit_connection(self, type: str, id: str, **payload) -> Response[user.Connection]: return self.request(Route('PATCH', '/users/@me/connections/{type}/{id}', type=type, id=id), json=payload) - def delete_connection(self, type: str, id: str): + def refresh_connection(self, type: str, id: str, **payload) -> Response[None]: + return self.request(Route('POST', '/users/@me/connections/{type}/{id}/refresh', type=type, id=id), json=payload) + + def delete_connection(self, type: str, id: str) -> Response[None]: return self.request(Route('DELETE', '/users/@me/connections/{type}/{id}', type=type, id=id)) - def get_connection_token(self, type: str, id: str): + def authorize_connection( + self, + type: str, + two_way_link_type: Optional[str] = None, + continuation: bool = False, + ) -> Response[user.ConnectionAuthorization]: + params = {} + if two_way_link_type is not None: + params['two_way_link'] = 'true' + params['two_way_link_type'] = two_way_link_type + if continuation: + params['continuation'] = 'true' + return self.request(Route('GET', '/connections/{type}/authorize', type=type), params=params) + + def add_connection( + self, + type: str, + **payload, + ) -> Response[None]: + return self.request(Route('POST', '/connections/{type}/callback', type=type), json=payload) + + def get_connection_token(self, type: str, id: str) -> Response[user.ConnectionAccessToken]: return self.request(Route('GET', '/users/@me/connections/{type}/{id}/access-token', type=type, id=id)) # Applications diff --git a/discord/state.py b/discord/state.py index 35715e9c6..e3a3dea45 100644 --- a/discord/state.py +++ b/discord/state.py @@ -79,6 +79,7 @@ from .member import _ClientStatus from .modal import Modal from .member import VoiceState from .appinfo import InteractionApplication +from .connections import Connection if TYPE_CHECKING: from .abc import PrivateChannel, Snowflake as abcSnowflake @@ -453,6 +454,7 @@ class ConnectionState: self._users: weakref.WeakValueDictionary[int, User] = weakref.WeakValueDictionary() self.settings: Optional[UserSettings] = None self.consents: Optional[Tracking] = None + self.connections: Dict[str, Connection] = {} self.analytics_token: Optional[str] = None self.preferred_regions: List[str] = [] self.country_code: Optional[str] = None @@ -512,7 +514,8 @@ class ConnectionState: @property def session_id(self) -> Optional[str]: - return self.ws.session_id + if self.ws: + return self.ws.session_id @property def ws(self): @@ -886,6 +889,7 @@ class ConnectionState: self.consents = Tracking(data=data.get('consents', {}), state=self) self.country_code = data.get('country_code', 'US') self.session_type = data.get('session_type', 'normal') + self.connections = {c['id']: Connection(state=self, data=c) for c in data.get('connected_accounts', [])} if 'required_action' in data: # Locked more than likely self.parse_user_required_action_update(data) @@ -1088,8 +1092,20 @@ class ConnectionState: required_action = try_enum(RequiredActionType, data['required_action']) self.dispatch('required_action_update', required_action) - def parse_user_connections_update(self, data: gw.ResumedEvent) -> None: - self.dispatch('connections_update') + def parse_user_connections_update(self, data: gw.Connection) -> None: + id = data['id'] + if id not in self.connections: + self.connections[id] = connection = Connection(state=self, data=data) + self.dispatch('connection_create', connection) + else: + # TODO: We can also get to this point if the connection has been deleted + # We can detect that by checking if the payload is identical to the previous payload + # However, certain events can also trigger updates with identical payloads, so we can't rely on that + # For now, we assume everything is an update; thanks Discord + connection = self.connections[id] + old_connection = copy.copy(connection) + connection._update(data) + self.dispatch('connection_update', old_connection, connection) def parse_sessions_replace(self, data: List[Dict[str, Any]]) -> None: overall = MISSING diff --git a/discord/types/gateway.py b/discord/types/gateway.py index 9b4e3d178..262b5510c 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -39,7 +39,7 @@ from .message import Message from .sticker import GuildSticker from .appinfo import PartialAppInfo from .guild import Guild, UnavailableGuild, SupplementalGuild -from .user import User +from .user import Connection, User from .threads import Thread, ThreadMember from .scheduled_event import GuildScheduledEvent from .channel import DMChannel, GroupDMChannel @@ -60,7 +60,7 @@ class ShardInfo(TypedDict): class ReadyEvent(TypedDict): analytics_token: str auth_token: NotRequired[str] - connected_accounts: List[dict] + connected_accounts: List[Connection] country_code: str friend_suggestion_count: int geo_ordered_rtc_regions: List[str] diff --git a/discord/types/user.py b/discord/types/user.py index c2d477cf7..cf16125c1 100644 --- a/discord/types/user.py +++ b/discord/types/user.py @@ -22,7 +22,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from typing import List, Literal, Optional, TypedDict +from typing import Any, Dict, List, Literal, Optional, TypedDict from typing_extensions import NotRequired from .integration import ConnectionIntegration @@ -39,13 +39,16 @@ class PartialUser(TypedDict): ConnectionType = Literal[ 'battlenet', 'contacts', + 'ebay', 'epicgames', 'facebook', 'github', 'leagueoflegends', + 'paypal', 'playstation', 'reddit', - 'samsunggalaxy', + 'riotgames', + 'samsung', 'spotify', 'skype', 'steam', @@ -81,12 +84,23 @@ class PartialConnection(TypedDict): type: ConnectionType name: str verified: bool - revoked: NotRequired[bool] class Connection(PartialConnection): - visibility: int + revoked: bool + visibility: Literal[0, 1] + metadata_visibility: Literal[0, 1] show_activity: bool friend_sync: bool + two_way_link: bool integrations: NotRequired[List[ConnectionIntegration]] access_token: NotRequired[str] + metadata: NotRequired[Dict[str, Any]] + + +class ConnectionAccessToken(TypedDict): + access_token: str + + +class ConnectionAuthorization(TypedDict): + url: str diff --git a/docs/api.rst b/docs/api.rst index fa8a0295a..ebabe49b5 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -387,7 +387,7 @@ Client .. function:: on_guild_settings_update(before, after) - Called when a :class:`.Guild`'s :class:`GuildSettings` updates, for example: + Called when a :class:`.Guild` :class:`GuildSettings` updates, for example: - Muted guild or channel - Changed notification settings @@ -411,13 +411,32 @@ Client :param action: The action required. :type action: :class:`RequiredActionType` -.. function:: on_connections_update() +Connections +~~~~~~~~~~~~ + +.. function:: on_connection_create(connection) - Called when your account connections are updated. - The updated connections are not provided and must be fetched by :meth:`Client.connections`. + Called when a connection is added to your account. .. versionadded:: 2.0 + :param connection: The connection that was added. + :type connection: :class:`Connection` + +.. function:: on_connection_update(before, after) + + Called when a connection is updated on your account. + + .. note:: + Due to a Discord limitation, this is also called when a connection is removed. + + .. versionadded:: 2.0 + + :param before: The connection prior to being updated. + :type before: :class:`Connection` + :param after: The connection after being updated. + :type after: :class:`Connection` + Relationships ~~~~~~~~~~~~~ @@ -3167,6 +3186,10 @@ of :class:`enum.Enum`. The user has a contact sync connection. + .. attribute:: ebay + + The user has an eBay connection. + .. attribute:: epic_games The user has an Epic Games connection. @@ -3183,6 +3206,10 @@ of :class:`enum.Enum`. The user has a League of Legends connection. + .. attribute:: paypal + + The user has a PayPal connection. + .. attribute:: playstation The user has a PlayStation connection. @@ -3191,6 +3218,10 @@ of :class:`enum.Enum`. The user has a Reddit connection. + .. attribute:: riot_games + + The user has a Riot Games connection. + .. attribute:: samsung The user has a Samsung Account connection. @@ -3223,6 +3254,24 @@ of :class:`enum.Enum`. The user has an Xbox Live connection. +.. class:: ConnectionLinkType + + Represents the type of two-way link a Discord connection has. + + .. versionadded:: 2.0 + + .. attribute:: web + + The connection is linked via web. + + .. attribute:: mobile + + The connection is linked via mobile. + + .. attribute:: desktop + + The connection is linked via desktop. + .. class:: InteractionType Specifies the type of :class:`Interaction`. @@ -4174,6 +4223,11 @@ Connection .. autoclass:: PartialConnection() :members: +.. attributetable:: ConnectionMetadata + +.. autoclass:: ConnectionMetadata() + :members: + Application ~~~~~~~~~~~