Browse Source

Implement relationship presence and gateway session management

pull/10109/head
dolfies 2 years ago
parent
commit
edf083bd1e
  1. 150
      discord/activity.py
  2. 255
      discord/client.py
  3. 28
      discord/enums.py
  4. 40
      discord/gateway.py
  5. 10
      discord/guild.py
  6. 35
      discord/http.py
  7. 127
      discord/member.py
  8. 232
      discord/relationship.py
  9. 457
      discord/state.py
  10. 4
      discord/types/activity.py
  11. 52
      discord/types/gateway.py
  12. 11
      discord/types/user.py
  13. 61
      discord/user.py
  14. 20
      discord/utils.py

150
discord/activity.py

@ -25,11 +25,11 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import datetime
from typing import Any, Dict, List, Optional, TYPE_CHECKING, Union, overload
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple, Union, overload
from .asset import Asset
from .enums import ActivityType, try_enum
from .colour import Colour
from .enums import ActivityType, ClientType, OperatingSystem, Status, try_enum
from .partial_emoji import PartialEmoji
from .utils import _get_as_snowflake
@ -91,15 +91,17 @@ t.ActivityFlags = {
"""
if TYPE_CHECKING:
from typing_extensions import Self
from .state import ConnectionState
from .types.activity import (
Activity as ActivityPayload,
ActivityTimestamps,
ActivityParty,
ActivityAssets,
ActivityButton,
ActivityParty,
ActivityTimestamps,
)
from .state import ConnectionState
from .types.gateway import Session as SessionPayload
class BaseActivity:
@ -270,7 +272,7 @@ class Activity(BaseActivity):
def __ne__(self, other):
return not self.__eq__(other)
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> ActivityPayload:
ret: Dict[str, Any] = {}
for attr in self.__slots__:
value = getattr(self, attr, None)
@ -284,7 +286,7 @@ class Activity(BaseActivity):
ret['type'] = int(self.type)
if self.emoji:
ret['emoji'] = self.emoji.to_dict()
return ret
return ret # type: ignore
@property
def start(self) -> Optional[datetime.datetime]:
@ -420,7 +422,7 @@ class Game(BaseActivity):
def __repr__(self) -> str:
return f'<Game name={self.name!r}>'
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> ActivityPayload:
timestamps: Dict[str, Any] = {}
if self._start:
timestamps['start'] = self._start
@ -432,7 +434,7 @@ class Game(BaseActivity):
return {
'type': ActivityType.playing.value,
'name': str(self.name),
'timestamps': timestamps
'timestamps': timestamps # type: ignore
}
# fmt: on
@ -531,7 +533,7 @@ class Streaming(BaseActivity):
else:
return name[7:] if name[:7] == 'twitch:' else None
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> ActivityPayload:
# fmt: off
ret: Dict[str, Any] = {
'type': ActivityType.streaming.value,
@ -542,7 +544,7 @@ class Streaming(BaseActivity):
# fmt: on
if self.details:
ret['details'] = self.details
return ret
return ret # type: ignore
def __eq__(self, other: object) -> bool:
return isinstance(other, Streaming) and other.name == self.name and other.url == self.url
@ -620,7 +622,7 @@ class Spotify:
There is an alias for this named :attr:`colour`"""
return self.colour
def to_dict(self) -> Dict[str, Any]:
def to_dict(self) -> ActivityPayload:
return {
'flags': 48, # SYNC | PLAY
'name': 'Spotify',
@ -631,7 +633,7 @@ class Spotify:
'timestamps': self._timestamps,
'details': self._details,
'state': self._state,
}
} # type: ignore
@property
def name(self) -> str:
@ -801,7 +803,7 @@ class CustomActivity(BaseActivity):
"""
return ActivityType.custom
def to_dict(self) -> Dict[str, Union[str, int]]:
def to_dict(self) -> ActivityPayload:
o = {
'type': ActivityType.custom.value,
'state': self.name,
@ -809,7 +811,7 @@ class CustomActivity(BaseActivity):
}
if self.emoji:
o['emoji'] = self.emoji.to_dict()
return o
return o # type: ignore
def to_settings_dict(self) -> Dict[str, Any]:
o: Dict[str, Optional[Union[str, int]]] = {}
@ -845,6 +847,117 @@ class CustomActivity(BaseActivity):
return f'<CustomActivity name={self.name!r} emoji={self.emoji!r}>'
class Session:
"""Represents a connected Discord gateway session.
.. container:: operations
.. describe:: x == y
Checks if two sessions are equal.
.. describe:: x != y
Checks if two sessions are not equal.
.. describe:: hash(x)
Returns the session's hash.
.. versionadded:: 2.0
Attributes
-----------
session_id: :class:`str`
The session ID.
active: :class:`bool`
Whether the session is active.
os: :class:`OperatingSystem`
The operating system the session is running on.
client: :class:`ClientType`
The client the session is running on.
version: :class:`int`
The version of the client the session is running on (used for differentiating between e.g. PS4/PS5).
status: :class:`Status`
The status of the session.
activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]
The activities the session is currently doing.
"""
__slots__ = (
'session_id',
'active',
'os',
'client',
'version',
'status',
'activities',
'_state',
)
def __init__(self, *, data: SessionPayload, state: ConnectionState):
self._state = state
client_info = data['client_info']
self.session_id: str = data['session_id']
self.os: OperatingSystem = OperatingSystem.from_string(client_info['os'])
self.client: ClientType = try_enum(ClientType, client_info['client'])
self.version: int = client_info.get('version', 0)
self._update(data)
def _update(self, data: SessionPayload):
state = self._state
# Only these should ever change
self.active: bool = data.get('active', False)
self.status: Status = try_enum(Status, data['status'])
self.activities: Tuple[ActivityTypes, ...] = tuple(
create_activity(activity, state) for activity in data['activities']
)
def __repr__(self) -> str:
return f'<Session session_id={self.session_id!r} active={self.active!r} status={self.status!r} activities={self.activities!r}>'
def __eq__(self, other: object) -> bool:
return isinstance(other, Session) and self.session_id == other.session_id
def __ne__(self, other: object) -> bool:
if isinstance(other, Session):
return self.session_id != other.session_id
return True
def __hash__(self) -> int:
return hash(self.session_id)
@classmethod
def _fake_all(cls, *, state: ConnectionState, data: SessionPayload) -> Self:
self = cls.__new__(cls)
self._state = state
self.session_id = 'all'
self.os = OperatingSystem.unknown
self.client = ClientType.unknown
self.version = 0
self._update(data)
return self
def is_overall(self) -> bool:
""":class:`bool`: Whether the session represents the overall presence across all platforms.
.. note::
If this is ``True``, then :attr:`session_id`, :attr:`os`, and :attr:`client` will not be real values.
"""
return self.session_id == 'all'
def is_headless(self) -> bool:
""":class:`bool`: Whether the session is headless."""
return self.session_id.startswith('h:')
def is_current(self) -> bool:
""":class:`bool`: Whether the session is the current session."""
return self.session_id == self._state.session_id
ActivityTypes = Union[Activity, Game, CustomActivity, Streaming, Spotify]
@ -858,6 +971,11 @@ def create_activity(data: None, state: ConnectionState) -> None:
...
@overload
def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> Optional[ActivityTypes]:
...
def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> Optional[ActivityTypes]:
if not data:
return None

255
discord/client.py

@ -56,13 +56,13 @@ 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, EntitlementType, Status, try_enum
from .enums import ActivityType, ChannelType, ClientType, ConnectionType, EntitlementType, Status
from .mentions import AllowedMentions
from .errors import *
from .enums import Status
from .enums import RelationshipType, Status
from .gateway import *
from .gateway import ConnectionClosed
from .activity import ActivityTypes, BaseActivity, create_activity
from .activity import ActivityTypes, BaseActivity, Session, Spotify, create_activity
from .voice_client import VoiceClient
from .http import HTTPClient
from .state import ConnectionState
@ -78,7 +78,6 @@ from .sticker import GuildSticker, StandardSticker, StickerPack, _sticker_factor
from .profile import UserProfile
from .connections import Connection
from .team import Team
from .member import _ClientStatus
from .handlers import CaptchaHandler
from .billing import PaymentSource, PremiumUsage
from .subscriptions import Subscription, SubscriptionItem, SubscriptionInvoice
@ -88,12 +87,13 @@ from .entitlements import Entitlement, Gift
from .store import SKU, StoreListing, SubscriptionPlan
from .guild_premium import *
from .library import LibraryApplication
from .relationship import Relationship
if TYPE_CHECKING:
from typing_extensions import Self
from types import TracebackType
from .guild import GuildChannel
from .abc import PrivateChannel, GuildChannel, Snowflake, SnowflakeTime
from .abc import PrivateChannel, Snowflake, SnowflakeTime
from .channel import DMChannel
from .message import Message
from .member import Member
@ -251,13 +251,6 @@ class Client:
self._closed: bool = False
self._ready: asyncio.Event = MISSING
self._client_status: _ClientStatus = _ClientStatus()
self._client_activities: Dict[Optional[str], Tuple[ActivityTypes, ...]] = {
None: tuple(),
'this': tuple(),
}
self._session_count = 1
if VoiceClient.warn_nacl:
VoiceClient.warn_nacl = False
_log.warning('PyNaCl is not installed, voice will NOT be supported.')
@ -295,9 +288,10 @@ class Client:
state = self._connection
activities = self.initial_activities
status = self.initial_status
if status or activities:
if status is None:
status = getattr(state.settings, 'status', None) or Status.online
self.loop.create_task(self.change_presence(activities=activities, status=status)) # type: ignore
self.loop.create_task(self.change_presence(activities=activities, status=status))
@property
def latency(self) -> float:
@ -343,6 +337,16 @@ class Client:
"""
return self._connection.stickers
@property
def sessions(self) -> List[Session]:
"""List[:class:`.Session`]: The gateway sessions that the current user is connected in with.
When connected, this includes a representation of the library's session and an "all" session representing the user's overall presence.
.. versionadded:: 2.0
"""
return list(self._connection._sessions.values())
@property
def cached_messages(self) -> Sequence[Message]:
"""Sequence[:class:`.Message`]: Read-only list of messages the connected client has cached.
@ -370,6 +374,47 @@ class Client:
"""List[:class:`.abc.PrivateChannel`]: The private channels that the connected client is participating on."""
return self._connection.private_channels
@property
def relationships(self) -> List[Relationship]:
"""List[:class:`.Relationship`]: Returns all the relationships that the connected client has.
.. versionadded:: 2.0
"""
return list(self._connection._relationships.values())
@property
def friends(self) -> List[Relationship]:
r"""List[:class:`.Relationship`]: Returns all the users that the connected client is friends with.
.. versionadded:: 2.0
"""
return [r for r in self._connection._relationships.values() if r.type is RelationshipType.friend]
@property
def blocked(self) -> List[Relationship]:
r"""List[:class:`.Relationship`]: Returns all the users that the connected client has blocked.
.. versionadded:: 2.0
"""
return [r for r in self._connection._relationships.values() if r.type is RelationshipType.blocked]
def get_relationship(self, user_id: int, /) -> Optional[Relationship]:
"""Retrieves the :class:`.Relationship`, if applicable.
.. versionadded:: 2.0
Parameters
-----------
user_id: :class:`int`
The user ID to check if we have a relationship with them.
Returns
--------
Optional[:class:`.Relationship`]
The relationship, if available.
"""
return self._connection._relationships.get(user_id)
@property
def voice_clients(self) -> List[VoiceProtocol]:
"""List[:class:`.VoiceProtocol`]: Represents a list of voice connections.
@ -506,7 +551,7 @@ class Client:
if (activity := new_settings.custom_activity) is not None:
activities.append(activity)
await self.change_presence(status=status, activities=activities, edit_settings=False) # type: ignore
await self.change_presence(status=status, activities=activities, edit_settings=False)
# Hooks
@ -547,9 +592,9 @@ class Client:
async def setup_hook(self) -> None:
"""|coro|
A coroutine to be called to setup the bot, by default this is blank.
A coroutine to be called to setup the client, by default this is blank.
To perform asynchronous setup after the bot is logged in but before
To perform asynchronous setup after the user is logged in but before
it has connected to the Websocket, overwrite this coroutine.
This is only called once, in :meth:`login`, and will be called before
@ -716,7 +761,7 @@ class Client:
def clear(self) -> None:
"""Clears the internal state of the bot.
After this, the bot can be considered "re-opened", i.e. :meth:`is_closed`
After this, the client can be considered "re-opened", i.e. :meth:`is_closed`
and :meth:`is_ready` both return ``False`` along with the bot's internal
cache cleared.
"""
@ -820,20 +865,19 @@ class Client:
def initial_activities(self, values: Sequence[ActivityTypes]) -> None:
if not values:
self._connection._activities = []
elif all(isinstance(value, BaseActivity) for value in values):
elif all(isinstance(value, (BaseActivity, Spotify)) for value in values):
self._connection._activities = [value.to_dict() for value in values]
else:
raise TypeError('activity must derive from BaseActivity')
@property
def initial_status(self):
def initial_status(self) -> Optional[Status]:
"""Optional[:class:`.Status`]: The status set upon logging in.
.. versionadded:: 2.0
"""
if self._connection._status in {state.value for state in Status}:
return Status(self._connection._status)
return
@initial_status.setter
def initial_status(self, value: Status):
@ -850,10 +894,10 @@ class Client:
.. versionadded:: 2.0
"""
status = try_enum(Status, self._client_status._status)
if status is Status.offline and not self.is_closed():
status = getattr(self._connection.all_session, 'status', None)
if status is None and not self.is_closed():
status = getattr(self._connection.settings, 'status', status)
return status
return status or Status.offline
@property
def raw_status(self) -> str:
@ -863,52 +907,23 @@ class Client:
"""
return str(self.status)
@status.setter
def status(self, value: Status) -> None:
# Internal use only
self._client_status._status = str(value)
@property
def mobile_status(self) -> Status:
""":class:`.Status`: The user's status on a mobile device, if applicable.
.. versionadded:: 2.0
"""
return try_enum(Status, self._client_status.mobile or 'offline')
@property
def desktop_status(self) -> Status:
""":class:`.Status`: The user's status on the desktop client, if applicable.
.. versionadded:: 2.0
"""
return try_enum(Status, self._client_status.desktop or 'offline')
@property
def web_status(self) -> Status:
""":class:`.Status`: The user's status on the web client, if applicable.
.. versionadded:: 2.0
"""
return try_enum(Status, self._client_status.web or 'offline')
@property
def client_status(self) -> Status:
""":class:`.Status`: The library's status.
.. versionadded:: 2.0
"""
status = try_enum(Status, self._client_status._this)
if status is Status.offline and not self.is_closed():
status = getattr(self._connection.current_session, 'status', None)
if status is None and not self.is_closed():
status = getattr(self._connection.settings, 'status', status)
return status
return status or Status.offline
def is_on_mobile(self) -> bool:
""":class:`bool`: A helper function that determines if a member is active on a mobile device.
""":class:`bool`: A helper function that determines if the user is active on a mobile device.
.. versionadded:: 2.0
"""
return self._client_status.mobile is not None
return any(session.client == ClientType.mobile for session in self._connection._sessions.values())
@property
def activities(self) -> Tuple[ActivityTypes]:
@ -924,11 +939,11 @@ class Client:
than 128 characters. See :issue:`1738` for more information.
"""
state = self._connection
activities = tuple(create_activity(d, state) for d in self._client_activities[None]) # type: ignore
activities = state.all_session.activities if state.all_session else None
if activities is None and not self.is_closed():
activities = getattr(state.settings, 'custom_activity', [])
activities = [activities] if activities else activities
return activities
activity = getattr(state.settings, 'custom_activity', None)
activities = (activity,) if activity else activities
return activities or ()
@property
def activity(self) -> Optional[ActivityTypes]:
@ -950,36 +965,6 @@ class Client:
if activities := self.activities:
return activities[0]
@property
def mobile_activities(self) -> Tuple[ActivityTypes]:
"""Tuple[Union[:class:`.BaseActivity`, :class:`.Spotify`]]: Returns the activities
the client is currently doing on a mobile device, if applicable.
.. versionadded:: 2.0
"""
state = self._connection
return tuple(create_activity(d, state) for d in self._client_activities.get('mobile', []))
@property
def desktop_activities(self) -> Tuple[ActivityTypes]:
"""Tuple[Union[:class:`.BaseActivity`, :class:`.Spotify`]]: Returns the activities
the client is currently doing on the desktop client, if applicable.
.. versionadded:: 2.0
"""
state = self._connection
return tuple(create_activity(d, state) for d in self._client_activities.get('desktop', []))
@property
def web_activities(self) -> Tuple[ActivityTypes]:
"""Tuple[Union[:class:`.BaseActivity`, :class:`.Spotify`]]: Returns the activities
the client is currently doing on the web client, if applicable.
.. versionadded:: 2.0
"""
state = self._connection
return tuple(create_activity(d, state) for d in self._client_activities.get('web', []))
@property
def client_activities(self) -> Tuple[ActivityTypes]:
"""Tuple[Union[:class:`.BaseActivity`, :class:`.Spotify`]]: Returns the activities
@ -988,11 +973,11 @@ class Client:
.. versionadded:: 2.0
"""
state = self._connection
activities = tuple(create_activity(d, state) for d in self._client_activities.get('this', []))
activities = state.current_session.activities if state.current_session else None
if activities is None and not self.is_closed():
activities = getattr(state.settings, 'custom_activity', [])
activities = [activities] if activities else activities
return activities
activity = getattr(state.settings, 'custom_activity', None)
activities = (activity,) if activity else activities
return activities or ()
@property
def allowed_mentions(self) -> Optional[AllowedMentions]:
@ -1013,7 +998,7 @@ class Client:
@property
def users(self) -> List[User]:
"""List[:class:`~discord.User`]: Returns a list of all the users the bot can see."""
"""List[:class:`~discord.User`]: Returns a list of all the users the current user can see."""
return list(self._connection._users.values())
def get_channel(self, id: int, /) -> Optional[Union[GuildChannel, Thread, PrivateChannel]]:
@ -1357,8 +1342,8 @@ class Client:
async def change_presence(
self,
*,
activity: Optional[BaseActivity] = None,
activities: Optional[List[BaseActivity]] = None,
activity: Optional[ActivityTypes] = None,
activities: Optional[List[ActivityTypes]] = None,
status: Optional[Status] = None,
afk: bool = False,
edit_settings: bool = True,
@ -1368,7 +1353,7 @@ class Client:
Changes the client's presence.
.. versionchanged:: 2.0
Edits are no longer in place most of the time.
Edits are no longer in place.
Added option to update settings.
.. versionchanged:: 2.0
@ -1401,8 +1386,8 @@ class Client:
Whether to update the settings with the new status and/or
custom activity. This will broadcast the change and cause
all connected (official) clients to change presence as well.
Required for setting/editing expires_at for custom activities.
It's not recommended to change this.
Required for setting/editing ``expires_at`` for custom activities.
It's not recommended to change this, as setting it to ``False`` causes undefined behavior.
Raises
------
@ -1438,14 +1423,6 @@ class Client:
if payload:
await self.user.edit_settings(**payload) # type: ignore # user is always present when logged in
status_str = str(status)
activities_tuple = tuple(a.to_dict() for a in activities)
self._client_status._this = str(status)
self._client_activities['this'] = activities_tuple # type: ignore
if self._session_count <= 1:
self._client_status._status = status_str
self._client_activities[None] = self._client_activities['this'] = activities_tuple # type: ignore
async def change_voice_state(
self,
*,
@ -1887,7 +1864,7 @@ class Client:
data = await state.http.create_friend_invite()
return Invite.from_incomplete(state=state, data=data)
async def accept_invite(self, invite: Union[Invite, str], /) -> Invite:
async def accept_invite(self, url: Union[Invite, str], /) -> Invite:
"""|coro|
Uses an invite.
@ -1897,7 +1874,7 @@ class Client:
Parameters
----------
invite: Union[:class:`.Invite`, :class:`str`]
url: Union[:class:`.Invite`, :class:`str`]
The Discord invite ID, URL (must be a discord.gg URL), or :class:`.Invite`.
Raises
@ -1910,8 +1887,19 @@ class Client:
:class:`.Invite`
The accepted invite.
"""
if not isinstance(invite, Invite):
invite = await self.fetch_invite(invite, with_counts=False, with_expiration=False)
state = self._connection
resolved = utils.resolve_invite(url)
data = await state.http.get_invite(
resolved.code,
with_counts=True,
with_expiration=True,
input_value=resolved.code if isinstance(url, Invite) else url,
)
if isinstance(url, Invite):
invite = url
else:
invite = Invite.from_incomplete(state=state, data=data)
state = self._connection
type = invite.type
@ -2266,7 +2254,7 @@ class Client:
Raises
-------
HTTPException
Retreiving the notes failed.
Retrieving the notes failed.
Returns
--------
@ -2296,7 +2284,7 @@ class Client:
Raises
-------
HTTPException
Retreiving the note failed.
Retrieving the note failed.
Returns
--------
@ -2317,7 +2305,7 @@ class Client:
Raises
-------
HTTPException
Retreiving your connections failed.
Retrieving your connections failed.
Returns
-------
@ -2331,7 +2319,7 @@ class Client:
async def authorize_connection(
self,
type: ConnectionType,
two_way_link_type: Optional[ConnectionLinkType] = None,
two_way_link_type: Optional[ClientType] = None,
two_way_user_code: Optional[str] = None,
continuation: bool = False,
) -> str:
@ -2345,7 +2333,7 @@ class Client:
-----------
type: :class:`.ConnectionType`
The type of connection to authorize.
two_way_link_type: Optional[:class:`.ConnectionLinkType`]
two_way_link_type: Optional[:class:`.ClientType`]
The type of two-way link to use, if any.
two_way_user_code: Optional[:class:`str`]
The device code to use for two-way linking, if any.
@ -2426,10 +2414,14 @@ class Client:
.. versionadded:: 2.0
.. note::
This method is an API call. For general usage, consider :attr:`private_channels` instead.
Raises
-------
HTTPException
Retreiving your private channels failed.
Retrieving your private channels failed.
Returns
--------
@ -2440,6 +2432,31 @@ class Client:
channels = await state.http.get_private_channels()
return [_private_channel_factory(data['type'])[0](me=self.user, data=data, state=state) for data in channels] # type: ignore # user is always present when logged in
async def fetch_relationships(self) -> List[Relationship]:
"""|coro|
Retrieves all your relationships.
.. versionadded:: 2.0
.. note::
This method is an API call. For general usage, consider :attr:`relationships` instead.
Raises
-------
HTTPException
Retrieving your relationships failed.
Returns
--------
List[:class:`.Relationship`]
All your relationships.
"""
state = self._connection
data = await state.http.get_relationships()
return [Relationship(state=state, data=d) for d in data]
async def fetch_country_code(self) -> str:
"""|coro|

