diff --git a/discord/abc.py b/discord/abc.py index 369fab3b..452e1a1d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE. """ import abc +import sys import copy import asyncio @@ -169,7 +170,7 @@ class _Overwrites: self.id = kwargs.pop('id') self.allow = kwargs.pop('allow', 0) self.deny = kwargs.pop('deny', 0) - self.type = kwargs.pop('type') + self.type = sys.intern(kwargs.pop('type')) def _asdict(self): return { @@ -933,7 +934,6 @@ class Messageable(metaclass=abc.ABCMeta): """ return Typing(self) - @utils.deprecated('fetch_message_fast') async def fetch_message(self, id): """|coro| @@ -941,8 +941,6 @@ class Messageable(metaclass=abc.ABCMeta): This can only be used by bot accounts. - Prefer using :meth:`fetch_message_fast`. - Parameters ------------ id: :class:`int` @@ -967,40 +965,6 @@ class Messageable(metaclass=abc.ABCMeta): data = await self._state.http.get_message(channel.id, id) return self._state.create_message(channel=channel, data=data) - async def fetch_message_fast(self, id): - """|coro| - - Retrieves a single :class:`~discord.Message` from the destination, using - the history endpoint. - - .. versionadded:: 1.5 - - Parameters - ------------ - id: :class:`int` - The message ID to look for. - - Raises - -------- - ~discord.NotFound - The specified channel was not found. - ~discord.Forbidden - You do not have permissions to get channel message history. - ~discord.HTTPException - The request to get message history failed. - - Returns - -------- - Optional[:class:`~discord.Message`] - The message asked for, or None if there is no match. - """ - - channel = await self._get_channel() - data = await self._state.http.logs_from(channel.id, limit=1, around=id) - if data and int(data[0]['id']) == id: - return self._state.create_message(channel=channel, data=data[0]) - return None - async def pins(self): """|coro| diff --git a/discord/client.py b/discord/client.py index 23809424..36df67b6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -142,9 +142,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 @@ -565,6 +570,8 @@ class Client: # sometimes, discord sends us 1000 for unknown reasons so we should reconnect # regardless and rely on is_closed instead if isinstance(exc, ConnectionClosed): + if exc.code == 4014: + raise PrivilegedIntentsRequired(exc.shard_id) from None if exc.code != 1000: await self.close() raise diff --git a/discord/enums.py b/discord/enums.py index c171e1e3..7d1ba6ec 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/errors.py b/discord/errors.py index bd78131a..be3015cc 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -175,3 +175,27 @@ class ConnectionClosed(ClientException): self.reason = '' self.shard_id = shard_id super().__init__('Shard ID %s WebSocket closed with %s' % (self.shard_id, self.code)) + +class PrivilegedIntentsRequired(ClientException): + """Exception that's thrown when the gateway is requesting privileged intents + but they're not ticked in the developer page yet. + + Go to https://discord.com/developers/applications/ and enable the intents + that are required. Currently these are as follows: + + - :attr:`Intents.members` + - :attr:`Intents.presences` + + Attributes + ----------- + shard_id: Optional[:class:`int`] + The shard ID that got closed if applicable. + """ + + def __init__(self, shard_id): + self.shard_id = shard_id + msg = 'Shard ID %s is requesting privileged intents that have not been explicitly enabled in the ' \ + 'developer portal. It is recommended to go to https://discord.com/developers/applications/ ' \ + 'and explicitly enable the privileged intents within your application\'s page. If this is not ' \ + 'possible, then consider disabling the privileged intents instead.' + super().__init__(msg % shard_id) diff --git a/discord/flags.py b/discord/flags.py index bc2a52ed..acf61302 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -31,6 +31,7 @@ __all__ = ( 'MessageFlags', 'PublicUserFlags', 'Intents', + 'MemberCacheFlags', ) class flag_value: @@ -651,3 +652,127 @@ 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 \ No newline at end of file diff --git a/discord/guild.py b/discord/guild.py index 847fda95..695898b1 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 0b89d59f..3d45e5df 100644 --- a/discord/member.py +++ b/discord/member.py @@ -25,6 +25,7 @@ DEALINGS IN THE SOFTWARE. """ import itertools +import sys from operator import attrgetter import discord.abc @@ -215,10 +216,10 @@ class Member(discord.abc.Messageable, _BaseUser): clone = cls(data=data, guild=guild, state=state) to_return = cls(data=data, guild=guild, state=state) to_return._client_status = { - key: value + sys.intern(key): sys.intern(value) for key, value in data.get('client_status', {}).items() } - to_return._client_status[None] = data['status'] + to_return._client_status[None] = sys.intern(data['status']) return to_return, clone @classmethod @@ -260,10 +261,10 @@ class Member(discord.abc.Messageable, _BaseUser): def _presence_update(self, data, user): self.activities = tuple(map(create_activity, data.get('activities', []))) self._client_status = { - key: value + sys.intern(key): sys.intern(value) for key, value in data.get('client_status', {}).items() } - self._client_status[None] = data['status'] + self._client_status[None] = sys.intern(data['status']) if len(user) > 1: return self._update_inner_user(user) @@ -285,6 +286,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/shard.py b/discord/shard.py index 2ed7724d..8e5b75cd 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -34,7 +34,15 @@ from .state import AutoShardedConnectionState from .client import Client from .backoff import ExponentialBackoff from .gateway import * -from .errors import ClientException, InvalidArgument, HTTPException, GatewayNotFound, ConnectionClosed +from .errors import ( + ClientException, + InvalidArgument, + HTTPException, + GatewayNotFound, + ConnectionClosed, + PrivilegedIntentsRequired, +) + from . import utils from .enums import Status @@ -125,6 +133,9 @@ class Shard: return if isinstance(e, ConnectionClosed): + if e.code == 4014: + self._queue_put(EventItem(EventType.terminate, self, PrivilegedIntentsRequired(self.id))) + return if e.code != 1000: self._queue_put(EventItem(EventType.close, self, e)) return @@ -407,8 +418,11 @@ class AutoShardedClient(Client): item = await self.__queue.get() if item.type == EventType.close: await self.close() - if isinstance(item.error, ConnectionClosed) and item.error.code != 1000: - raise item.error + if isinstance(item.error, ConnectionClosed): + if item.error.code != 1000: + raise item.error + if item.error.code == 4014: + raise PrivilegedIntentsRequired(item.shard.id) from None return elif item.type in (EventType.identify, EventType.resume): await item.shard.reidentify(item.error) diff --git a/discord/state.py b/discord/state.py index 23200da3..7e2b260a 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 @@ -110,8 +110,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) @@ -136,6 +134,19 @@ class ConnectionState: if not intents.members and self._fetch_offline: raise ValueError('Intents.members has be enabled to fetch offline members.') + else: + intents = Intents() + + 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 @@ -558,6 +569,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. @@ -565,13 +577,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): @@ -691,7 +707,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) @@ -754,7 +770,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) @@ -920,6 +936,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) @@ -930,6 +947,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 c97197cd..0a2f5521 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2803,6 +2803,18 @@ AllowedMentions .. autoclass:: AllowedMentions :members: +Intents +~~~~~~~~~~ + +.. autoclass:: Intents + :members: + +MemberCacheFlags +~~~~~~~~~~~~~~~~~~ + +.. autoclass:: MemberCacheFlags + :members: + File ~~~~~ @@ -2912,6 +2924,8 @@ The following exceptions are thrown by the library. .. autoexception:: ConnectionClosed +.. autoexception:: PrivilegedIntentsRequired + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -2928,6 +2942,7 @@ Exception Hierarchy - :exc:`InvalidArgument` - :exc:`LoginFailure` - :exc:`ConnectionClosed` + - :exc:`PrivilegedIntentsRequired` - :exc:`NoMoreItems` - :exc:`GatewayNotFound` - :exc:`HTTPException`