Browse Source

Implement new connection capabilities

pull/10109/head
dolfies 3 years ago
parent
commit
c4b6007979
  1. 99
      discord/client.py
  2. 2
      discord/commands.py
  3. 168
      discord/connections.py
  4. 18
      discord/enums.py
  5. 32
      discord/http.py
  6. 22
      discord/state.py
  7. 4
      discord/types/gateway.py
  8. 22
      discord/types/user.py
  9. 62
      docs/api.rst

99
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|

2
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

168
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'<ConnectionMetadata {" ".join(f"{k}={v!r}" for k, v in self.__dict__.items())}>'
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__

18
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

32
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

22
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

4
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]

22
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

62
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
~~~~~~~~~~~

Loading…
Cancel
Save