From 0847b02ae1b6c852bac6d4a57660987901c0295c Mon Sep 17 00:00:00 2001 From: dolfies Date: Tue, 13 Feb 2024 11:15:38 -0500 Subject: [PATCH] Overhaul presence syncing and afk/idle tracking (#663) * Overhaul presence syncing and afk/idle tracking * Log initial presence as well * Fix duplicate custom status checking --- discord/client.py | 134 +++++++++++++++++++++++++++++++++++---------- discord/gateway.py | 39 +++++++------ discord/state.py | 10 ++++ 3 files changed, 137 insertions(+), 46 deletions(-) diff --git a/discord/client.py b/discord/client.py index dc526af92..d0a4742b8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -186,6 +186,14 @@ class Client: A status to start your presence with upon logging on to Discord. activity: Optional[:class:`.BaseActivity`] An activity to start your presence with upon logging on to Discord. + activities: List[:class:`.BaseActivity`] + A list of activities to start your presence with upon logging on to Discord. Cannot be sent with ``activity``. + + .. versionadded:: 2.0 + afk: :class:`bool` + Whether to start your session as AFK. Defaults to ``False``. + + .. versionadded:: 2.1 allowed_mentions: Optional[:class:`AllowedMentions`] Control how the client handles mentions by default on every message sent. @@ -324,6 +332,7 @@ class Client: if status or activities: if status is None: status = getattr(state.settings, 'status', None) or Status.unknown + _log.debug('Setting initial presence to %s %s', status, activities) self.loop.create_task(self.change_presence(activities=activities, status=status)) @property @@ -684,11 +693,21 @@ class Client: ): return # Nothing changed + current_activity = None + for activity in self.activities: + if activity.type != ActivityType.custom: + current_activity = activity + break + + if new_settings.status == self.client_status and new_settings.custom_activity == current_activity: + return # Nothing changed + status = new_settings.status - activities = [a for a in self.activities if a.type != ActivityType.custom] + activities = [a for a in self.client_activities if a.type != ActivityType.custom] if new_settings.custom_activity is not None: activities.append(new_settings.custom_activity) + _log.debug('Syncing presence to %s %s', status, new_settings.custom_activity) await self.change_presence(status=status, activities=activities, edit_settings=False) # Hooks @@ -1230,6 +1249,32 @@ class Client: activities = (activity,) if activity else activities return activities or tuple() + def is_afk(self) -> bool: + """:class:`bool`: Indicates if the client is currently AFK. + + This allows the Discord client to know how to handle push notifications + better for you in case you are away from your keyboard. + + .. versionadded:: 2.1 + """ + if self.ws: + return self.ws.afk + return False + + @property + def idle_since(self) -> Optional[datetime]: + """Optional[:class:`datetime.datetime`]: When the client went idle. + + This indicates that you are truly idle and not just lying. + + .. versionadded:: 2.1 + """ + ws = self.ws + if ws is None or not ws.idle_since: + return None + + return utils.parse_timestamp(ws.idle_since) + @property def allowed_mentions(self) -> Optional[AllowedMentions]: """Optional[:class:`~discord.AllowedMentions`]: The allowed mention configuration. @@ -1606,21 +1651,30 @@ class Client: async def change_presence( self, *, - activity: Optional[ActivityTypes] = None, - activities: Optional[List[ActivityTypes]] = None, - status: Optional[Status] = None, - afk: bool = False, + activity: Optional[ActivityTypes] = MISSING, + activities: List[ActivityTypes] = MISSING, + status: Status = MISSING, + afk: bool = MISSING, + idle_since: Optional[datetime] = MISSING, edit_settings: bool = True, ) -> None: """|coro| Changes the client's presence. + .. versionchanged:: 2.1 + + The default value for parameters is now the current value. + ``None`` is no longer a valid value for most; you must explicitly + set it to the default value if you want to reset it. + .. versionchanged:: 2.0 + Edits are no longer in place. Added option to update settings. .. versionchanged:: 2.0 + This function will now raise :exc:`TypeError` instead of ``InvalidArgument``. @@ -1636,55 +1690,79 @@ class Client: ---------- activity: Optional[:class:`.BaseActivity`] The activity being done. ``None`` if no activity is done. - activities: Optional[List[:class:`.BaseActivity`]] - A list of the activities being done. ``None`` if no activities - are done. Cannot be sent with ``activity``. - status: Optional[:class:`.Status`] - Indicates what status to change to. If ``None``, then - :attr:`.Status.online` is used. + activities: List[:class:`.BaseActivity`] + A list of the activities being done. Cannot be sent with ``activity``. + + .. versionadded:: 2.0 + status: :class:`.Status` + Indicates what status to change to. afk: :class:`bool` Indicates if you are going AFK. This allows the Discord client to know how to handle push notifications better - for you in case you are actually idle and not lying. + for you in case you are away from your keyboard. + idle_since: Optional[:class:`datetime.datetime`] + When the client went idle. This indicates that you are + truly idle and not just lying. edit_settings: :class:`bool` - Whether to update the settings with the new status and/or + Whether to update user settings with the new status and/or custom activity. This will broadcast the change and cause all connected (official) clients to change presence as well. + + This should be set to ``False`` for idle changes. + Required for setting/editing ``expires_at`` for custom activities. - It's not recommended to change this, as setting it to ``False`` causes undefined behavior. + It's not recommended to change this, as setting it to ``False`` + can cause undefined behavior. Raises ------ TypeError The ``activity`` parameter is not the proper type. Both ``activity`` and ``activities`` were passed. + ValueError + More than one custom activity was passed. """ - if activity and activities: + if activity is not MISSING and activities is not MISSING: raise TypeError('Cannot pass both activity and activities') - activities = activities or activity and [activity] - if activities is None: - activities = [] - if status is None: - status = Status.online - elif status is Status.offline: - status = Status.invisible + skip_activities = False + if activities is MISSING: + if activity is not MISSING: + activities = [activity] if activity else [] + else: + activities = list(self.client_activities) + skip_activities = True + else: + activities = activities or [] - await self.ws.change_presence(status=status, activities=activities, afk=afk) + skip_status = status is MISSING + if status is MISSING: + status = self.client_status + if status is Status.offline: + status = Status.invisible - if edit_settings: - custom_activity = None + if idle_since is MISSING: + since = self.ws.idle_since if self.ws else 0 + else: + since = int(idle_since.timestamp() * 1000) if idle_since else 0 + custom_activity = None + if not skip_activities: for activity in activities: if getattr(activity, 'type', None) is ActivityType.custom: + if custom_activity is not None: + raise ValueError('More than one custom activity was passed') custom_activity = activity + await self.ws.change_presence(status=status, activities=activities, afk=afk, since=since) + + if edit_settings and self.settings: payload: Dict[str, Any] = {} - if status != getattr(self.settings, 'status', None): + if not skip_status and status != self.settings.status: payload['status'] = status - if custom_activity != getattr(self.settings, 'custom_activity', None): + if not skip_activities and custom_activity != self.settings.custom_activity: payload['custom_activity'] = custom_activity - if payload and self.settings: + if payload: await self.settings.edit(**payload) async def change_voice_state( diff --git a/discord/gateway.py b/discord/gateway.py index cc3872d7d..e1b9e1c33 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -32,7 +32,7 @@ import threading import traceback import zlib -from typing import Any, Callable, Coroutine, Dict, List, TYPE_CHECKING, NamedTuple, Optional, TypeVar +from typing import Any, Callable, Coroutine, Dict, List, TYPE_CHECKING, NamedTuple, Optional, Sequence, TypeVar import aiohttp import yarl @@ -334,6 +334,10 @@ class DiscordWebSocket: self._close_code: Optional[int] = None self._rate_limiter: GatewayRatelimiter = GatewayRatelimiter() + # Presence state tracking + self.afk: bool = False + self.idle_since: int = 0 + @property def open(self) -> bool: return not self.socket.closed @@ -395,6 +399,8 @@ class DiscordWebSocket: ws._user_agent = client.http.user_agent ws._super_properties = client.http.super_properties ws._zlib_enabled = zlib + ws.afk = client._connection._afk + ws.idle_since = client._connection._idle_since if client._enable_debug_events: ws.send = ws.debug_send @@ -456,15 +462,13 @@ class DiscordWebSocket: # but that needs more testing... presence = { 'status': 'unknown', - 'since': 0, + 'since': self.idle_since, 'activities': [], - 'afk': False, + 'afk': self.afk, } 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' @@ -482,11 +486,12 @@ class DiscordWebSocket: 'client_state': { 'api_code_version': 0, 'guild_versions': {}, - 'highest_last_message_id': '0', - 'private_channels_version': '0', - 'read_state_version': 0, - 'user_guild_settings_version': -1, - 'user_settings_version': -1, + # 'highest_last_message_id': '0', + # 'initial_guild_id': None, + # 'private_channels_version': '0', + # 'read_state_version': 0, + # 'user_guild_settings_version': -1, + # 'user_settings_version': -1, }, }, } @@ -700,7 +705,7 @@ class DiscordWebSocket: async def change_presence( self, *, - activities: Optional[List[ActivityTypes]] = None, + activities: Optional[Sequence[ActivityTypes]] = None, status: Optional[Status] = None, since: int = 0, afk: bool = False, @@ -712,17 +717,15 @@ class DiscordWebSocket: else: activities_data = [] - if status == 'idle': - since = int(time.time() * 1000) - payload = { 'op': self.PRESENCE, - 'd': {'activities': activities_data, 'afk': afk, 'since': since, 'status': str(status or 'online')}, + 'd': {'activities': activities_data, 'afk': afk, 'since': since, 'status': str(status or 'unknown')}, } - sent = utils._to_json(payload) - _log.debug('Sending "%s" to change presence.', sent) - await self.send(sent) + _log.debug('Sending %s to change presence.', payload['d']) + await self.send_as_json(payload) + self.afk = afk + self.idle_since = since async def request_lazy_guild( self, diff --git a/discord/state.py b/discord/state.py index c9381394d..0f80311ad 100644 --- a/discord/state.py +++ b/discord/state.py @@ -615,6 +615,14 @@ class ConnectionState: else: status = str(status) + idle_since = options.get('idle_since', None) + if idle_since: + if not isinstance(idle_since, datetime.datetime): + raise TypeError('idle_since parameter must be a datetime.datetime') + since = int(idle_since.timestamp() * 1000) + else: + since = 0 + self._chunk_guilds: bool = options.get('chunk_guilds_at_startup', True) self._request_guilds = options.get('request_guilds', True) @@ -628,6 +636,8 @@ class ConnectionState: self.member_cache_flags: MemberCacheFlags = cache_flags self._activities: List[ActivityPayload] = activities self._status: Optional[str] = status + self._afk: bool = options.get('afk', False) + self._idle_since: int = since if cache_flags._empty: self.store_user = self.create_user