From 23ae084b8cb3bb5c9d815b791c1a94b0cf53e516 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 14 Sep 2020 02:52:53 -0400 Subject: [PATCH] Allow finer grained control over the member cache. --- discord/client.py | 9 +++- discord/enums.py | 2 +- discord/flags.py | 126 ++++++++++++++++++++++++++++++++++++++++++++++ discord/guild.py | 5 +- discord/member.py | 8 +++ discord/state.py | 33 +++++++++--- docs/api.rst | 12 +++++ 7 files changed, 184 insertions(+), 11 deletions(-) diff --git a/discord/client.py b/discord/client.py index c39a26da4..861617b74 100644 --- a/discord/client.py +++ b/discord/client.py @@ -141,9 +141,14 @@ class Client: shard_count: Optional[:class:`int`] The total number of shards. intents: :class:`Intents` - A list of intents that you want to enable for the session. This is a way of + The intents that you want to enable for the session. This is a way of disabling and enabling certain gateway events from triggering and being sent. - Currently, if no intents are passed then you will receive all data. + + .. versionadded:: 1.5 + member_cache_flags: :class:`MemberCacheFlags` + Allows for finer control over how the library caches members. + + .. versionadded:: 1.5 fetch_offline_members: :class:`bool` Indicates if :func:`.on_ready` should be delayed to fetch all offline members from the guilds the client belongs to. If this is ``False``\, then diff --git a/discord/enums.py b/discord/enums.py index 7fa39289f..e74e66981 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -51,7 +51,7 @@ __all__ = ( 'Theme', 'WebhookType', 'ExpireBehaviour', - 'ExpireBehavior' + 'ExpireBehavior', ) def _create_value_cls(name): diff --git a/discord/flags.py b/discord/flags.py index bc2a52edb..a204937cb 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -31,6 +31,7 @@ __all__ = ( 'MessageFlags', 'PublicUserFlags', 'Intents', + 'MemberCacheFlags', ) class flag_value: @@ -651,3 +652,128 @@ class Intents(BaseFlags): - :func:`on_typing` (only for DMs) """ return 1 << 14 + +@fill_with_flags() +class MemberCacheFlags(BaseFlags): + """Controls the library's cache policy when it comes to members. + + This allows for finer grained control over what members are cached. + For more information, check :attr:`Client.member_cache_flags`. Note + that the bot's own member is always cached. + + Due to a quirk in how Discord works, in order to ensure proper cleanup + of cache resources it is recommended to have :attr:`Intents.members` + enabled. Otherwise the library cannot know when a member leaves a guild and + is thus unable to cleanup after itself. + + To construct an object you can pass keyword arguments denoting the flags + to enable or disable. + + The default value is all flags enabled. + + .. versionadded:: 1.5 + + .. container:: operations + + .. describe:: x == y + + Checks if two flags are equal. + .. describe:: x != y + + Checks if two flags are not equal. + .. describe:: hash(x) + + Return the flag's hash. + .. describe:: iter(x) + + Returns an iterator of ``(name, value)`` pairs. This allows it + to be, for example, constructed as a dict or a list of pairs. + + Attributes + ----------- + value: :class:`int` + The raw value. You should query flags via the properties + rather than using this raw value. + """ + + __slots__ = () + + def __init__(self, **kwargs): + bits = max(self.VALID_FLAGS.values()).bit_length() + self.value = (1 << bits) - 1 + for key, value in kwargs.items(): + if key not in self.VALID_FLAGS: + raise TypeError('%r is not a valid flag name.' % key) + setattr(self, key, value) + + @classmethod + def all(cls): + """A factory method that creates a :class:`MemberCacheFlags` with everything enabled.""" + bits = max(cls.VALID_FLAGS.values()).bit_length() + value = (1 << bits) - 1 + self = cls.__new__(cls) + self.value = value + return self + + @classmethod + def none(cls): + """A factory method that creates a :class:`MemberCacheFlags` with everything disabled.""" + self = cls.__new__(cls) + self.value = self.DEFAULT_VALUE + return self + + @flag_value + def online(self): + """:class:`bool`: Whether to cache members with a status. + + For example, members that are part of the initial ``GUILD_CREATE`` + or become online at a later point. This requires :attr:`Intents.presences`. + + Members that go offline are no longer cached. + """ + return 1 + + @flag_value + def voice(self): + """:class:`bool`: Whether to cache members that are in voice. + + This requires :attr:`Intents.voice_states`. + + Members that leave voice are no longer cached. + """ + return 2 + + @flag_value + def joined(self): + """:class:`bool`: Whether to cache members that joined the guild + or are chunked as part of the initial log in flow. + + This requires :attr:`Intents.members`. + + Members that leave the guild are no longer cached. + """ + return 4 + + def _verify_intents(self, intents): + if self.online and not intents.presences: + raise ValueError('MemberCacheFlags.online requires Intents.presences enabled') + + if self.voice and not intents.voice_states: + raise ValueError('MemberCacheFlags.voice requires Intents.voice_states') + + if self.joined and not intents.members: + raise ValueError('MemberCacheFlags.joined requires Intents.members') + + if not self.joined and self.voice and self.online: + msg = 'MemberCacheFlags.voice and MemberCacheFlags.online require MemberCacheFlags.joined ' \ + 'to properly evict members from the cache.' + raise ValueError(msg) + + @property + def _voice_only(self): + return self.value == 2 + + @property + def _online_only(self): + return self.value == 1 + diff --git a/discord/guild.py b/discord/guild.py index 2ab4afb26..4247f1054 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -305,11 +305,12 @@ class Guild(Hashable): self._rules_channel_id = utils._get_as_snowflake(guild, 'rules_channel_id') self._public_updates_channel_id = utils._get_as_snowflake(guild, 'public_updates_channel_id') - cache_members = self._state._cache_members + cache_online_members = self._state._member_cache_flags.online + cache_joined = self._state._member_cache_flags.joined self_id = self._state.self_id for mdata in guild.get('members', []): member = Member(data=mdata, guild=self, state=state) - if cache_members or member.id == self_id: + if cache_joined or (cache_online_members and member.raw_status != 'offline') or member.id == self_id: self._add_member(member) self._sync(guild) diff --git a/discord/member.py b/discord/member.py index 00f72fb2f..4b8fb8a07 100644 --- a/discord/member.py +++ b/discord/member.py @@ -291,6 +291,14 @@ class Member(discord.abc.Messageable, _BaseUser): """: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[None]) + @property + def raw_status(self): + """:class:`str`: The member's overall status as a string value. + + .. versionadded:: 1.5 + """ + return self._client_status[None] + @status.setter def status(self, value): # internal use only diff --git a/discord/state.py b/discord/state.py index ad491197d..39190f1e9 100644 --- a/discord/state.py +++ b/discord/state.py @@ -51,7 +51,7 @@ from .member import Member from .role import Role from .enums import ChannelType, try_enum, Status from . import utils -from .flags import Intents +from .flags import Intents, MemberCacheFlags from .embeds import Embed from .object import Object from .invite import Invite @@ -116,8 +116,6 @@ class ConnectionState: raise TypeError('allowed_mentions parameter must be AllowedMentions') self.allowed_mentions = allowed_mentions - # Only disable cache if both fetch_offline and guild_subscriptions are off. - self._cache_members = (self._fetch_offline or self.guild_subscriptions) self._chunk_requests = [] activity = options.get('activity', None) @@ -142,6 +140,16 @@ class ConnectionState: if not intents.members and self._fetch_offline: raise ValueError('Intents.members has be enabled to fetch offline members.') + cache_flags = options.get('member_cache_flags', None) + if cache_flags is None: + cache_flags = MemberCacheFlags.all() + else: + if not isinstance(cache_flags, MemberCacheFlags): + raise TypeError('member_cache_flags parameter must be MemberCacheFlags not %r' % type(cache_flags)) + + cache_flags._verify_intents(intents) + + self._member_cache_flags = cache_flags self._activity = activity self._status = status self._intents = intents @@ -564,6 +572,7 @@ class ConnectionState: user = data['user'] member_id = int(user['id']) member = guild.get_member(member_id) + flags = self._member_cache_flags if member is None: if 'username' not in user: # sometimes we receive 'incomplete' member data post-removal. @@ -571,13 +580,17 @@ class ConnectionState: return member, old_member = Member._from_presence_update(guild=guild, data=data, state=self) - guild._add_member(member) + if flags.online or (flags._online_only and member.raw_status != 'offline'): + guild._add_member(member) else: old_member = Member._copy(member) user_update = member._presence_update(data=data, user=user) if user_update: self.dispatch('user_update', user_update[0], user_update[1]) + if flags._online_only and member.raw_status == 'offline': + guild._remove_member(member) + self.dispatch('member_update', old_member, member) def parse_user_update(self, data): @@ -697,7 +710,7 @@ class ConnectionState: return member = Member(guild=guild, data=data, state=self) - if self._cache_members: + if self._member_cache_flags.joined: guild._add_member(member) guild._member_count += 1 self.dispatch('member_join', member) @@ -760,7 +773,7 @@ class ConnectionState: return self._add_guild_from_data(data) async def chunk_guild(self, guild, *, wait=True, cache=None): - cache = cache or self._cache_members + cache = cache or self._member_cache_flags.joined future = self.loop.create_future() request = ChunkRequest(guild.id, future, self._get_guild, cache=cache) self._chunk_requests.append(request) @@ -926,6 +939,7 @@ class ConnectionState: def parse_voice_state_update(self, data): guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) channel_id = utils._get_as_snowflake(data, 'channel_id') + flags = self._member_cache_flags if guild is not None: if int(data['user_id']) == self.user.id: voice = self._get_voice_client(guild.id) @@ -935,6 +949,13 @@ class ConnectionState: member, before, after = guild._update_voice_state(data, channel_id) if member is not None: + if flags.voice: + if channel_id is None and flags.value == MemberCacheFlags.voice.flag: + # Only remove from cache iff we only have the voice flag enabled + guild._remove_member(member) + else: + guild._add_member(member) + self.dispatch('voice_state_update', member, before, after) else: log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) diff --git a/docs/api.rst b/docs/api.rst index d4af5ff19..b3c10991f 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2816,6 +2816,18 @@ AllowedMentions .. autoclass:: AllowedMentions :members: +Intents +~~~~~~~~~~ + +.. autoclass:: Intents + :members: + +MemberCacheFlags +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: MemberCacheFlags + :members: + File ~~~~~