From 418a7915e687090179a67d12de807db0db4f284c Mon Sep 17 00:00:00 2001 From: Mysty <29671945+EvieePy@users.noreply.github.com> Date: Tue, 21 Jan 2025 19:49:50 +1000 Subject: [PATCH] Add raw presence update evemt --- discord/__init__.py | 1 + discord/client.py | 9 +++ discord/guild.py | 9 +-- discord/member.py | 88 ++++++++----------------- discord/presences.py | 150 ++++++++++++++++++++++++++++++++++++++++++ discord/raw_models.py | 12 +--- discord/state.py | 31 +++++---- discord/utils.py | 8 +++ docs/api.rst | 39 +++++++++++ setup.py | 1 + 10 files changed, 262 insertions(+), 86 deletions(-) create mode 100644 discord/presences.py diff --git a/discord/__init__.py b/discord/__init__.py index c206f650f..f850ee4ac 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -72,6 +72,7 @@ from .automod import * from .poll import * from .soundboard import * from .subscription import * +from .presences import * class VersionInfo(NamedTuple): diff --git a/discord/client.py b/discord/client.py index ef7980ec4..83296148c 100644 --- a/discord/client.py +++ b/discord/client.py @@ -237,6 +237,15 @@ class Client: To enable these events, this must be set to ``True``. Defaults to ``False``. .. versionadded:: 2.0 + enable_raw_presences: :class:`bool` + Whether to manually enable or disable the :func:`on_raw_presence_update` event. + + Setting this flag to ``True`` requires :attr:`Intents.presences` to be enabled. + + By default, this flag is set to ``True`` only when :attr:`Intents.presences` is enabled and :attr:`Intents.members` + is disabled, otherwise it's set to ``False``. + + .. versionadded:: 2.5 http_trace: :class:`aiohttp.TraceConfig` The trace configuration to use for tracking HTTP requests the library does using ``aiohttp``. This allows you to check requests the library is using. For more information, check the diff --git a/discord/guild.py b/discord/guild.py index faf64e279..b7e53f0c7 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -95,7 +95,7 @@ from .welcome_screen import WelcomeScreen, WelcomeChannel from .automod import AutoModRule, AutoModTrigger, AutoModRuleAction from .partial_emoji import _EmojiTag, PartialEmoji from .soundboard import SoundboardSound - +from .presences import RawPresenceUpdateEvent __all__ = ( 'Guild', @@ -653,10 +653,11 @@ class Guild(Hashable): empty_tuple = () for presence in guild.get('presences', []): - user_id = int(presence['user']['id']) - member = self.get_member(user_id) + raw_presence = RawPresenceUpdateEvent(data=presence, state=self._state) + member = self.get_member(raw_presence.user_id) + if member is not None: - member._presence_update(presence, empty_tuple) # type: ignore + member._presence_update(raw_presence, empty_tuple) # type: ignore if 'threads' in guild: threads = guild['threads'] diff --git a/discord/member.py b/discord/member.py index 6118e3267..2de8fbfc1 100644 --- a/discord/member.py +++ b/discord/member.py @@ -36,13 +36,13 @@ from . import utils from .asset import Asset from .utils import MISSING from .user import BaseUser, ClientUser, User, _UserTag -from .activity import create_activity, ActivityTypes from .permissions import Permissions -from .enums import Status, try_enum +from .enums import Status from .errors import ClientException from .colour import Colour from .object import Object from .flags import MemberFlags +from .presences import ClientStatus __all__ = ( 'VoiceState', @@ -57,10 +57,8 @@ if TYPE_CHECKING: from .channel import DMChannel, VoiceChannel, StageChannel from .flags import PublicUserFlags from .guild import Guild - from .types.activity import ( - ClientStatus as ClientStatusPayload, - PartialPresenceUpdate, - ) + from .activity import ActivityTypes + from .presences import RawPresenceUpdateEvent from .types.member import ( MemberWithUser as MemberWithUserPayload, Member as MemberPayload, @@ -168,46 +166,6 @@ class VoiceState: return f'<{self.__class__.__name__} {inner}>' -class _ClientStatus: - __slots__ = ('_status', 'desktop', 'mobile', 'web') - - def __init__(self): - self._status: 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.desktop = client_status.desktop - self.mobile = client_status.mobile - self.web = client_status.web - - return self - - def flatten_user(cls: T) -> T: for attr, value in itertools.chain(BaseUser.__dict__.items(), User.__dict__.items()): # ignore private/special methods @@ -306,6 +264,10 @@ class Member(discord.abc.Messageable, _UserTag): This will be set to ``None`` or a time in the past if the user is not timed out. .. versionadded:: 2.0 + client_status: :class:`ClientStatus` + Model which holds information about the status of the member on various clients/platforms via presence updates. + + .. versionadded:: 2.5 """ __slots__ = ( @@ -318,7 +280,7 @@ class Member(discord.abc.Messageable, _UserTag): 'nick', 'timed_out_until', '_permissions', - '_client_status', + 'client_status', '_user', '_state', '_avatar', @@ -354,7 +316,7 @@ class Member(discord.abc.Messageable, _UserTag): self.joined_at: Optional[datetime.datetime] = utils.parse_time(data.get('joined_at')) self.premium_since: Optional[datetime.datetime] = utils.parse_time(data.get('premium_since')) self._roles: utils.SnowflakeList = utils.SnowflakeList(map(int, data['roles'])) - self._client_status: _ClientStatus = _ClientStatus() + self.client_status: ClientStatus = ClientStatus() self.activities: Tuple[ActivityTypes, ...] = () self.nick: Optional[str] = data.get('nick', None) self.pending: bool = data.get('pending', False) @@ -430,7 +392,7 @@ class Member(discord.abc.Messageable, _UserTag): self._roles = utils.SnowflakeList(member._roles, is_sorted=True) self.joined_at = member.joined_at self.premium_since = member.premium_since - self._client_status = _ClientStatus._copy(member._client_status) + self.client_status = member.client_status self.guild = member.guild self.nick = member.nick self.pending = member.pending @@ -473,13 +435,12 @@ class Member(discord.abc.Messageable, _UserTag): self._flags = data.get('flags', 0) self._avatar_decoration_data = data.get('avatar_decoration_data') - def _presence_update(self, data: PartialPresenceUpdate, user: UserPayload) -> Optional[Tuple[User, User]]: - self.activities = tuple(create_activity(d, self._state) for d in data['activities']) - self._client_status._update(data['status'], data['client_status']) + def _presence_update(self, raw: RawPresenceUpdateEvent, user: UserPayload) -> Optional[Tuple[User, User]]: + self.activities = raw.activities + self.client_status = raw.client_status if len(user) > 1: return self._update_inner_user(user) - return None def _update_inner_user(self, user: UserPayload) -> Optional[Tuple[User, User]]: u = self._user @@ -518,7 +479,7 @@ class Member(discord.abc.Messageable, _UserTag): @property def status(self) -> Status: """:class:`Status`: The member's overall status. If the value is unknown, then it will be a :class:`str` instead.""" - return try_enum(Status, self._client_status._status) + return self.client_status.status @property def raw_status(self) -> str: @@ -526,31 +487,36 @@ class Member(discord.abc.Messageable, _UserTag): .. versionadded:: 1.5 """ - return self._client_status._status + return self.client_status._status @status.setter def status(self, value: Status) -> None: # internal use only - self._client_status._status = str(value) + self.client_status._status = str(value) @property def mobile_status(self) -> Status: """:class:`Status`: The member's status on a mobile device, if applicable.""" - return try_enum(Status, self._client_status.mobile or 'offline') + return self.client_status.mobile_status @property def desktop_status(self) -> Status: """:class:`Status`: The member's status on the desktop client, if applicable.""" - return try_enum(Status, self._client_status.desktop or 'offline') + return self.client_status.desktop_status @property def web_status(self) -> Status: """:class:`Status`: The member's status on the web client, if applicable.""" - return try_enum(Status, self._client_status.web or 'offline') + return self.client_status.web_status def is_on_mobile(self) -> bool: - """:class:`bool`: A helper function that determines if a member is active on a mobile device.""" - return self._client_status.mobile is not None + """A helper function that determines if a member is active on a mobile device. + + Returns + ------- + :class:`bool` + """ + return self.client_status.is_on_mobile() @property def colour(self) -> Colour: diff --git a/discord/presences.py b/discord/presences.py new file mode 100644 index 000000000..7fec2a09d --- /dev/null +++ b/discord/presences.py @@ -0,0 +1,150 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional, Tuple + +from .activity import create_activity +from .enums import Status, try_enum +from .utils import MISSING, _get_as_snowflake, _RawReprMixin + +if TYPE_CHECKING: + from typing_extensions import Self + + from .activity import ActivityTypes + from .guild import Guild + from .state import ConnectionState + from .types.activity import ClientStatus as ClientStatusPayload, PartialPresenceUpdate + + +__all__ = ( + 'RawPresenceUpdateEvent', + 'ClientStatus', +) + + +class ClientStatus: + """Represents the :ddocs:`Client Status Object ` from Discord, + which holds information about the status of the user on various clients/platforms, with additional helpers. + + .. versionadded:: 2.5 + """ + + __slots__ = ('_status', 'desktop', 'mobile', 'web') + + def __init__(self, *, status: str = MISSING, data: ClientStatusPayload = MISSING) -> None: + self._status: str = status or 'offline' + + data = data or {} + self.desktop: Optional[str] = data.get('desktop') + self.mobile: Optional[str] = data.get('mobile') + self.web: Optional[str] = data.get('web') + + 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 + + @property + def status(self) -> Status: + """:class:`Status`: The user's overall status. If the value is unknown, then it will be a :class:`str` instead.""" + return try_enum(Status, self._status) + + @property + def raw_status(self) -> str: + """:class:`str`: The user's overall status as a string value.""" + return self._status + + @property + def mobile_status(self) -> Status: + """:class:`Status`: The user's status on a mobile device, if applicable.""" + return try_enum(Status, self.mobile or 'offline') + + @property + def desktop_status(self) -> Status: + """:class:`Status`: The user's status on the desktop client, if applicable.""" + return try_enum(Status, self.desktop or 'offline') + + @property + def web_status(self) -> Status: + """:class:`Status`: The user's status on the web client, if applicable.""" + return try_enum(Status, self.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.""" + return self.mobile is not None + + +class RawPresenceUpdateEvent(_RawReprMixin): + """Represents the payload for a :func:`on_raw_presence_update` event. + + .. versionadded:: 2.5 + + Attributes + ---------- + user_id: :class:`int` + The ID of the user that triggered the presence update. + guild_id: Optional[:class:`int`] + The guild ID for the users presence update. Could be ``None``. + guild: Optional[:class:`Guild`] + The guild associated with the presence update and user. Could be ``None``. + client_status: :class:`ClientStatus` + The :class:`~.ClientStatus` model which holds information about the status of the user on various clients. + activities: Tuple[Union[:class:`BaseActivity`, :class:`Spotify`]] + The activities the user is currently doing. 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. + """ + + __slots__ = ('user_id', 'guild_id', 'guild', 'client_status', 'activities') + + def __init__(self, *, data: PartialPresenceUpdate, state: ConnectionState) -> None: + self.user_id: int = int(data['user']['id']) + self.client_status: ClientStatus = ClientStatus(status=data['status'], data=data['client_status']) + self.activities: Tuple[ActivityTypes, ...] = tuple(create_activity(d, state) for d in data['activities']) + self.guild_id: Optional[int] = _get_as_snowflake(data, 'guild_id') + self.guild: Optional[Guild] = state._get_guild(self.guild_id) diff --git a/discord/raw_models.py b/discord/raw_models.py index 012b8f07d..c8c8b0e38 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -25,10 +25,10 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -from typing import TYPE_CHECKING, Literal, Optional, Set, List, Tuple, Union +from typing import TYPE_CHECKING, Literal, Optional, Set, List, Union from .enums import ChannelType, try_enum, ReactionType -from .utils import _get_as_snowflake +from .utils import _get_as_snowflake, _RawReprMixin from .app_commands import AppCommandPermissions from .colour import Colour @@ -82,14 +82,6 @@ __all__ = ( ) -class _RawReprMixin: - __slots__: Tuple[str, ...] = () - - def __repr__(self) -> str: - value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__) - return f'<{self.__class__.__name__} {value}>' - - class RawMessageDeleteEvent(_RawReprMixin): """Represents the event payload for a :func:`on_raw_message_delete` event. diff --git a/discord/state.py b/discord/state.py index 453fbc5b6..b1409f809 100644 --- a/discord/state.py +++ b/discord/state.py @@ -62,6 +62,7 @@ from .message import Message from .channel import * from .channel import _channel_factory from .raw_models import * +from .presences import RawPresenceUpdateEvent from .member import Member from .role import Role from .enums import ChannelType, try_enum, Status @@ -261,6 +262,10 @@ class ConnectionState(Generic[ClientT]): if not intents.members or cache_flags._empty: self.store_user = self.store_user_no_intents + self.raw_presence_flag: bool = options.get('enable_raw_presences', utils.MISSING) + if self.raw_presence_flag is utils.MISSING: + self.raw_presence_flag = not intents.members and intents.presences + self.parsers: Dict[str, Callable[[Any], None]] self.parsers = parsers = {} for attr, func in inspect.getmembers(self): @@ -827,22 +832,24 @@ class ConnectionState(Generic[ClientT]): self.dispatch('interaction', interaction) def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: - guild_id = utils._get_as_snowflake(data, 'guild_id') - # guild_id won't be None here - guild = self._get_guild(guild_id) - if guild is None: - _log.debug('PRESENCE_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) + raw = RawPresenceUpdateEvent(data=data, state=self) + + if self.raw_presence_flag: + self.dispatch('raw_presence_update', raw) + + if raw.guild is None: + _log.debug('PRESENCE_UPDATE referencing an unknown guild ID: %s. Discarding.', raw.guild_id) return - user = data['user'] - member_id = int(user['id']) - member = guild.get_member(member_id) + member = raw.guild.get_member(raw.user_id) + if member is None: - _log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding', member_id) + _log.debug('PRESENCE_UPDATE referencing an unknown member ID: %s. Discarding', raw.user_id) return old_member = Member._copy(member) - user_update = member._presence_update(data=data, user=user) + user_update = member._presence_update(raw=raw, user=data['user']) + if user_update: self.dispatch('user_update', user_update[0], user_update[1]) @@ -1430,8 +1437,10 @@ class ConnectionState(Generic[ClientT]): user = presence['user'] member_id = user['id'] member = member_dict.get(member_id) + if member is not None: - member._presence_update(presence, user) + raw_presence = RawPresenceUpdateEvent(data=presence, state=self) + member._presence_update(raw_presence, user) complete = data.get('chunk_index', 0) + 1 == data.get('chunk_count') self.process_chunk_requests(guild_id, data.get('nonce'), members, complete) diff --git a/discord/utils.py b/discord/utils.py index 9b6bd59a2..bd327b5a8 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -1532,3 +1532,11 @@ def _format_call_duration(duration: datetime.timedelta) -> str: formatted = f"{years} years" return formatted + + +class _RawReprMixin: + __slots__: Tuple[str, ...] = () + + def __repr__(self) -> str: + value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__) + return f'<{self.__class__.__name__} {value}>' diff --git a/docs/api.rst b/docs/api.rst index b9348ec4b..8da2ba80c 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -916,6 +916,29 @@ Members :param after: The updated member's updated info. :type after: :class:`Member` +.. function:: on_raw_presence_update(payload) + + Called when a :class:`Member` updates their presence. + + This requires :attr:`Intents.presences` to be enabled. + + Unlike :func:`on_presence_update`, when enabled, this is called regardless of the state of internal guild + and member caches, and **does not** provide a comparison between the previous and updated states of the :class:`Member`. + + .. important:: + + By default, this event is only dispatched when :attr:`Intents.presences` is enabled **and** :attr:`Intents.members` + is disabled. + + You can manually override this behaviour by setting the **enable_raw_presences** flag in the :class:`Client`, + however :attr:`Intents.presences` is always required for this event to work. + + .. versionadded:: 2.5 + + :param payload: The raw presence update event model. + :type payload: :class:`RawPresenceUpdateEvent` + + Messages ~~~~~~~~~ @@ -5364,6 +5387,14 @@ RawPollVoteActionEvent .. autoclass:: RawPollVoteActionEvent() :members: +RawPresenceUpdateEvent +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RawPresenceUpdateEvent + +.. autoclass:: RawPresenceUpdateEvent() + :members: + PartialWebhookGuild ~~~~~~~~~~~~~~~~~~~~ @@ -5398,6 +5429,14 @@ MessageSnapshot .. autoclass:: MessageSnapshot :members: +ClientStatus +~~~~~~~~~~~~ + +.. attributetable:: ClientStatus + +.. autoclass:: ClientStatus() + :members: + Data Classes -------------- diff --git a/setup.py b/setup.py index 2481afeb4..e3d6d59f4 100644 --- a/setup.py +++ b/setup.py @@ -1,6 +1,7 @@ from setuptools import setup import re + def derive_version() -> str: version = '' with open('discord/__init__.py') as f: