diff --git a/discord/activity.py b/discord/activity.py index 0f0a11c06..43ac9d2d0 100644 --- a/discord/activity.py +++ b/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'' - 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'' +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'' + + 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 diff --git a/discord/client.py b/discord/client.py index f1c98406e..452a62dd6 100644 --- a/discord/client.py +++ b/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 is None: - status = getattr(state.settings, 'status', None) or Status.online - self.loop.create_task(self.change_presence(activities=activities, status=status)) # type: ignore + 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)) @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| diff --git a/discord/enums.py b/discord/enums.py index 2c8e4486e..d9ef7f35a 100644 --- a/discord/enums.py +++ b/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 diff --git a/discord/gateway.py b/discord/gateway.py index e30dc7a0f..f29180cb1 100644 --- a/discord/gateway.py +++ b/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: diff --git a/discord/guild.py b/discord/guild.py index 792913670..3600e2fae 100644 --- a/discord/guild.py +++ b/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]: diff --git a/discord/http.py b/discord/http.py index 2f294ad76..c636ef172 100644 --- a/discord/http.py +++ b/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') diff --git a/discord/member.py b/discord/member.py index 5a81ffd7f..ce3a913cb 100644 --- a/discord/member.py +++ b/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. diff --git a/discord/relationship.py b/discord/relationship.py index db20f7b76..7401f1d0f 100644 --- a/discord/relationship.py +++ b/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.user: User - if (user := data.get('user')) is not None: - self.user = self._state.store_user(user) - elif self.user: - return - 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 + self.since: Optional[datetime] = parse_time(data.get('since')) + + 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'' - 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 diff --git a/discord/state.py b/discord/state.py index 95438a279..c50238b63 100644 --- a/discord/state.py +++ b/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) - if member is None: - _log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding.', member_id) - return + 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.', user_id) + return + + user_update = member._user._update_self(user) + if old_presence != presence: + old_member = Member._copy(member) + old_member._presence = old_presence + self.dispatch('presence_update', old_member, member) - old_member = Member._copy(member) - user_update = member._presence_update(data=data, user=user) 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 - # Force an update on the inner user if necessary - user_update = member._update_inner_user(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) + if member is not None: + # Force an update on the inner user if necessary + user_update = member._user._update_self(user) + if user_update: + self.dispatch('user_update', user_update[0], user_update[1]) + 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 should_parse and ( - old_member._client_status != member._client_status or old_member._activities != member._activities - ): - self.dispatch('presence_update', old_member, member) - - user_update = member._update_inner_user(user) - if should_parse and user_update: + 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) + + old_member._presence = old_presence + if should_parse and old_presence != presence: + self.dispatch('presence_update', old_member, member) + + 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 should_parse and ( - old_member._client_status != member._client_status or old_member._activities != member._activities - ): - self.dispatch('presence_update', old_member, member) - - user_update = member._update_inner_user(user) - if should_parse and user_update: + 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) + + old_member._presence = old_presence + if should_parse and old_presence != presence: + self.dispatch('presence_update', old_member, member) + + 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 diff --git a/discord/types/activity.py b/discord/types/activity.py index 5902cce8a..562b3cccf 100644 --- a/discord/types/activity.py +++ b/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] diff --git a/discord/types/gateway.py b/discord/types/gateway.py index f1b64328d..686244660 100644 --- a/discord/types/gateway.py +++ b/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] diff --git a/discord/types/user.py b/discord/types/user.py index 87a90cc34..4b849dde0 100644 --- a/discord/types/user.py +++ b/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] diff --git a/discord/user.py b/discord/user.py index cef517253..0597ce3d4 100644 --- a/discord/user.py +++ b/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. diff --git a/discord/utils.py b/discord/utils.py index 94a5883df..2de4122f4 100644 --- a/discord/utils.py +++ b/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__