28
discord/enums.py

@ -88,7 +88,7 @@ __all__ = (
'EmbeddedActivityPlatform',
'EmbeddedActivityOrientation',
'ConnectionType',
'ConnectionLinkType',
'ClientType',
'PaymentSourceType',
'PaymentGateway',
'SubscriptionType',
@ -397,6 +397,8 @@ class RelationshipType(Enum):
blocked = 2
incoming_request = 3
outgoing_request = 4
implicit = 5
suggestion = 6
class NotificationLevel(Enum, comparable=True):
@ -1039,10 +1041,11 @@ class ConnectionType(Enum):
return self.value
class ConnectionLinkType(Enum):
class ClientType(Enum):
web = 'web'
mobile = 'mobile'
desktop = 'desktop'
unknown = 'unknown'
def __str__(self) -> str:
return self.value
@ -1258,11 +1261,30 @@ class SKUGenre(Enum):
return self.value
# There are tons of different operating system/client enums in the API,
# so we try to unify them here
# They're normalized as the numbered enum, and converted from the stringified enums
class OperatingSystem(Enum):
windows = 1
mac = 2
macos = 2
linux = 3
android = -1
ios = -1
unknown = -1
@classmethod
def from_string(cls, value: str) -> Self:
lookup = {
'windows': cls.windows,
'macos': cls.macos,
'linux': cls.linux,
'android': cls.android,
'ios': cls.ios,
'unknown': cls.unknown,
}
return lookup.get(value, create_unknown_value(cls, value))
class ContentRatingAgency(Enum):
esrb = 1

40
discord/gateway.py

@ -37,7 +37,7 @@ from typing import Any, Callable, Coroutine, Dict, List, TYPE_CHECKING, NamedTup
import aiohttp
from . import utils
from .activity import BaseActivity
from .activity import BaseActivity, Spotify
from .enums import SpeakingState
from .errors import ConnectionClosed
@ -54,6 +54,7 @@ __all__ = (
if TYPE_CHECKING:
from typing_extensions import Self
from .activity import ActivityTypes
from .client import Client
from .enums import Status
from .state import ConnectionState
@ -427,18 +428,36 @@ class DiscordWebSocket:
async def identify(self) -> None:
"""Sends the IDENTIFY packet."""
# User presence is weird...
# This payload is only sometimes respected; usually the gateway tells
# us our presence through the READY packet's sessions key
# However, when reidentifying, we should send our last known presence
# initial_status and initial_activities could probably also be sent here
# but that needs more testing...
presence = {
'status': 'unknown',
'since': 0,
'activities': [],
'afk': False,
}
existing = self._connection.current_session
if existing is not None:
presence['status'] = str(existing.status) if existing.status is not Status.offline else 'invisible'
if existing.status == Status.idle:
presence['since'] = int(time.time() * 1000)
presence['activities'] = [a.to_dict() for a in existing.activities]
# else:
# presence['status'] = self._connection._status or 'unknown'
# presence['activities'] = self._connection._activities
payload = {
'op': self.IDENTIFY,
'd': {
'token': self.token,
'capabilities': 509,
'properties': self._super_properties,
'presence': {
'status': 'online',
'since': 0,
'activities': [],
'afk': False,
},
'presence': presence,
'compress': False,
'client_state': {
'guild_hashes': {},
@ -450,6 +469,7 @@ class DiscordWebSocket:
}
if not self._zlib_enabled:
# We require at least one form of compression
payload['d']['compress'] = True
await self.call_hooks('before_identify', initial=self._initial_identify)
@ -658,13 +678,13 @@ class DiscordWebSocket:
async def change_presence(
self,
*,
activities: Optional[List[BaseActivity]] = None,
activities: Optional[List[ActivityTypes]] = None,
status: Optional[Status] = None,
since: float = 0.0,
since: int = 0,
afk: bool = False,
) -> None:
if activities is not None:
if not all(isinstance(activity, BaseActivity) for activity in activities):
if not all(isinstance(activity, (BaseActivity, Spotify)) for activity in activities):
raise TypeError('activity must derive from BaseActivity')
activities_data = [activity.to_dict() for activity in activities]
else:

10
discord/guild.py

@ -367,6 +367,9 @@ class Guild(Hashable):
def _add_member(self, member: Member, /) -> None:
self._members[member.id] = member
if member._presence:
self._state.store_presence(member.id, member._presence, self.id)
member._presence = None
def _store_thread(self, payload: ThreadPayload, /) -> Thread:
thread = Thread(guild=self, state=self._state, data=payload)
@ -375,6 +378,7 @@ class Guild(Hashable):
def _remove_member(self, member: Snowflake, /) -> None:
self._members.pop(member.id, None)
self._state.remove_presence(member.id, self.id)
def _add_thread(self, thread: Thread, /) -> None:
self._threads[thread.id] = thread
@ -541,12 +545,10 @@ class Guild(Hashable):
continue
self._add_member(member)
empty_tuple = tuple()
for presence in guild.get('presences', []):
user_id = int(presence['user']['id'])
member = self.get_member(user_id)
if member is not None:
member._presence_update(presence, empty_tuple) # type: ignore
presence = state.create_presence(presence)
state.store_presence(user_id, presence, self.id)
@property
def channels(self) -> List[GuildChannel]:

35
discord/http.py

@ -1842,12 +1842,14 @@ class HTTPClient:
with_counts: bool = True,
with_expiration: bool = True,
guild_scheduled_event_id: Optional[Snowflake] = None,
input_value: Optional[str] = None,
) -> Response[invite.Invite]:
params: Dict[str, Any] = {
'inputValue': invite_id,
'with_counts': str(with_counts).lower(),
'with_expiration': str(with_expiration).lower(),
}
if input_value:
params['inputValue'] = input_value
if guild_scheduled_event_id:
params['guild_scheduled_event_id'] = guild_scheduled_event_id
@ -2219,7 +2221,7 @@ class HTTPClient:
# Relationships
def get_relationships(self): # TODO: return type
def get_relationships(self) -> Response[List[user.Relationship]]:
return self.request(Route('GET', '/users/@me/relationships'))
def remove_relationship(self, user_id: Snowflake, *, action: RelationshipAction) -> Response[None]:
@ -2232,16 +2234,10 @@ class HTTPClient:
ContextProperties._from_dm_channel,
)
)()
elif action is RelationshipAction.unfriend: # Friends, ContextMenu, User Profile, DM Channel
props = choice(
(
ContextProperties._from_contextmenu,
ContextProperties._from_user_profile,
ContextProperties._from_friends_page,
ContextProperties._from_dm_channel,
)
)()
elif action == RelationshipAction.unblock: # Friends, ContextMenu, User Profile, DM Channel, NONE
elif action in (
RelationshipAction.unfriend,
RelationshipAction.unblock,
): # Friends, ContextMenu, User Profile, DM Channel
props = choice(
(
ContextProperties._from_contextmenu,
@ -2252,10 +2248,14 @@ class HTTPClient:
)()
elif action == RelationshipAction.remove_pending_request: # Friends
props = ContextProperties._from_friends_page()
else:
props = ContextProperties._empty()
return self.request(r, context_properties=props) # type: ignore
return self.request(r, context_properties=props)
def add_relationship(self, user_id: Snowflake, type: int = MISSING, *, action: RelationshipAction): # TODO: return type
def add_relationship(
self, user_id: Snowflake, type: Optional[int] = None, *, action: RelationshipAction
) -> Response[None]:
r = Route('PUT', '/users/@me/relationships/{user_id}', user_id=user_id)
if action is RelationshipAction.accept_request: # User Profile, Friends, DM Channel
props = choice(
@ -2282,11 +2282,10 @@ class HTTPClient:
ContextProperties._from_dm_channel,
)
)()
kwargs = {'context_properties': props} # type: ignore
if type:
kwargs['json'] = {'type': type}
else:
props = ContextProperties._empty()
return self.request(r, **kwargs)
return self.request(r, context_properties=props, json={'type': type} if type else None)
def send_friend_request(self, username: str, discriminator: Snowflake) -> Response[None]:
r = Route('POST', '/users/@me/relationships')

127
discord/member.py

@ -36,7 +36,6 @@ from . import utils
from .asset import Asset
from .utils import MISSING
from .user import BaseUser, User, _UserTag
from .activity import create_activity, ActivityTypes
from .permissions import Permissions
from .enums import RelationshipAction, Status, try_enum
from .errors import ClientException
@ -51,12 +50,12 @@ __all__ = (
if TYPE_CHECKING:
from typing_extensions import Self
from .activity import ActivityTypes
from .asset import Asset
from .channel import DMChannel, VoiceChannel, StageChannel, GroupChannel
from .flags import PublicUserFlags
from .guild import Guild
from .types.activity import (
ClientStatus as ClientStatusPayload,
PartialPresenceUpdate,
)
from .types.member import (
@ -67,7 +66,7 @@ if TYPE_CHECKING:
from .types.gateway import GuildMemberUpdateEvent
from .types.user import PartialUser as PartialUserPayload
from .abc import Snowflake
from .state import ConnectionState
from .state import ConnectionState, Presence
from .message import Message
from .role import Role
from .types.voice import (
@ -171,48 +170,6 @@ class VoiceState:
return f'<{self.__class__.__name__} {inner}>'
class _ClientStatus:
__slots__ = ('_status', '_this', 'desktop', 'mobile', 'web')
def __init__(self):
self._status: str = 'offline'
self._this: str = 'offline'
self.desktop: Optional[str] = None
self.mobile: Optional[str] = None
self.web: Optional[str] = None
def __repr__(self) -> str:
attrs = [
('_status', self._status),
('desktop', self.desktop),
('mobile', self.mobile),
('web', self.web),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<{self.__class__.__name__} {inner}>'
def _update(self, status: str, data: ClientStatusPayload, /) -> None:
self._status = status
self.desktop = data.get('desktop')
self.mobile = data.get('mobile')
self.web = data.get('web')
@classmethod
def _copy(cls, client_status: Self, /) -> Self:
self = cls.__new__(cls) # bypass __init__
self._status = client_status._status
self._this = client_status._this
self.desktop = client_status.desktop
self.mobile = client_status.mobile
self.web = client_status.web
return self
def flatten_user(cls: Any) -> Type[Member]:
for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()):
# Ignore private/special methods
@ -308,12 +265,11 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
'_roles',
'joined_at',
'premium_since',
'_activities',
'guild',
'pending',
'nick',
'timed_out_until',
'_client_status',
'_presence',
'_user',
'_state',
'_avatar',
@ -344,8 +300,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
self.joined_at: Optional[datetime.datetime] = utils.parse_time(data.get('joined_at'))
self.premium_since: Optional[datetime.datetime] = utils.parse_time(data.get('premium_since'))
self._roles: utils.SnowflakeList = utils.SnowflakeList(map(int, data['roles']))
self._client_status: _ClientStatus = _ClientStatus()
self._activities: Tuple[ActivityTypes, ...] = tuple()
self._presence: Optional[Presence] = None
self.nick: Optional[str] = data.get('nick', None)
self.pending: bool = data.get('pending', False)
self._avatar: Optional[str] = data.get('avatar')
@ -401,8 +356,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
self._roles = utils.SnowflakeList(member._roles, is_sorted=True)
self.joined_at = member.joined_at
self.premium_since = member.premium_since
self._activities = member._activities
self._client_status = _ClientStatus._copy(member._client_status)
self._presence = member._presence
self.guild = member.guild
self.nick = member.nick
self.pending = member.pending
@ -440,26 +394,11 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
if any(getattr(self, attr) != getattr(old, attr) for attr in attrs):
return old
def _presence_update(self, data: PartialPresenceUpdate, user: PartialUserPayload) -> Optional[Tuple[User, User]]:
if self._self:
return
self._activities = tuple(create_activity(d, self._state) for d in data['activities'])
self._client_status._update(data['status'], data['client_status'])
if len(user) > 1:
return self._update_inner_user(user)
def _update_inner_user(self, user: PartialUserPayload) -> Optional[Tuple[User, User]]:
u = self._user
original = (u.name, u._avatar, u.discriminator, u._public_flags)
# These keys seem to always be available
modified = (user['username'], user['avatar'], user['discriminator'], user.get('public_flags', 0))
if original != modified:
to_return = User._copy(self._user)
u.name, u._avatar, u.discriminator, u._public_flags = modified
# Signal to dispatch user_update
return to_return, u
def _presence_update(
self, data: PartialPresenceUpdate, user: Union[PartialUserPayload, Tuple[()]]
) -> Optional[Tuple[User, User]]:
self._presence = self._state.create_presence(data)
return self._user._update_self(user)
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
@ -471,11 +410,15 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
ch = await self.create_dm()
return ch
@property
def presence(self) -> Presence:
state = self._state
return self._presence or state.get_presence(self._user.id, self.guild.id) or state.create_offline_presence()
@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."""
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status._status)
""":class:`Status`: The member's overall status."""
return try_enum(Status, self.presence.client_status.status)
@property
def raw_status(self) -> str:
@ -483,37 +426,26 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
.. versionadded:: 1.5
"""
client_status = self._client_status if not self._self else self._state.client._client_status
return client_status._status
@status.setter
def status(self, value: Status) -> None:
# Internal use only
client_status = self._client_status if not self._self else self._state.client._client_status
client_status._status = str(value)
return self.presence.client_status.status
@property
def mobile_status(self) -> Status:
""":class:`Status`: The member's status on a mobile device, if applicable."""
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.mobile or 'offline')
return try_enum(Status, self.presence.client_status.mobile or 'offline')
@property
def desktop_status(self) -> Status:
""":class:`Status`: The member's status on the desktop client, if applicable."""
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.desktop or 'offline')
return try_enum(Status, self.presence.client_status.desktop or 'offline')
@property
def web_status(self) -> Status:
""":class:`Status`: The member's status on the web client, if applicable."""
client_status = self._client_status if not self._self else self._state.client._client_status
return try_enum(Status, client_status.web or 'offline')
return try_enum(Status, self.presence.client_status.web or 'offline')
def is_on_mobile(self) -> bool:
""":class:`bool`: A helper function that determines if a member is active on a mobile device."""
client_status = self._client_status if not self._self else self._state.client._client_status
return client_status.mobile is not None
return self.presence.client_status.mobile is not None
@property
def colour(self) -> Colour:
@ -608,11 +540,8 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
Due to a Discord API limitation, a user's Spotify activity may not appear
if they are listening to a song with a title longer
than 128 characters. See :issue:`1738` for more information.
"""
if self._self:
return self._state.client.activities
return self._activities
return self.presence.activities
@property
def activity(self) -> Optional[ActivityTypes]:
@ -702,10 +631,6 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
"""Optional[:class:`VoiceState`]: Returns the member's current voice state."""
return self.guild._voice_state_for(self._user.id)
@property
def _self(self) -> bool:
return self._user.id == self._state.self_id
async def ban(
self,
*,
@ -773,7 +698,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
.. note::
To upload an avatar, a :term:`py:bytes-like object` must be passed in that
To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that
represents the image being uploaded. If this is done through a file
then the file must be opened via ``open('some_filename', 'rb')`` and
the :term:`py:bytes-like object` is given through the use of ``fp.read()``.
@ -841,7 +766,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
"""
http = self._state.http
guild_id = self.guild.id
me = self._self
me = self._user.id == self._state.self_id
payload: Dict[str, Any] = {}
data = None
@ -1122,7 +1047,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
return utils.utcnow() < self.timed_out_until
return False
async def send_friend_request(self) -> None: # TODO: check if the req returns a relationship obj
async def send_friend_request(self) -> None:
"""|coro|
Sends the member a friend request.

232
discord/relationship.py

@ -24,15 +24,21 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
import copy
from typing import Optional, TYPE_CHECKING
from typing import TYPE_CHECKING, Optional, Tuple, Union
from .enums import RelationshipAction, RelationshipType, try_enum
from .enums import RelationshipAction, RelationshipType, Status, try_enum
from .mixins import Hashable
from .object import Object
from .utils import MISSING
from .utils import MISSING, parse_time
if TYPE_CHECKING:
from .state import ConnectionState
from datetime import datetime
from typing_extensions import Self
from .activity import ActivityTypes
from .state import ConnectionState, Presence
from .types.gateway import RelationshipEvent
from .types.user import Relationship as RelationshipPayload
from .user import User
# fmt: off
@ -42,7 +48,7 @@ __all__ = (
# fmt: on
class Relationship:
class Relationship(Hashable):
"""Represents a relationship in Discord.
A relationship is like a friendship, a person who is blocked, etc.
@ -63,49 +69,194 @@ class Relationship:
Attributes
-----------
user: :class:`User`
The user you have the relationship with.
type: :class:`RelationshipType`
The type of relationship you have.
nick: Optional[:class:`str`]
The user's friend nickname (if applicable).
.. versionadded:: 1.9
.. versionchanged:: 2.0
Renamed ``nickname`` to :attr:`nick`.
user: :class:`User`
The user you have the relationship with.
type: :class:`RelationshipType`
The type of relationship you have.
since: Optional[:class:`datetime.datetime`]
When the relationship was created.
Only available for type :class:`RelationshipType.incoming_request`.
.. versionadded:: 2.0
"""
__slots__ = ('nick', 'type', 'user', '_state')
__slots__ = ('_presence', 'since', 'nick', 'type', 'user', '_state')
if TYPE_CHECKING:
user: User
def __init__(self, *, state: ConnectionState, data) -> None: # TODO: type data
def __init__(self, *, state: ConnectionState, data: RelationshipPayload) -> None:
self._state = state
self._presence: Optional[Presence] = None
self._update(data)
def _update(self, data: dict) -> None:
def _update(self, data: Union[RelationshipPayload, RelationshipEvent]) -> None:
self.type: RelationshipType = try_enum(RelationshipType, data['type'])
self.nick: Optional[str] = data.get('nickname')
self.since: Optional[datetime] = parse_time(data.get('since'))
self.user: User
if (user := data.get('user')) is not None:
self.user = self._state.store_user(user)
elif self.user:
return
if not getattr(self, 'user', None):
if 'user' in data:
self.user = self._state.store_user(data['user']) # type: ignore
else:
user_id = int(data['id'])
self.user = self._state.get_user(user_id) or Object(id=user_id) # type: ignore # Lying for better developer UX
@classmethod
def _from_implicit(cls, *, state: ConnectionState, user: User) -> Relationship:
self = cls.__new__(cls)
self._state = state
self.type = RelationshipType.implicit
self.nick = None
self.since = None
self.user = user
return self
@classmethod
def _copy(cls, relationship: Self, presence: Presence) -> Self:
self = cls.__new__(cls) # to bypass __init__
self._state = relationship._state
self._presence = presence
self.type = relationship.type
self.nick = relationship.nick
self.since = relationship.since
self.user = relationship.user
return self
def __repr__(self) -> str:
return f'<Relationship user={self.user!r} type={self.type!r} nick={self.nick!r}>'
def __eq__(self, other: object) -> bool:
return isinstance(other, Relationship) and other.user.id == self.user.id
@property
def id(self) -> int:
""":class:`int`: Returns the relationship's ID."""
return self.user.id
@property
def presence(self) -> Presence:
state = self._state
return self._presence or state._presences.get(self.user.id) or state.create_offline_presence()
@property
def status(self) -> Status:
""":class:`Status`: The user's overall status.
def __ne__(self, other: object) -> bool:
if isinstance(other, Relationship):
return other.user.id != self.user.id
return True
.. versionadded:: 2.0
def __hash__(self) -> int:
return self.user.__hash__()
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return try_enum(Status, self.presence.client_status.status)
@property
def raw_status(self) -> str:
""":class:`str`: The user's overall status as a string value.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return self.presence.client_status.status
@property
def mobile_status(self) -> Status:
""":class:`Status`: The user's status on a mobile device, if applicable.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return try_enum(Status, self.presence.client_status.mobile or 'offline')
@property
def desktop_status(self) -> Status:
""":class:`Status`: The user's status on the desktop client, if applicable.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return try_enum(Status, self.presence.client_status.desktop or 'offline')
@property
def web_status(self) -> Status:
""":class:`Status`: The user's status on the web client, if applicable.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return try_enum(Status, self.presence.client_status.web or 'offline')
def is_on_mobile(self) -> bool:
""":class:`bool`: A helper function that determines if a user is active on a mobile device.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
"""
return self.presence.client_status.mobile is not None
@property
def activities(self) -> Tuple[ActivityTypes, ...]:
"""Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the activities that
the user is currently doing.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
.. note::
Due to a Discord API limitation, a user's Spotify activity may not appear
if they are listening to a song with a title longer
than 128 characters. See :issue:`1738` for more information.
"""
return self.presence.activities
@property
def activity(self) -> Optional[ActivityTypes]:
"""Optional[Union[:class:`BaseActivity`, :class:`Spotify`]]: Returns the primary
activity the user is currently doing. Could be ``None`` if no activity is being done.
.. versionadded:: 2.0
.. note::
This is only reliably provided for type :class:`RelationshipType.friend`.
.. note::
Due to a Discord API limitation, this may be ``None`` if
the user is listening to a song on Spotify with a title longer
than 128 characters. See :issue:`1738` for more information.
.. note::
A user may have multiple activities, these can be accessed under :attr:`activities`.
"""
if self.activities:
return self.activities[0]
async def delete(self) -> None:
"""|coro|
@ -113,7 +264,7 @@ class Relationship:
Deletes the relationship.
Depending on the type, this could mean unfriending or unblocking the user,
denying an incoming friend request, or discarding an outgoing friend request.
denying an incoming friend request, discarding an outgoing friend request, etc.
Raises
------
@ -132,29 +283,20 @@ class Relationship:
await self._state.http.remove_relationship(self.user.id, action=action)
async def accept(self) -> Relationship:
async def accept(self) -> None:
"""|coro|
Accepts the relationship request. Only applicable for
type :class:`RelationshipType.incoming_request`.
.. versionchanged:: 2.0
Changed the return type to :class:`Relationship`.
Raises
-------
HTTPException
Accepting the relationship failed.
Returns
-------
:class:`Relationship`
The new relationship.
"""
data = await self._state.http.add_relationship(self.user.id, action=RelationshipAction.accept_request)
return Relationship(state=self._state, data=data)
await self._state.http.add_relationship(self.user.id, action=RelationshipAction.accept_request)
async def edit(self, nick: Optional[str] = MISSING) -> Relationship:
async def edit(self, nick: Optional[str] = MISSING) -> None:
"""|coro|
Edits the relationship.
@ -174,19 +316,9 @@ class Relationship:
-------
HTTPException
Changing the nickname failed.
Returns
-------
:class:`Relationship`
The new relationship.
"""
payload = {}
if nick is not MISSING:
payload['nick'] = nick
payload['nickname'] = nick
await self._state.http.edit_relationship(self.user.id, **payload)
# Emulate the return for consistency
new = copy.copy(self)
new.nick = nick if nick is not MISSING else self.nick
return new

457
discord/state.py

@ -50,7 +50,7 @@ from math import ceil
from .errors import ClientException, InvalidData, NotFound
from .guild import CommandCounts, Guild
from .activity import BaseActivity
from .activity import BaseActivity, create_activity, Session
from .user import User, ClientUser
from .emoji import Emoji
from .mentions import AllowedMentions
@ -62,7 +62,15 @@ from .raw_models import *
from .member import Member
from .relationship import Relationship
from .role import Role
from .enums import ChannelType, PaymentSourceType, RequiredActionType, Status, try_enum, UnavailableGuildType
from .enums import (
ChannelType,
PaymentSourceType,
RelationshipType,
RequiredActionType,
Status,
try_enum,
UnavailableGuildType,
)
from . import utils
from .flags import MemberCacheFlags
from .invite import Invite
@ -74,7 +82,6 @@ from .sticker import GuildSticker
from .settings import UserSettings, GuildSettings, ChannelSettings, TrackingSettings
from .interactions import Interaction
from .permissions import Permissions, PermissionOverwrite
from .member import _ClientStatus
from .modal import Modal
from .member import VoiceState
from .appinfo import IntegrationApplication, PartialApplication, Achievement
@ -85,7 +92,10 @@ from .guild_premium import PremiumGuildSubscriptionSlot
from .library import LibraryApplication
if TYPE_CHECKING:
from typing_extensions import Self
from .abc import PrivateChannel, Snowflake as abcSnowflake
from .activity import ActivityTypes
from .message import MessageableChannel
from .guild import GuildChannel, VocalGuildChannel
from .http import HTTPClient
@ -105,6 +115,7 @@ if TYPE_CHECKING:
from .types.message import Message as MessagePayload, PartialMessage as PartialMessagePayload
from .types import gateway as gw
from .types.voice import GuildVoiceState
from .types.activity import ClientStatus as ClientStatusPayload
T = TypeVar('T')
Channel = Union[GuildChannel, VocalGuildChannel, PrivateChannel, PartialMessageable, ForumChannel]
@ -378,6 +389,115 @@ class MemberSidebar:
self.guild._chunked = True
class ClientStatus:
__slots__ = ('status', 'desktop', 'mobile', 'web')
def __init__(self, status: Optional[str] = None, data: Optional[ClientStatusPayload] = None, /) -> None:
self.status: str = 'offline'
self.desktop: Optional[str] = None
self.mobile: Optional[str] = None
self.web: Optional[str] = None
if status is not None or data is not None:
self._update(status or 'offline', data or {})
def __repr__(self) -> str:
attrs = [
('status', self.status),
('desktop', self.desktop),
('mobile', self.mobile),
('web', self.web),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<{self.__class__.__name__} {inner}>'
def _update(self, status: str, data: ClientStatusPayload, /) -> None:
self.status = status
self.desktop = data.get('desktop')
self.mobile = data.get('mobile')
self.web = data.get('web')
@classmethod
def _copy(cls, client_status: Self, /) -> Self:
self = cls.__new__(cls) # bypass __init__
self.status = client_status.status
self.desktop = client_status.desktop
self.mobile = client_status.mobile
self.web = client_status.web
return self
class Presence:
__slots__ = ('client_status', 'activities', 'last_modified')
def __init__(self, data: gw.PresenceUpdateEvent, state: ConnectionState, /) -> None:
self.client_status: ClientStatus = ClientStatus(data['status'], data.get('client_status'))
self.activities: Tuple[ActivityTypes, ...] = tuple(create_activity(d, state) for d in data['activities'])
self.last_modified: Optional[datetime.datetime] = utils.parse_timestamp(data.get('last_modified'))
def __repr__(self) -> str:
attrs = [
('client_status', self.client_status),
('activities', self.activities),
('last_modified', self.last_modified),
]
inner = ' '.join('%s=%r' % t for t in attrs)
return f'<{self.__class__.__name__} {inner}>'
def __eq__(self, other: Any) -> bool:
if not isinstance(other, Presence):
return False
return self.client_status == other.client_status and self.activities == other.activities
def __ne__(self, other: Any) -> bool:
if not isinstance(other, Presence):
return True
return self.client_status != other.client_status or self.activities != other.activities
def _update(self, data: gw.PresenceUpdateEvent, state: ConnectionState, /) -> None:
self.client_status._update(data['status'], data.get('client_status'))
self.activities = tuple(create_activity(d, state) for d in data['activities'])
self.last_modified = utils.parse_timestamp(data.get('last_modified')) or utils.utcnow()
@classmethod
def _offline(cls) -> Self:
self = cls.__new__(cls) # bypass __init__
self.client_status = ClientStatus()
self.activities = ()
self.last_modified = None
return self
@classmethod
def _copy(cls, presence: Self, /) -> Self:
self = cls.__new__(cls) # bypass __init__
self.client_status = ClientStatus._copy(presence.client_status)
self.activities = presence.activities
self.last_modified = presence.last_modified
return self
class FakeClientPresence(Presence):
__slots__ = ('_state',)
def __init__(self, state: ConnectionState, /) -> None:
self._state = state
@property
def client_status(self) -> ClientStatus:
state = self._state
status = str(getattr(state.current_session, 'status', 'offline'))
client_status = {str(session.client): str(session.status) for session in state._sessions.values()}
return ClientStatus(status, client_status) # type: ignore
@property
def activities(self) -> Tuple[ActivityTypes, ...]:
return getattr(self._state.current_session, 'activities', ())
@property
def last_modified(self) -> Optional[datetime.datetime]:
return None
async def logging_coroutine(coroutine: Coroutine[Any, Any, T], *, info: str) -> Optional[T]:
try:
await coroutine
@ -490,6 +610,10 @@ class ConnectionState:
self._private_channels: Dict[int, PrivateChannel] = {}
self._private_channels_by_user: Dict[int, DMChannel] = {}
self._guild_presences: Dict[int, Dict[int, Presence]] = {}
self._presences: Dict[int, Presence] = {}
self._sessions: Dict[str, Session] = {}
if self.max_messages is not None:
self._messages: Optional[Deque[Message]] = deque(maxlen=self.max_messages)
else:
@ -602,15 +726,7 @@ class ConnectionState:
# this way is 300% faster than `dict.setdefault`.
user_id = int(data['id'])
try:
user = self._users[user_id]
# We use the data available to us since we
# might not have events for that user
# However, the data may only have an ID
try:
user._update(data)
except KeyError:
pass
return user
return self._users[user_id]
except KeyError:
user = User(state=self, data=data)
if user.discriminator != '0000':
@ -867,9 +983,6 @@ class ConnectionState:
if member:
voice_state['member'] = member
# There's also a friends key that has presence data for your friends
# Parsing that would require a redesign of the Relationship class ;-;
# Self parsing
self.user = user = ClientUser(state=self, data=data['user'])
self._users[user.id] = user # type: ignore
@ -889,6 +1002,11 @@ class ConnectionState:
relationship['user'] = temp_users[int(relationship.pop('user_id'))]
self._relationships[r_id] = Relationship(state=self, data=relationship)
# Relationship presence parsing
for presence in extra_data['merged_presences'].get('friends', []):
user_id = int(presence.pop('user_id')) # type: ignore
self.store_presence(user_id, self.create_presence(presence))
# Private channel parsing
for pm in data.get('private_channels', []) + extra_data.get('lazy_private_channels', []):
factory, _ = _private_channel_factory(pm['type'])
@ -915,7 +1033,7 @@ class ConnectionState:
self.parse_user_required_action_update(data)
if 'sessions' in data:
self.parse_sessions_replace(data['sessions'])
self.parse_sessions_replace(data['sessions'], from_ready=True)
if 'auth_token' in data:
self.http._token(data['auth_token'])
@ -1055,32 +1173,55 @@ class ConnectionState:
if reaction:
self.dispatch('reaction_clear_emoji', reaction)
def parse_presences_replace(self, data: List[gw.PresenceUpdateEvent]) -> None:
def parse_presences_replace(self, data: List[gw.PartialPresenceUpdate]) -> None:
for presence in data:
self.parse_presence_update(presence)
def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None:
guild_id = utils._get_as_snowflake(data, 'guild_id')
guild = self._get_guild(guild_id)
if guild is None:
if guild_id and not guild:
_log.debug('PRESENCE_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id)
return
user = data['user']
member_id = int(user['id'])
member = guild.get_member(member_id)
user_id = int(user['id'])
presence = self.get_presence(user_id, guild_id)
if presence is not None:
old_presence = Presence._copy(presence)
presence._update(data, self)
else:
old_presence = Presence._offline()
presence = self.store_presence(user_id, self.create_presence(data), guild_id)
if not guild:
try:
relationship = self.create_implicit_relationship(self.store_user(user))
except (KeyError, ValueError):
# User object is partial, so we can't continue
_log.debug('PRESENCE_UPDATE referencing an unknown relationship ID: %s. Discarding.', user_id)
return
user_update = relationship.user._update_self(user)
if old_presence != presence:
old_relationship = Relationship._copy(relationship, old_presence)
self.dispatch('presence_update', old_relationship, relationship)
else:
member = guild.get_member(user_id)
if member is None:
_log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding.', member_id)
_log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding.', user_id)
return
user_update = member._user._update_self(user)
if old_presence != presence:
old_member = Member._copy(member)
user_update = member._presence_update(data=data, user=user)
old_member._presence = old_presence
self.dispatch('presence_update', old_member, member)
if user_update:
self.dispatch('user_update', user_update[0], user_update[1])
if old_member._client_status != member._client_status or old_member._activities != member._activities:
self.dispatch('presence_update', old_member, member)
def parse_user_update(self, data: gw.UserUpdateEvent) -> None:
if self.user:
self.user._full_update(data)
@ -1191,46 +1332,52 @@ class ConnectionState:
entry = LibraryApplication(state=self, data=data)
self.dispatch('library_application_update', entry)
def parse_sessions_replace(self, data: List[Dict[str, Any]]) -> None:
overall = MISSING
this = MISSING
client_status = _ClientStatus()
client_activities = {}
statuses = {}
if len(data) == 1:
overall = this = data[0]
# Duplicates will be overwritten I guess
for session in data:
if session['session_id'] == 'all':
overall = session
data.remove(session)
continue
elif session['session_id'] == self.session_id:
this = session
continue
key = session['client_info']['client']
statuses[key] = session['status']
client_activities[key] = tuple(session['activities'])
if overall is MISSING and this is MISSING:
_log.debug('SESSIONS_REPLACE has weird data: %s.', data)
return # ._.
elif overall is MISSING:
overall = this
elif this is MISSING:
this = overall
client_status._update(overall['status'], statuses) # type: ignore
client_status._this = this['status']
client_activities[None] = tuple(overall['activities'])
client_activities['this'] = tuple(this['activities'])
client = self.client
client._client_status = client_status
client._client_activities = client_activities
client._session_count = len(data)
def parse_sessions_replace(self, payload: gw.SessionsReplaceEvent, *, from_ready: bool = False) -> None:
data = {s['session_id']: s for s in payload}
for session_id, session in data.items():
existing = self._sessions.get(session_id)
if existing is not None:
old = copy.copy(existing)
existing._update(session)
if not from_ready and (
old.status != existing.status or old.active != existing.active or old.activities != existing.activities
):
self.dispatch('session_update', old, existing)
else:
existing = Session(state=self, data=session)
self._sessions[session_id] = existing
if not from_ready:
self.dispatch('session_create', existing)
old_all = None
if not from_ready:
removed_sessions = [s for s in self._sessions if s not in data]
for session_id in removed_sessions:
if session_id == 'all':
old_all = self._sessions.pop('all')
else:
session = self._sessions.pop(session_id)
self.dispatch('session_delete', session)
if 'all' not in self._sessions:
# The "all" session does not always exist...
# This usually happens if there is only a single session (us)
# In the case it is "removed", we try to update the old one
# Else, we create a new one with fake data
if len(data) > 1:
# We have more than one session, this should not happen
fake = data[self.session_id] # type: ignore
else:
fake = list(data.values())[0]
if old_all is not None:
old = copy.copy(old_all)
old_all._update(fake)
if old.status != old_all.status or old.active != old_all.active or old.activities != old_all.activities:
self.dispatch('session_update', old, old_all)
else:
old_all = Session._fake_all(state=self, data=fake)
self._sessions['all'] = old_all
def parse_entitlement_create(self, data: gw.EntitlementEvent) -> None:
entitlement = Entitlement(state=self, data=data)
@ -1437,7 +1584,8 @@ class ConnectionState:
new_threads = {}
for d in data.get('threads', []):
if (thread := threads.pop(int(d['id']), None)) is not None:
thread = threads.pop(int(d['id']), None)
if thread is not None:
old = thread._update(d)
if old is not None:
self.dispatch('thread_update', old, thread) # Honestly not sure if this is right
@ -1530,17 +1678,15 @@ class ConnectionState:
if self.member_cache_flags.other or int(data['user']['id']) == self.self_id or guild.chunked:
member = Member(guild=guild, data=data, state=self)
guild._add_member(member)
if data.get('presence') is not None:
presence = self.create_presence(data['presence']) # type: ignore
self.store_presence(member.id, presence, guild.id)
if (presence := data.get('presence')) is not None:
member._presence_update(presence, tuple()) # type: ignore
guild._add_member(member)
def parse_guild_member_remove(self, data: gw.GuildMemberRemoveEvent) -> None:
guild = self._get_guild(int(data['guild_id']))
if guild is not None:
if guild._member_count is not None:
guild._member_count -= 1
user_id = int(data['user']['id'])
member = guild.get_member(user_id)
if member is not None:
@ -1560,25 +1706,24 @@ class ConnectionState:
member = guild.get_member(user_id)
if member is not None:
old_member = member._update(data)
user_update = member._update_inner_user(user)
if user_update:
self.dispatch('user_update', user_update[0], user_update[1])
if old_member is not None:
self.dispatch('member_update', old_member, member)
else:
if self.member_cache_flags.other or user_id == self.self_id or guild.chunked:
member = Member(data=data, guild=guild, state=self) # type: ignore # The data is close enough
guild._add_member(member)
_log.debug('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s.', user_id)
if member is not None:
# Force an update on the inner user if necessary
user_update = member._update_inner_user(user)
user_update = member._user._update_self(user)
if user_update:
self.dispatch('user_update', user_update[0], user_update[1])
guild._add_member(member)
_log.debug('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s.', user_id)
def parse_guild_sync(self, data) -> None:
print('I noticed you triggered a `GUILD_SYNC`.\nIf you want to share your secrets, please feel free to email me.')
print(
'I noticed you triggered a `GUILD_SYNC`.\nIf you want to share your secrets, please feel free to open an issue.'
)
def parse_guild_member_list_update(self, data) -> None:
self.dispatch('raw_member_list_update', data)
@ -1590,10 +1735,10 @@ class ConnectionState:
request = self._scrape_requests.get(guild.id)
should_parse = guild.chunked or getattr(request, 'chunk', False)
if (count := data['member_count']) > 0:
guild._member_count = count
if (count := data['online_count']) > 0:
guild._presence_count = count
if data['member_count'] > 0:
guild._member_count = data['member_count']
if data['online_count'] > 0:
guild._presence_count = data['online_count']
guild._true_online_count = sum(group['count'] for group in data['groups'] if group['id'] != 'offline')
empty_tuple = tuple()
@ -1625,9 +1770,10 @@ class ConnectionState:
guild._member_list.append(None) if should_parse else None # Insert blank so indexes don't fuck up
continue
member = Member(data=item['member'], guild=guild, state=self)
if presence := item['member'].get('presence'):
member._presence_update(presence, empty_tuple) # type: ignore
mdata = item['member']
member = Member(data=mdata, guild=guild, state=self)
if mdata.get('presence') is not None:
member._presence_update(mdata['presence'], empty_tuple) # type: ignore
members.append(member)
guild._member_list.append(member) if should_parse else None
@ -1648,16 +1794,22 @@ class ConnectionState:
old_member = Member._copy(member)
dispatch = bool(member._update(mdata))
if presence := mdata.get('presence'):
member._presence_update(presence, empty_tuple) # type: ignore
if mdata.get('presence') is not None:
pdata = mdata['presence']
presence = self.get_presence(user_id, guild.id)
if presence is not None:
old_presence = Presence._copy(presence)
presence._update(pdata, self)
else:
old_presence = Presence._offline()
presence = self.store_presence(user_id, self.create_presence(pdata), guild.id)
if should_parse and (
old_member._client_status != member._client_status or old_member._activities != member._activities
):
old_member._presence = old_presence
if should_parse and old_presence != presence:
self.dispatch('presence_update', old_member, member)
user_update = member._update_inner_user(user)
if should_parse and user_update:
user_update = member._user._update_self(user)
if user_update:
self.dispatch('user_update', user_update[0], user_update[1])
if should_parse and dispatch:
@ -1666,8 +1818,8 @@ class ConnectionState:
disregard.append(member)
else:
member = Member(data=mdata, guild=guild, state=self)
if presence := mdata.get('presence'):
member._presence_update(presence, empty_tuple) # type: ignore
if mdata.get('presence') is not None:
member._presence_update(mdata['presence'], empty_tuple) # type: ignore
to_add.append(member)
@ -1687,24 +1839,37 @@ class ConnectionState:
old_member = Member._copy(member)
dispatch = bool(member._update(mdata))
if presence := mdata.get('presence'):
member._presence_update(presence, empty_tuple) # type: ignore
if mdata.get('presence') is not None:
pdata = mdata['presence']
presence = self.get_presence(user_id, guild.id)
if presence is not None:
old_presence = Presence._copy(presence)
presence._update(pdata, self)
else:
old_presence = Presence._offline()
presence = self.store_presence(user_id, self.create_presence(pdata), guild.id)
if should_parse and (
old_member._client_status != member._client_status or old_member._activities != member._activities
):
old_member._presence = old_presence
if should_parse and old_presence != presence:
self.dispatch('presence_update', old_member, member)
user_update = member._update_inner_user(user)
if should_parse and user_update:
user_update = member._user._update_self(user)
if user_update:
self.dispatch('user_update', user_update[0], user_update[1])
if should_parse and dispatch:
self.dispatch('member_update', old_member, member)
else:
_log.debug(
'GUILD_MEMBER_LIST_UPDATE type UPDATE referencing an unknown member ID %s index %s in %s. Discarding.',
user_id,
opdata['index'],
guild.id,
)
member = Member(data=mdata, guild=guild, state=self)
if presence := mdata.get('presence'):
member._presence_update(presence, empty_tuple) # type: ignore
if mdata.get('presence') is not None:
self.store_presence(user_id, self.create_presence(mdata['presence']), guild.id)
guild._member_list.insert(opdata['index'], member) # Race condition?
@ -2047,13 +2212,14 @@ class ConnectionState:
_log.debug('Processed a chunk for %s members in guild ID %s.', len(members), guild_id)
if presences:
empty_tuple = ()
member_dict: Dict[Snowflake, Member] = {str(member.id): member for member in members}
for presence in presences:
user = presence['user']
member_id = user['id']
member = member_dict.get(member_id)
if member is not None:
member._presence_update(presence, user)
member._presence_update(presence, empty_tuple)
complete = data.get('chunk_index', 0) + 1 == data.get('chunk_count')
self.process_chunk_requests(guild_id, data.get('nonce'), members, complete)
@ -2300,7 +2466,7 @@ class ConnectionState:
timestamp = datetime.datetime.fromtimestamp(data['timestamp'], tz=datetime.timezone.utc)
self.dispatch('typing', channel, member, timestamp)
def parse_relationship_add(self, data) -> None:
def parse_relationship_add(self, data: gw.RelationshipAddEvent) -> None:
key = int(data['id'])
new = self._relationships.get(key)
if new is None:
@ -2312,20 +2478,20 @@ class ConnectionState:
new._update(data)
self.dispatch('relationship_update', old, new)
def parse_relationship_remove(self, data) -> None:
def parse_relationship_remove(self, data: gw.RelationshipEvent) -> None:
key = int(data['id'])
try:
old = self._relationships.pop(key)
except KeyError:
_log.warning('Relationship_remove referencing unknown relationship ID: %s. Discarding.', key)
_log.warning('RELATIONSHIP_REMOVE referencing unknown relationship ID: %s. Discarding.', key)
else:
self.dispatch('relationship_remove', old)
def parse_relationship_update(self, data) -> None:
def parse_relationship_update(self, data: gw.RelationshipEvent) -> None:
key = int(data['id'])
new = self._relationships.get(key)
if new is None:
relationship = Relationship(state=self, data=data)
relationship = Relationship(state=self, data=data) # type: ignore
self._relationships[key] = relationship
else:
old = copy.copy(new)
@ -2427,6 +2593,73 @@ class ConnectionState:
def default_channel_settings(self, guild_id: Optional[int], channel_id: int) -> ChannelSettings:
return ChannelSettings(guild_id, data={'channel_id': channel_id}, state=self)
def create_implicit_relationship(self, user: User) -> Relationship:
relationship = self._relationships.get(user.id)
if relationship is not None:
if relationship.type.value == 0:
relationship.type = RelationshipType.implicit
else:
relationship = Relationship._from_implicit(state=self, user=user)
self._relationships[relationship.id] = relationship
return relationship
@property
def all_session(self) -> Optional[Session]:
return self._sessions.get('all')
@property
def current_session(self) -> Optional[Session]:
return self._sessions.get(self.session_id) # type: ignore
@utils.cached_property
def client_presence(self) -> FakeClientPresence:
return FakeClientPresence(self)
def create_presence(self, data: gw.PresenceUpdateEvent) -> Presence:
return Presence(data, self)
def create_offline_presence(self) -> Presence:
return Presence._offline()
def get_presence(self, user_id: int, guild_id: Optional[int] = None) -> Optional[Presence]:
if user_id == self.self_id:
# Our own presence is unified
return self.client_presence
if guild_id is not None:
guild = self._guild_presences.get(guild_id)
if guild is not None:
return guild.get(user_id)
return
return self._presences.get(user_id)
def remove_presence(self, user_id: int, guild_id: Optional[int] = None) -> None:
if guild_id is not None:
guild = self._guild_presences.get(guild_id)
if guild is not None:
guild.pop(user_id, None)
else:
self._presences.pop(user_id, None)
def store_presence(self, user_id: int, presence: Presence, guild_id: Optional[int] = None) -> Presence:
if presence.client_status.status == Status.offline.value and not presence.activities:
# We don't store empty presences
self.remove_presence(user_id, guild_id)
return presence
if user_id == self.self_id:
# We don't store our own presence
return presence
if guild_id is not None:
guild = self._guild_presences.get(guild_id)
if guild is None:
guild = self._guild_presences[guild_id] = {}
guild[user_id] = presence
else:
self._presences[user_id] = presence
return presence
@utils.cached_property
def premium_subscriptions_application(self) -> PartialApplication:
# Hardcoded application for premium subscriptions, highly unlikely to change

4
discord/types/activity.py

@ -26,7 +26,7 @@ from __future__ import annotations
from typing import List, Literal, Optional, TypedDict
from typing_extensions import NotRequired
from .user import User
from .user import PartialUser
from .snowflake import Snowflake
@ -34,7 +34,7 @@ StatusType = Literal['idle', 'dnd', 'online', 'offline']
class PartialPresenceUpdate(TypedDict):
user: User
user: PartialUser
guild_id: Optional[Snowflake]
status: StatusType
activities: List[Activity]

52
discord/types/gateway.py

@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE.
from typing import List, Literal, Optional, TypedDict, Union
from typing_extensions import NotRequired, Required
from .activity import PartialPresenceUpdate
from .activity import Activity, ClientStatus, PartialPresenceUpdate, StatusType
from .voice import GuildVoiceState
from .integration import BaseIntegration, IntegrationApplication
from .role import Role
@ -39,7 +39,7 @@ from .message import Message
from .sticker import GuildSticker
from .appinfo import BaseAchievement, PartialApplication
from .guild import Guild, UnavailableGuild, SupplementalGuild
from .user import Connection, User, PartialUser
from .user import Connection, User, PartialUser, Relationship, RelationshipType
from .threads import Thread, ThreadMember
from .scheduled_event import GuildScheduledEvent
from .channel import DMChannel, GroupDMChannel
@ -49,7 +49,15 @@ from .entitlements import Entitlement, GatewayGift
from .library import LibraryApplication
PresenceUpdateEvent = PartialPresenceUpdate
class UserPresenceUpdateEvent(TypedDict):
user: PartialUser
status: StatusType
activities: List[Activity]
client_status: ClientStatus
last_modified: int
PresenceUpdateEvent = Union[PartialPresenceUpdate, UserPresenceUpdateEvent]
class Gateway(TypedDict):
@ -61,11 +69,26 @@ class ShardInfo(TypedDict):
shard_count: int
class ClientInfo(TypedDict):
version: int
os: str
client: str
class Session(TypedDict):
session_id: str
active: NotRequired[bool]
client_info: ClientInfo
status: StatusType
activities: List[Activity]
class ResumedEvent(TypedDict):
_trace: List[str]
class ReadyEvent(ResumedEvent):
_trace: List[str]
api_code_version: int
analytics_token: str
auth_session_id_hash: str
@ -78,9 +101,9 @@ class ReadyEvent(ResumedEvent):
merged_members: List[List[MemberWithUser]]
pending_payments: NotRequired[List[Payment]]
private_channels: List[Union[DMChannel, GroupDMChannel]]
relationships: List[dict]
relationships: List[Relationship]
required_action: NotRequired[str]
sessions: List[dict]
sessions: List[Session]
session_id: str
session_type: str
shard: NotRequired[ShardInfo]
@ -93,8 +116,8 @@ class ReadyEvent(ResumedEvent):
class MergedPresences(TypedDict):
friends: List[PresenceUpdateEvent]
guilds: List[List[PresenceUpdateEvent]]
friends: List[UserPresenceUpdateEvent]
guilds: List[List[PartialPresenceUpdate]]
class ReadySupplementalEvent(TypedDict):
@ -108,6 +131,9 @@ NoEvent = Literal[None]
MessageCreateEvent = Message
SessionsReplaceEvent = List[Session]
class MessageDeleteEvent(TypedDict):
id: Snowflake
channel_id: Snowflake
@ -298,7 +324,7 @@ class GuildMembersChunkEvent(TypedDict):
chunk_index: int
chunk_count: int
not_found: NotRequired[List[Snowflake]]
presences: NotRequired[List[PresenceUpdateEvent]]
presences: NotRequired[List[PartialPresenceUpdate]]
nonce: NotRequired[str]
@ -413,3 +439,13 @@ GiftCreateEvent = GiftUpdateEvent = GatewayGift
EntitlementEvent = Entitlement
LibraryApplicationUpdateEvent = LibraryApplication
class RelationshipAddEvent(Relationship):
should_notify: NotRequired[bool]
class RelationshipEvent(TypedDict):
id: Snowflake
type: RelationshipType
nickname: Optional[str]

11
discord/types/user.py

@ -110,3 +110,14 @@ class ConnectionAccessToken(TypedDict):
class ConnectionAuthorization(TypedDict):
url: str
RelationshipType = Literal[-1, 0, 1, 2, 3, 4, 5, 6]
class Relationship(TypedDict):
id: Snowflake
type: RelationshipType
user: PartialUser
nickname: Optional[str]
since: NotRequired[str]

61
discord/user.py

@ -24,7 +24,7 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations
from typing import Any, Callable, Dict, List, Optional, Tuple, TYPE_CHECKING, Union
from typing import Any, Callable, Dict, Optional, Tuple, TYPE_CHECKING, Union
import discord.abc
from .asset import Asset
@ -613,21 +613,6 @@ class ClientUser(BaseUser):
self.desktop: bool = data.get('desktop', False)
self.mobile: bool = data.get('mobile', False)
def get_relationship(self, user_id: int) -> Optional[Relationship]:
"""Retrieves the :class:`Relationship` if applicable.
Parameters
-----------
user_id: :class:`int`
The user ID to check if we have a relationship with them.
Returns
--------
Optional[:class:`Relationship`]
The relationship if available or ``None``.
"""
return self._state._relationships.get(user_id)
@property
def locale(self) -> Locale:
""":class:`Locale`: The IETF language tag used to identify the language the user is using."""
@ -638,33 +623,6 @@ class ClientUser(BaseUser):
"""Indicates if the user is a premium user (i.e. has Discord Nitro)."""
return self.premium_type is not None
@property
def relationships(self) -> List[Relationship]:
"""List[:class:`Relationship`]: Returns all the relationships that the user has.
.. versionchanged:: 2.0
This now returns a :class:`Relationship`.
"""
return list(self._state._relationships.values())
@property
def friends(self) -> List[Relationship]:
r"""List[:class:`Relationship`]: Returns all the users that the user is friends with.
.. versionchanged:: 2.0
This now returns a :class:`Relationship`.
"""
return [r for r in self._state._relationships.values() if r.type is RelationshipType.friend]
@property
def blocked(self) -> List[Relationship]:
r"""List[:class:`Relationship`]: Returns all the users that the user has blocked.
.. versionchanged:: 2.0
This now returns a :class:`Relationship`.
"""
return [r for r in self._state._relationships.values() if r.type is RelationshipType.blocked]
@property
def settings(self) -> Optional[UserSettings]:
"""Optional[:class:`UserSettings`]: Returns the user's settings.
@ -988,6 +946,19 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):
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
def _update_self(self, user: Union[PartialUserPayload, Tuple[()]]) -> Optional[Tuple[User, User]]:
if len(user) == 0 or len(user) <= 1: # Done because of typing
return
original = (self.name, self._avatar, self.discriminator, self._public_flags)
# These keys seem to always be available
modified = (user['username'], user.get('avatar'), user['discriminator'], user.get('public_flags', 0))
if original != modified:
to_return = User._copy(self)
self.name, self._avatar, self.discriminator, self._public_flags = modified
# Signal to dispatch user_update
return to_return, self
async def _get_channel(self) -> DMChannel:
ch = await self.create_dm()
return ch
@ -1009,7 +980,7 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):
@property
def relationship(self) -> Optional[Relationship]:
"""Optional[:class:`Relationship`]: Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise."""
return self._state.user.get_relationship(self.id) # type: ignore # user is always present when logged in
return self._state._relationships.get(self.id)
@copy_doc(discord.abc.Connectable.connect)
async def connect(
@ -1105,7 +1076,7 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):
"""
await self._state.http.remove_relationship(self.id, action=RelationshipAction.unfriend)
async def send_friend_request(self) -> None: # TODO: maybe return relationship
async def send_friend_request(self) -> None:
"""|coro|
Sends the user a friend request.

20
discord/utils.py

@ -284,6 +284,26 @@ def parse_date(date: Optional[str]) -> Optional[datetime.date]:
return None
@overload
def parse_timestamp(timestamp: None) -> None:
...
@overload
def parse_timestamp(timestamp: float) -> datetime.datetime:
...
@overload
def parse_timestamp(timestamp: Optional[float]) -> Optional[datetime.datetime]:
...
def parse_timestamp(timestamp: Optional[float]) -> Optional[datetime.datetime]:
if timestamp:
return datetime.datetime.fromtimestamp(timestamp / 1000.0, tz=datetime.timezone.utc)
def copy_doc(original: Callable) -> Callable[[T], T]:
def decorator(overridden: T) -> T:
overridden.__doc__ = original.__doc__

Loading…
Cancel
Save