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 from __future__ import annotations
import datetime 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 .asset import Asset
from .enums import ActivityType, try_enum
from .colour import Colour from .colour import Colour
from .enums import ActivityType, ClientType, OperatingSystem, Status, try_enum
from .partial_emoji import PartialEmoji from .partial_emoji import PartialEmoji
from .utils import _get_as_snowflake from .utils import _get_as_snowflake
@ -91,15 +91,17 @@ t.ActivityFlags = {
""" """
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self
from .state import ConnectionState
from .types.activity import ( from .types.activity import (
Activity as ActivityPayload, Activity as ActivityPayload,
ActivityTimestamps,
ActivityParty,
ActivityAssets, ActivityAssets,
ActivityButton, ActivityButton,
ActivityParty,
ActivityTimestamps,
) )
from .types.gateway import Session as SessionPayload
from .state import ConnectionState
class BaseActivity: class BaseActivity:
@ -270,7 +272,7 @@ class Activity(BaseActivity):
def __ne__(self, other): def __ne__(self, other):
return not self.__eq__(other) return not self.__eq__(other)
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> ActivityPayload:
ret: Dict[str, Any] = {} ret: Dict[str, Any] = {}
for attr in self.__slots__: for attr in self.__slots__:
value = getattr(self, attr, None) value = getattr(self, attr, None)
@ -284,7 +286,7 @@ class Activity(BaseActivity):
ret['type'] = int(self.type) ret['type'] = int(self.type)
if self.emoji: if self.emoji:
ret['emoji'] = self.emoji.to_dict() ret['emoji'] = self.emoji.to_dict()
return ret return ret # type: ignore
@property @property
def start(self) -> Optional[datetime.datetime]: def start(self) -> Optional[datetime.datetime]:
@ -420,7 +422,7 @@ class Game(BaseActivity):
def __repr__(self) -> str: def __repr__(self) -> str:
return f'<Game name={self.name!r}>' return f'<Game name={self.name!r}>'
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> ActivityPayload:
timestamps: Dict[str, Any] = {} timestamps: Dict[str, Any] = {}
if self._start: if self._start:
timestamps['start'] = self._start timestamps['start'] = self._start
@ -432,7 +434,7 @@ class Game(BaseActivity):
return { return {
'type': ActivityType.playing.value, 'type': ActivityType.playing.value,
'name': str(self.name), 'name': str(self.name),
'timestamps': timestamps 'timestamps': timestamps # type: ignore
} }
# fmt: on # fmt: on
@ -531,7 +533,7 @@ class Streaming(BaseActivity):
else: else:
return name[7:] if name[:7] == 'twitch:' else None return name[7:] if name[:7] == 'twitch:' else None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> ActivityPayload:
# fmt: off # fmt: off
ret: Dict[str, Any] = { ret: Dict[str, Any] = {
'type': ActivityType.streaming.value, 'type': ActivityType.streaming.value,
@ -542,7 +544,7 @@ class Streaming(BaseActivity):
# fmt: on # fmt: on
if self.details: if self.details:
ret['details'] = self.details ret['details'] = self.details
return ret return ret # type: ignore
def __eq__(self, other: object) -> bool: def __eq__(self, other: object) -> bool:
return isinstance(other, Streaming) and other.name == self.name and other.url == self.url 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`""" There is an alias for this named :attr:`colour`"""
return self.colour return self.colour
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> ActivityPayload:
return { return {
'flags': 48, # SYNC | PLAY 'flags': 48, # SYNC | PLAY
'name': 'Spotify', 'name': 'Spotify',
@ -631,7 +633,7 @@ class Spotify:
'timestamps': self._timestamps, 'timestamps': self._timestamps,
'details': self._details, 'details': self._details,
'state': self._state, 'state': self._state,
} } # type: ignore
@property @property
def name(self) -> str: def name(self) -> str:
@ -801,7 +803,7 @@ class CustomActivity(BaseActivity):
""" """
return ActivityType.custom return ActivityType.custom
def to_dict(self) -> Dict[str, Union[str, int]]: def to_dict(self) -> ActivityPayload:
o = { o = {
'type': ActivityType.custom.value, 'type': ActivityType.custom.value,
'state': self.name, 'state': self.name,
@ -809,7 +811,7 @@ class CustomActivity(BaseActivity):
} }
if self.emoji: if self.emoji:
o['emoji'] = self.emoji.to_dict() o['emoji'] = self.emoji.to_dict()
return o return o # type: ignore
def to_settings_dict(self) -> Dict[str, Any]: def to_settings_dict(self) -> Dict[str, Any]:
o: Dict[str, Optional[Union[str, int]]] = {} 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}>' 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] 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]: def create_activity(data: Optional[ActivityPayload], state: ConnectionState) -> Optional[ActivityTypes]:
if not data: if not data:
return None return None

255
discord/client.py

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

28
discord/enums.py

@ -88,7 +88,7 @@ __all__ = (
'EmbeddedActivityPlatform', 'EmbeddedActivityPlatform',
'EmbeddedActivityOrientation', 'EmbeddedActivityOrientation',
'ConnectionType', 'ConnectionType',
'ConnectionLinkType', 'ClientType',
'PaymentSourceType', 'PaymentSourceType',
'PaymentGateway', 'PaymentGateway',
'SubscriptionType', 'SubscriptionType',
@ -397,6 +397,8 @@ class RelationshipType(Enum):
blocked = 2 blocked = 2
incoming_request = 3 incoming_request = 3
outgoing_request = 4 outgoing_request = 4
implicit = 5
suggestion = 6
class NotificationLevel(Enum, comparable=True): class NotificationLevel(Enum, comparable=True):
@ -1039,10 +1041,11 @@ class ConnectionType(Enum):
return self.value return self.value
class ConnectionLinkType(Enum): class ClientType(Enum):
web = 'web' web = 'web'
mobile = 'mobile' mobile = 'mobile'
desktop = 'desktop' desktop = 'desktop'
unknown = 'unknown'
def __str__(self) -> str: def __str__(self) -> str:
return self.value return self.value
@ -1258,11 +1261,30 @@ class SKUGenre(Enum):
return self.value 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): class OperatingSystem(Enum):
windows = 1 windows = 1
mac = 2 macos = 2
linux = 3 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): class ContentRatingAgency(Enum):
esrb = 1 esrb = 1

40
discord/gateway.py

@ -37,7 +37,7 @@ from typing import Any, Callable, Coroutine, Dict, List, TYPE_CHECKING, NamedTup
import aiohttp import aiohttp
from . import utils from . import utils
from .activity import BaseActivity from .activity import BaseActivity, Spotify
from .enums import SpeakingState from .enums import SpeakingState
from .errors import ConnectionClosed from .errors import ConnectionClosed
@ -54,6 +54,7 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from .activity import ActivityTypes
from .client import Client from .client import Client
from .enums import Status from .enums import Status
from .state import ConnectionState from .state import ConnectionState
@ -427,18 +428,36 @@ class DiscordWebSocket:
async def identify(self) -> None: async def identify(self) -> None:
"""Sends the IDENTIFY packet.""" """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 = { payload = {
'op': self.IDENTIFY, 'op': self.IDENTIFY,
'd': { 'd': {
'token': self.token, 'token': self.token,
'capabilities': 509, 'capabilities': 509,
'properties': self._super_properties, 'properties': self._super_properties,
'presence': { 'presence': presence,
'status': 'online',
'since': 0,
'activities': [],
'afk': False,
},
'compress': False, 'compress': False,
'client_state': { 'client_state': {
'guild_hashes': {}, 'guild_hashes': {},
@ -450,6 +469,7 @@ class DiscordWebSocket:
} }
if not self._zlib_enabled: if not self._zlib_enabled:
# We require at least one form of compression
payload['d']['compress'] = True payload['d']['compress'] = True
await self.call_hooks('before_identify', initial=self._initial_identify) await self.call_hooks('before_identify', initial=self._initial_identify)
@ -658,13 +678,13 @@ class DiscordWebSocket:
async def change_presence( async def change_presence(
self, self,
*, *,
activities: Optional[List[BaseActivity]] = None, activities: Optional[List[ActivityTypes]] = None,
status: Optional[Status] = None, status: Optional[Status] = None,
since: float = 0.0, since: int = 0,
afk: bool = False, afk: bool = False,
) -> None: ) -> None:
if activities is not 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') raise TypeError('activity must derive from BaseActivity')
activities_data = [activity.to_dict() for activity in activities] activities_data = [activity.to_dict() for activity in activities]
else: else:

10
discord/guild.py

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

35
discord/http.py

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

127
discord/member.py

@ -36,7 +36,6 @@ from . import utils
from .asset import Asset from .asset import Asset
from .utils import MISSING from .utils import MISSING
from .user import BaseUser, User, _UserTag from .user import BaseUser, User, _UserTag
from .activity import create_activity, ActivityTypes
from .permissions import Permissions from .permissions import Permissions
from .enums import RelationshipAction, Status, try_enum from .enums import RelationshipAction, Status, try_enum
from .errors import ClientException from .errors import ClientException
@ -51,12 +50,12 @@ __all__ = (
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self from typing_extensions import Self
from .activity import ActivityTypes
from .asset import Asset from .asset import Asset
from .channel import DMChannel, VoiceChannel, StageChannel, GroupChannel from .channel import DMChannel, VoiceChannel, StageChannel, GroupChannel
from .flags import PublicUserFlags from .flags import PublicUserFlags
from .guild import Guild from .guild import Guild
from .types.activity import ( from .types.activity import (
ClientStatus as ClientStatusPayload,
PartialPresenceUpdate, PartialPresenceUpdate,
) )
from .types.member import ( from .types.member import (
@ -67,7 +66,7 @@ if TYPE_CHECKING:
from .types.gateway import GuildMemberUpdateEvent from .types.gateway import GuildMemberUpdateEvent
from .types.user import PartialUser as PartialUserPayload from .types.user import PartialUser as PartialUserPayload
from .abc import Snowflake from .abc import Snowflake
from .state import ConnectionState from .state import ConnectionState, Presence
from .message import Message from .message import Message
from .role import Role from .role import Role
from .types.voice import ( from .types.voice import (
@ -171,48 +170,6 @@ class VoiceState:
return f'<{self.__class__.__name__} {inner}>' 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]: def flatten_user(cls: Any) -> Type[Member]:
for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()):
# Ignore private/special methods # Ignore private/special methods
@ -308,12 +265,11 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
'_roles', '_roles',
'joined_at', 'joined_at',
'premium_since', 'premium_since',
'_activities',
'guild', 'guild',
'pending', 'pending',
'nick', 'nick',
'timed_out_until', 'timed_out_until',
'_client_status', '_presence',
'_user', '_user',
'_state', '_state',
'_avatar', '_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.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.premium_since: Optional[datetime.datetime] = utils.parse_time(data.get('premium_since'))
self._roles: utils.SnowflakeList = utils.SnowflakeList(map(int, data['roles'])) self._roles: utils.SnowflakeList = utils.SnowflakeList(map(int, data['roles']))
self._client_status: _ClientStatus = _ClientStatus() self._presence: Optional[Presence] = None
self._activities: Tuple[ActivityTypes, ...] = tuple()
self.nick: Optional[str] = data.get('nick', None) self.nick: Optional[str] = data.get('nick', None)
self.pending: bool = data.get('pending', False) self.pending: bool = data.get('pending', False)
self._avatar: Optional[str] = data.get('avatar') 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._roles = utils.SnowflakeList(member._roles, is_sorted=True)
self.joined_at = member.joined_at self.joined_at = member.joined_at
self.premium_since = member.premium_since self.premium_since = member.premium_since
self._activities = member._activities self._presence = member._presence
self._client_status = _ClientStatus._copy(member._client_status)
self.guild = member.guild self.guild = member.guild
self.nick = member.nick self.nick = member.nick
self.pending = member.pending 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): if any(getattr(self, attr) != getattr(old, attr) for attr in attrs):
return old return old
def _presence_update(self, data: PartialPresenceUpdate, user: PartialUserPayload) -> Optional[Tuple[User, User]]: def _presence_update(
if self._self: self, data: PartialPresenceUpdate, user: Union[PartialUserPayload, Tuple[()]]
return ) -> Optional[Tuple[User, User]]:
self._presence = self._state.create_presence(data)
self._activities = tuple(create_activity(d, self._state) for d in data['activities']) return self._user._update_self(user)
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 _get_voice_client_key(self) -> Tuple[int, str]: 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 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() ch = await self.create_dm()
return ch 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 @property
def status(self) -> Status: def status(self) -> Status:
""":class:`Status`: The member's overall status. If the value is unknown, then it will be a :class:`str` instead.""" """:class:`Status`: The member's overall status."""
client_status = self._client_status if not self._self else self._state.client._client_status return try_enum(Status, self.presence.client_status.status)
return try_enum(Status, client_status._status)
@property @property
def raw_status(self) -> str: def raw_status(self) -> str:
@ -483,37 +426,26 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
.. versionadded:: 1.5 .. versionadded:: 1.5
""" """
client_status = self._client_status if not self._self else self._state.client._client_status return self.presence.client_status.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)
@property @property
def mobile_status(self) -> Status: def mobile_status(self) -> Status:
""":class:`Status`: The member's status on a mobile device, if applicable.""" """: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, self.presence.client_status.mobile or 'offline')
return try_enum(Status, client_status.mobile or 'offline')
@property @property
def desktop_status(self) -> Status: def desktop_status(self) -> Status:
""":class:`Status`: The member's status on the desktop client, if applicable.""" """: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, self.presence.client_status.desktop or 'offline')
return try_enum(Status, client_status.desktop or 'offline')
@property @property
def web_status(self) -> Status: def web_status(self) -> Status:
""":class:`Status`: The member's status on the web client, if applicable.""" """: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, self.presence.client_status.web or 'offline')
return try_enum(Status, client_status.web or 'offline')
def is_on_mobile(self) -> bool: 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 a member is active on a mobile device."""
client_status = self._client_status if not self._self else self._state.client._client_status return self.presence.client_status.mobile is not None
return client_status.mobile is not None
@property @property
def colour(self) -> Colour: 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 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 if they are listening to a song with a title longer
than 128 characters. See :issue:`1738` for more information. than 128 characters. See :issue:`1738` for more information.
""" """
if self._self: return self.presence.activities
return self._state.client.activities
return self._activities
@property @property
def activity(self) -> Optional[ActivityTypes]: 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.""" """Optional[:class:`VoiceState`]: Returns the member's current voice state."""
return self.guild._voice_state_for(self._user.id) 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( async def ban(
self, self,
*, *,
@ -773,7 +698,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
.. note:: .. 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 represents the image being uploaded. If this is done through a file
then the file must be opened via ``open('some_filename', 'rb')`` and 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()``. 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 http = self._state.http
guild_id = self.guild.id guild_id = self.guild.id
me = self._self me = self._user.id == self._state.self_id
payload: Dict[str, Any] = {} payload: Dict[str, Any] = {}
data = None data = None
@ -1122,7 +1047,7 @@ class Member(discord.abc.Messageable, discord.abc.Connectable, _UserTag):
return utils.utcnow() < self.timed_out_until return utils.utcnow() < self.timed_out_until
return False 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| """|coro|
Sends the member a friend request. Sends the member a friend request.

232
discord/relationship.py

@ -24,15 +24,21 @@ DEALINGS IN THE SOFTWARE.
from __future__ import annotations from __future__ import annotations
import copy from typing import TYPE_CHECKING, Optional, Tuple, Union
from typing import Optional, TYPE_CHECKING
from .enums import RelationshipAction, RelationshipType, try_enum from .enums import RelationshipAction, RelationshipType, Status, try_enum
from .mixins import Hashable
from .object import Object from .object import Object
from .utils import MISSING from .utils import MISSING, parse_time
if TYPE_CHECKING: 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 from .user import User
# fmt: off # fmt: off
@ -42,7 +48,7 @@ __all__ = (
# fmt: on # fmt: on
class Relationship: class Relationship(Hashable):
"""Represents a relationship in Discord. """Represents a relationship in Discord.
A relationship is like a friendship, a person who is blocked, etc. A relationship is like a friendship, a person who is blocked, etc.
@ -63,49 +69,194 @@ class Relationship:
Attributes Attributes
----------- -----------
user: :class:`User`
The user you have the relationship with.
type: :class:`RelationshipType`
The type of relationship you have.
nick: Optional[:class:`str`] nick: Optional[:class:`str`]
The user's friend nickname (if applicable). The user's friend nickname (if applicable).
.. versionadded:: 1.9
.. versionchanged:: 2.0 .. versionchanged:: 2.0
Renamed ``nickname`` to :attr:`nick`. Renamed ``nickname`` to :attr:`nick`.
user: :class:`User` since: Optional[:class:`datetime.datetime`]
The user you have the relationship with. When the relationship was created.
type: :class:`RelationshipType` Only available for type :class:`RelationshipType.incoming_request`.
The type of relationship you have.
.. 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._state = state
self._presence: Optional[Presence] = None
self._update(data) 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.type: RelationshipType = try_enum(RelationshipType, data['type'])
self.nick: Optional[str] = data.get('nickname') self.nick: Optional[str] = data.get('nickname')
self.since: Optional[datetime] = parse_time(data.get('since'))
self.user: User if not getattr(self, 'user', None):
if (user := data.get('user')) is not None: if 'user' in data:
self.user = self._state.store_user(user) self.user = self._state.store_user(data['user']) # type: ignore
elif self.user:
return
else: else:
user_id = int(data['id']) 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.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: def __repr__(self) -> str:
return f'<Relationship user={self.user!r} type={self.type!r} nick={self.nick!r}>' return f'<Relationship user={self.user!r} type={self.type!r} nick={self.nick!r}>'
def __eq__(self, other: object) -> bool: @property
return isinstance(other, Relationship) and other.user.id == self.user.id 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.
.. versionadded:: 2.0
.. 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
def __ne__(self, other: object) -> bool: .. note::
if isinstance(other, Relationship):
return other.user.id != self.user.id
return True
def __hash__(self) -> int: This is only reliably provided for type :class:`RelationshipType.friend`.
return self.user.__hash__() """
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: async def delete(self) -> None:
"""|coro| """|coro|
@ -113,7 +264,7 @@ class Relationship:
Deletes the relationship. Deletes the relationship.
Depending on the type, this could mean unfriending or unblocking the user, 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 Raises
------ ------
@ -132,29 +283,20 @@ class Relationship:
await self._state.http.remove_relationship(self.user.id, action=action) await self._state.http.remove_relationship(self.user.id, action=action)
async def accept(self) -> Relationship: async def accept(self) -> None:
"""|coro| """|coro|
Accepts the relationship request. Only applicable for Accepts the relationship request. Only applicable for
type :class:`RelationshipType.incoming_request`. type :class:`RelationshipType.incoming_request`.
.. versionchanged:: 2.0
Changed the return type to :class:`Relationship`.
Raises Raises
------- -------
HTTPException HTTPException
Accepting the relationship failed. Accepting the relationship failed.
Returns
-------
:class:`Relationship`
The new relationship.
""" """
data = await self._state.http.add_relationship(self.user.id, action=RelationshipAction.accept_request) await self._state.http.add_relationship(self.user.id, action=RelationshipAction.accept_request)
return Relationship(state=self._state, data=data)
async def edit(self, nick: Optional[str] = MISSING) -> Relationship: async def edit(self, nick: Optional[str] = MISSING) -> None:
"""|coro| """|coro|
Edits the relationship. Edits the relationship.
@ -174,19 +316,9 @@ class Relationship:
------- -------
HTTPException HTTPException
Changing the nickname failed. Changing the nickname failed.
Returns
-------
:class:`Relationship`
The new relationship.
""" """
payload = {} payload = {}
if nick is not MISSING: if nick is not MISSING:
payload['nick'] = nick payload['nickname'] = nick
await self._state.http.edit_relationship(self.user.id, **payload) 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 .errors import ClientException, InvalidData, NotFound
from .guild import CommandCounts, Guild from .guild import CommandCounts, Guild
from .activity import BaseActivity from .activity import BaseActivity, create_activity, Session
from .user import User, ClientUser from .user import User, ClientUser
from .emoji import Emoji from .emoji import Emoji
from .mentions import AllowedMentions from .mentions import AllowedMentions
@ -62,7 +62,15 @@ from .raw_models import *
from .member import Member from .member import Member
from .relationship import Relationship from .relationship import Relationship
from .role import Role 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 . import utils
from .flags import MemberCacheFlags from .flags import MemberCacheFlags
from .invite import Invite from .invite import Invite
@ -74,7 +82,6 @@ from .sticker import GuildSticker
from .settings import UserSettings, GuildSettings, ChannelSettings, TrackingSettings from .settings import UserSettings, GuildSettings, ChannelSettings, TrackingSettings
from .interactions import Interaction from .interactions import Interaction
from .permissions import Permissions, PermissionOverwrite from .permissions import Permissions, PermissionOverwrite
from .member import _ClientStatus
from .modal import Modal from .modal import Modal
from .member import VoiceState from .member import VoiceState
from .appinfo import IntegrationApplication, PartialApplication, Achievement from .appinfo import IntegrationApplication, PartialApplication, Achievement
@ -85,7 +92,10 @@ from .guild_premium import PremiumGuildSubscriptionSlot
from .library import LibraryApplication from .library import LibraryApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from typing_extensions import Self
from .abc import PrivateChannel, Snowflake as abcSnowflake from .abc import PrivateChannel, Snowflake as abcSnowflake
from .activity import ActivityTypes
from .message import MessageableChannel from .message import MessageableChannel
from .guild import GuildChannel, VocalGuildChannel from .guild import GuildChannel, VocalGuildChannel
from .http import HTTPClient from .http import HTTPClient
@ -105,6 +115,7 @@ if TYPE_CHECKING:
from .types.message import Message as MessagePayload, PartialMessage as PartialMessagePayload from .types.message import Message as MessagePayload, PartialMessage as PartialMessagePayload
from .types import gateway as gw from .types import gateway as gw
from .types.voice import GuildVoiceState from .types.voice import GuildVoiceState
from .types.activity import ClientStatus as ClientStatusPayload
T = TypeVar('T') T = TypeVar('T')
Channel = Union[GuildChannel, VocalGuildChannel, PrivateChannel, PartialMessageable, ForumChannel] Channel = Union[GuildChannel, VocalGuildChannel, PrivateChannel, PartialMessageable, ForumChannel]
@ -378,6 +389,115 @@ class MemberSidebar:
self.guild._chunked = True 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]: async def logging_coroutine(coroutine: Coroutine[Any, Any, T], *, info: str) -> Optional[T]:
try: try:
await coroutine await coroutine
@ -490,6 +610,10 @@ class ConnectionState:
self._private_channels: Dict[int, PrivateChannel] = {} self._private_channels: Dict[int, PrivateChannel] = {}
self._private_channels_by_user: Dict[int, DMChannel] = {} 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: if self.max_messages is not None:
self._messages: Optional[Deque[Message]] = deque(maxlen=self.max_messages) self._messages: Optional[Deque[Message]] = deque(maxlen=self.max_messages)
else: else:
@ -602,15 +726,7 @@ class ConnectionState:
# this way is 300% faster than `dict.setdefault`. # this way is 300% faster than `dict.setdefault`.
user_id = int(data['id']) user_id = int(data['id'])
try: try:
user = self._users[user_id] return 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
except KeyError: except KeyError:
user = User(state=self, data=data) user = User(state=self, data=data)
if user.discriminator != '0000': if user.discriminator != '0000':
@ -867,9 +983,6 @@ class ConnectionState:
if member: if member:
voice_state['member'] = 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 parsing
self.user = user = ClientUser(state=self, data=data['user']) self.user = user = ClientUser(state=self, data=data['user'])
self._users[user.id] = user # type: ignore self._users[user.id] = user # type: ignore
@ -889,6 +1002,11 @@ class ConnectionState:
relationship['user'] = temp_users[int(relationship.pop('user_id'))] relationship['user'] = temp_users[int(relationship.pop('user_id'))]
self._relationships[r_id] = Relationship(state=self, data=relationship) 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 # Private channel parsing
for pm in data.get('private_channels', []) + extra_data.get('lazy_private_channels', []): for pm in data.get('private_channels', []) + extra_data.get('lazy_private_channels', []):
factory, _ = _private_channel_factory(pm['type']) factory, _ = _private_channel_factory(pm['type'])
@ -915,7 +1033,7 @@ class ConnectionState:
self.parse_user_required_action_update(data) self.parse_user_required_action_update(data)
if 'sessions' in 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: if 'auth_token' in data:
self.http._token(data['auth_token']) self.http._token(data['auth_token'])
@ -1055,32 +1173,55 @@ class ConnectionState:
if reaction: if reaction:
self.dispatch('reaction_clear_emoji', 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: for presence in data:
self.parse_presence_update(presence) self.parse_presence_update(presence)
def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None:
guild_id = utils._get_as_snowflake(data, 'guild_id') guild_id = utils._get_as_snowflake(data, 'guild_id')
guild = self._get_guild(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) _log.debug('PRESENCE_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id)
return return
user = data['user'] user = data['user']
member_id = int(user['id']) user_id = int(user['id'])
member = guild.get_member(member_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: 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 return
user_update = member._user._update_self(user)
if old_presence != presence:
old_member = Member._copy(member) 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: if user_update:
self.dispatch('user_update', user_update[0], user_update[1]) 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: def parse_user_update(self, data: gw.UserUpdateEvent) -> None:
if self.user: if self.user:
self.user._full_update(data) self.user._full_update(data)
@ -1191,46 +1332,52 @@ class ConnectionState:
entry = LibraryApplication(state=self, data=data) entry = LibraryApplication(state=self, data=data)
self.dispatch('library_application_update', entry) self.dispatch('library_application_update', entry)
def parse_sessions_replace(self, data: List[Dict[str, Any]]) -> None: def parse_sessions_replace(self, payload: gw.SessionsReplaceEvent, *, from_ready: bool = False) -> None:
overall = MISSING data = {s['session_id']: s for s in payload}
this = MISSING
client_status = _ClientStatus() for session_id, session in data.items():
client_activities = {} existing = self._sessions.get(session_id)
statuses = {} if existing is not None:
old = copy.copy(existing)
if len(data) == 1: existing._update(session)
overall = this = data[0] if not from_ready and (
old.status != existing.status or old.active != existing.active or old.activities != existing.activities
# Duplicates will be overwritten I guess ):
for session in data: self.dispatch('session_update', old, existing)
if session['session_id'] == 'all': else:
overall = session existing = Session(state=self, data=session)
data.remove(session) self._sessions[session_id] = existing
continue if not from_ready:
elif session['session_id'] == self.session_id: self.dispatch('session_create', existing)
this = session
continue old_all = None
key = session['client_info']['client'] if not from_ready:
statuses[key] = session['status'] removed_sessions = [s for s in self._sessions if s not in data]
client_activities[key] = tuple(session['activities']) for session_id in removed_sessions:
if session_id == 'all':
if overall is MISSING and this is MISSING: old_all = self._sessions.pop('all')
_log.debug('SESSIONS_REPLACE has weird data: %s.', data) else:
return # ._. session = self._sessions.pop(session_id)
elif overall is MISSING: self.dispatch('session_delete', session)
overall = this
elif this is MISSING: if 'all' not in self._sessions:
this = overall # The "all" session does not always exist...
# This usually happens if there is only a single session (us)
client_status._update(overall['status'], statuses) # type: ignore # In the case it is "removed", we try to update the old one
client_status._this = this['status'] # Else, we create a new one with fake data
client_activities[None] = tuple(overall['activities']) if len(data) > 1:
client_activities['this'] = tuple(this['activities']) # We have more than one session, this should not happen
fake = data[self.session_id] # type: ignore
client = self.client else:
client._client_status = client_status fake = list(data.values())[0]
client._client_activities = client_activities if old_all is not None:
client._session_count = len(data) 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: def parse_entitlement_create(self, data: gw.EntitlementEvent) -> None:
entitlement = Entitlement(state=self, data=data) entitlement = Entitlement(state=self, data=data)
@ -1437,7 +1584,8 @@ class ConnectionState:
new_threads = {} new_threads = {}
for d in data.get('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) old = thread._update(d)
if old is not None: if old is not None:
self.dispatch('thread_update', old, thread) # Honestly not sure if this is right 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: 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) 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: guild._add_member(member)
member._presence_update(presence, tuple()) # type: ignore
def parse_guild_member_remove(self, data: gw.GuildMemberRemoveEvent) -> None: def parse_guild_member_remove(self, data: gw.GuildMemberRemoveEvent) -> None:
guild = self._get_guild(int(data['guild_id'])) guild = self._get_guild(int(data['guild_id']))
if guild is not None: if guild is not None:
if guild._member_count is not None:
guild._member_count -= 1
user_id = int(data['user']['id']) user_id = int(data['user']['id'])
member = guild.get_member(user_id) member = guild.get_member(user_id)
if member is not None: if member is not None:
@ -1560,25 +1706,24 @@ class ConnectionState:
member = guild.get_member(user_id) member = guild.get_member(user_id)
if member is not None: if member is not None:
old_member = member._update(data) 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: if old_member is not None:
self.dispatch('member_update', old_member, member) self.dispatch('member_update', old_member, member)
else: else:
if self.member_cache_flags.other or user_id == self.self_id or guild.chunked: 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 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 # 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: if user_update:
self.dispatch('user_update', user_update[0], user_update[1]) 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: 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: def parse_guild_member_list_update(self, data) -> None:
self.dispatch('raw_member_list_update', data) self.dispatch('raw_member_list_update', data)
@ -1590,10 +1735,10 @@ class ConnectionState:
request = self._scrape_requests.get(guild.id) request = self._scrape_requests.get(guild.id)
should_parse = guild.chunked or getattr(request, 'chunk', False) should_parse = guild.chunked or getattr(request, 'chunk', False)
if (count := data['member_count']) > 0: if data['member_count'] > 0:
guild._member_count = count guild._member_count = data['member_count']
if (count := data['online_count']) > 0: if data['online_count'] > 0:
guild._presence_count = count guild._presence_count = data['online_count']
guild._true_online_count = sum(group['count'] for group in data['groups'] if group['id'] != 'offline') guild._true_online_count = sum(group['count'] for group in data['groups'] if group['id'] != 'offline')
empty_tuple = tuple() 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 guild._member_list.append(None) if should_parse else None # Insert blank so indexes don't fuck up
continue continue
member = Member(data=item['member'], guild=guild, state=self) mdata = item['member']
if presence := item['member'].get('presence'): member = Member(data=mdata, guild=guild, state=self)
member._presence_update(presence, empty_tuple) # type: ignore if mdata.get('presence') is not None:
member._presence_update(mdata['presence'], empty_tuple) # type: ignore
members.append(member) members.append(member)
guild._member_list.append(member) if should_parse else None guild._member_list.append(member) if should_parse else None
@ -1648,16 +1794,22 @@ class ConnectionState:
old_member = Member._copy(member) old_member = Member._copy(member)
dispatch = bool(member._update(mdata)) dispatch = bool(member._update(mdata))
if presence := mdata.get('presence'): if mdata.get('presence') is not None:
member._presence_update(presence, empty_tuple) # type: ignore 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._presence = old_presence
old_member._client_status != member._client_status or old_member._activities != member._activities if should_parse and old_presence != presence:
):
self.dispatch('presence_update', old_member, member) self.dispatch('presence_update', old_member, member)
user_update = member._update_inner_user(user) user_update = member._user._update_self(user)
if should_parse and user_update: if user_update:
self.dispatch('user_update', user_update[0], user_update[1]) self.dispatch('user_update', user_update[0], user_update[1])
if should_parse and dispatch: if should_parse and dispatch:
@ -1666,8 +1818,8 @@ class ConnectionState:
disregard.append(member) disregard.append(member)
else: else:
member = Member(data=mdata, guild=guild, state=self) member = Member(data=mdata, guild=guild, state=self)
if presence := mdata.get('presence'): if mdata.get('presence') is not None:
member._presence_update(presence, empty_tuple) # type: ignore member._presence_update(mdata['presence'], empty_tuple) # type: ignore
to_add.append(member) to_add.append(member)
@ -1687,24 +1839,37 @@ class ConnectionState:
old_member = Member._copy(member) old_member = Member._copy(member)
dispatch = bool(member._update(mdata)) dispatch = bool(member._update(mdata))
if presence := mdata.get('presence'): if mdata.get('presence') is not None:
member._presence_update(presence, empty_tuple) # type: ignore 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._presence = old_presence
old_member._client_status != member._client_status or old_member._activities != member._activities if should_parse and old_presence != presence:
):
self.dispatch('presence_update', old_member, member) self.dispatch('presence_update', old_member, member)
user_update = member._update_inner_user(user) user_update = member._user._update_self(user)
if should_parse and user_update: if user_update:
self.dispatch('user_update', user_update[0], user_update[1]) self.dispatch('user_update', user_update[0], user_update[1])
if should_parse and dispatch: if should_parse and dispatch:
self.dispatch('member_update', old_member, member) self.dispatch('member_update', old_member, member)
else: 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) member = Member(data=mdata, guild=guild, state=self)
if presence := mdata.get('presence'): if mdata.get('presence') is not None:
member._presence_update(presence, empty_tuple) # type: ignore self.store_presence(user_id, self.create_presence(mdata['presence']), guild.id)
guild._member_list.insert(opdata['index'], member) # Race condition? 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) _log.debug('Processed a chunk for %s members in guild ID %s.', len(members), guild_id)
if presences: if presences:
empty_tuple = ()
member_dict: Dict[Snowflake, Member] = {str(member.id): member for member in members} member_dict: Dict[Snowflake, Member] = {str(member.id): member for member in members}
for presence in presences: for presence in presences:
user = presence['user'] user = presence['user']
member_id = user['id'] member_id = user['id']
member = member_dict.get(member_id) member = member_dict.get(member_id)
if member is not None: 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') complete = data.get('chunk_index', 0) + 1 == data.get('chunk_count')
self.process_chunk_requests(guild_id, data.get('nonce'), members, complete) 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) timestamp = datetime.datetime.fromtimestamp(data['timestamp'], tz=datetime.timezone.utc)
self.dispatch('typing', channel, member, timestamp) 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']) key = int(data['id'])
new = self._relationships.get(key) new = self._relationships.get(key)
if new is None: if new is None:
@ -2312,20 +2478,20 @@ class ConnectionState:
new._update(data) new._update(data)
self.dispatch('relationship_update', old, new) 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']) key = int(data['id'])
try: try:
old = self._relationships.pop(key) old = self._relationships.pop(key)
except KeyError: 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: else:
self.dispatch('relationship_remove', old) 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']) key = int(data['id'])
new = self._relationships.get(key) new = self._relationships.get(key)
if new is None: if new is None:
relationship = Relationship(state=self, data=data) relationship = Relationship(state=self, data=data) # type: ignore
self._relationships[key] = relationship self._relationships[key] = relationship
else: else:
old = copy.copy(new) old = copy.copy(new)
@ -2427,6 +2593,73 @@ class ConnectionState:
def default_channel_settings(self, guild_id: Optional[int], channel_id: int) -> ChannelSettings: def default_channel_settings(self, guild_id: Optional[int], channel_id: int) -> ChannelSettings:
return ChannelSettings(guild_id, data={'channel_id': channel_id}, state=self) 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 @utils.cached_property
def premium_subscriptions_application(self) -> PartialApplication: def premium_subscriptions_application(self) -> PartialApplication:
# Hardcoded application for premium subscriptions, highly unlikely to change # 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 import List, Literal, Optional, TypedDict
from typing_extensions import NotRequired from typing_extensions import NotRequired
from .user import User from .user import PartialUser
from .snowflake import Snowflake from .snowflake import Snowflake
@ -34,7 +34,7 @@ StatusType = Literal['idle', 'dnd', 'online', 'offline']
class PartialPresenceUpdate(TypedDict): class PartialPresenceUpdate(TypedDict):
user: User user: PartialUser
guild_id: Optional[Snowflake] guild_id: Optional[Snowflake]
status: StatusType status: StatusType
activities: List[Activity] 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 import List, Literal, Optional, TypedDict, Union
from typing_extensions import NotRequired, Required from typing_extensions import NotRequired, Required
from .activity import PartialPresenceUpdate from .activity import Activity, ClientStatus, PartialPresenceUpdate, StatusType
from .voice import GuildVoiceState from .voice import GuildVoiceState
from .integration import BaseIntegration, IntegrationApplication from .integration import BaseIntegration, IntegrationApplication
from .role import Role from .role import Role
@ -39,7 +39,7 @@ from .message import Message
from .sticker import GuildSticker from .sticker import GuildSticker
from .appinfo import BaseAchievement, PartialApplication from .appinfo import BaseAchievement, PartialApplication
from .guild import Guild, UnavailableGuild, SupplementalGuild 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 .threads import Thread, ThreadMember
from .scheduled_event import GuildScheduledEvent from .scheduled_event import GuildScheduledEvent
from .channel import DMChannel, GroupDMChannel from .channel import DMChannel, GroupDMChannel
@ -49,7 +49,15 @@ from .entitlements import Entitlement, GatewayGift
from .library import LibraryApplication 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): class Gateway(TypedDict):
@ -61,11 +69,26 @@ class ShardInfo(TypedDict):
shard_count: int 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): class ResumedEvent(TypedDict):
_trace: List[str] _trace: List[str]
class ReadyEvent(ResumedEvent): class ReadyEvent(ResumedEvent):
_trace: List[str]
api_code_version: int api_code_version: int
analytics_token: str analytics_token: str
auth_session_id_hash: str auth_session_id_hash: str
@ -78,9 +101,9 @@ class ReadyEvent(ResumedEvent):
merged_members: List[List[MemberWithUser]] merged_members: List[List[MemberWithUser]]
pending_payments: NotRequired[List[Payment]] pending_payments: NotRequired[List[Payment]]
private_channels: List[Union[DMChannel, GroupDMChannel]] private_channels: List[Union[DMChannel, GroupDMChannel]]
relationships: List[dict] relationships: List[Relationship]
required_action: NotRequired[str] required_action: NotRequired[str]
sessions: List[dict] sessions: List[Session]
session_id: str session_id: str
session_type: str session_type: str
shard: NotRequired[ShardInfo] shard: NotRequired[ShardInfo]
@ -93,8 +116,8 @@ class ReadyEvent(ResumedEvent):
class MergedPresences(TypedDict): class MergedPresences(TypedDict):
friends: List[PresenceUpdateEvent] friends: List[UserPresenceUpdateEvent]
guilds: List[List[PresenceUpdateEvent]] guilds: List[List[PartialPresenceUpdate]]
class ReadySupplementalEvent(TypedDict): class ReadySupplementalEvent(TypedDict):
@ -108,6 +131,9 @@ NoEvent = Literal[None]
MessageCreateEvent = Message MessageCreateEvent = Message
SessionsReplaceEvent = List[Session]
class MessageDeleteEvent(TypedDict): class MessageDeleteEvent(TypedDict):
id: Snowflake id: Snowflake
channel_id: Snowflake channel_id: Snowflake
@ -298,7 +324,7 @@ class GuildMembersChunkEvent(TypedDict):
chunk_index: int chunk_index: int
chunk_count: int chunk_count: int
not_found: NotRequired[List[Snowflake]] not_found: NotRequired[List[Snowflake]]
presences: NotRequired[List[PresenceUpdateEvent]] presences: NotRequired[List[PartialPresenceUpdate]]
nonce: NotRequired[str] nonce: NotRequired[str]
@ -413,3 +439,13 @@ GiftCreateEvent = GiftUpdateEvent = GatewayGift
EntitlementEvent = Entitlement EntitlementEvent = Entitlement
LibraryApplicationUpdateEvent = LibraryApplication 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): class ConnectionAuthorization(TypedDict):
url: str 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 __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 import discord.abc
from .asset import Asset from .asset import Asset
@ -613,21 +613,6 @@ class ClientUser(BaseUser):
self.desktop: bool = data.get('desktop', False) self.desktop: bool = data.get('desktop', False)
self.mobile: bool = data.get('mobile', 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 @property
def locale(self) -> Locale: def locale(self) -> Locale:
""":class:`Locale`: The IETF language tag used to identify the language the user is using.""" """: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).""" """Indicates if the user is a premium user (i.e. has Discord Nitro)."""
return self.premium_type is not None 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 @property
def settings(self) -> Optional[UserSettings]: def settings(self) -> Optional[UserSettings]:
"""Optional[:class:`UserSettings`]: Returns the user's settings. """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]: 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 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: async def _get_channel(self) -> DMChannel:
ch = await self.create_dm() ch = await self.create_dm()
return ch return ch
@ -1009,7 +980,7 @@ class User(BaseUser, discord.abc.Connectable, discord.abc.Messageable):
@property @property
def relationship(self) -> Optional[Relationship]: def relationship(self) -> Optional[Relationship]:
"""Optional[:class:`Relationship`]: Returns the :class:`Relationship` with this user if applicable, ``None`` otherwise.""" """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) @copy_doc(discord.abc.Connectable.connect)
async def 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) 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| """|coro|
Sends the user a friend request. 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 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 copy_doc(original: Callable) -> Callable[[T], T]:
def decorator(overridden: T) -> T: def decorator(overridden: T) -> T:
overridden.__doc__ = original.__doc__ overridden.__doc__ = original.__doc__

Loading…
Cancel
Save