From a183c4f75264a67015b6cbd6969b3e44397aa2bf Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 17 Sep 2020 02:45:53 -0400 Subject: [PATCH 01/58] Completely update member references from message inner members. Fixes #5819 --- discord/member.py | 8 +++++++- discord/message.py | 3 +-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/discord/member.py b/discord/member.py index 1fb11e63..b9d1ffa7 100644 --- a/discord/member.py +++ b/discord/member.py @@ -199,6 +199,12 @@ class Member(discord.abc.Messageable, _BaseUser): data['user'] = author._to_minimal_user_json() return cls(data=data, guild=message.guild, state=message._state) + def _update_from_message(self, data): + self.joined_at = utils.parse_time(data.get('joined_at')) + self.premium_since = utils.parse_time(data.get('premium_since')) + self._update_roles(data) + self.nick = data.get('nick', None) + @classmethod def _try_upgrade(cls, *, data, guild, state): # A User object with a 'member' key @@ -433,7 +439,7 @@ class Member(discord.abc.Messageable, _BaseUser): guild = self.guild if len(self._roles) == 0: return guild.default_role - + return max(guild.get_role(rid) or guild.default_role for rid in self._roles) @property diff --git a/discord/message.py b/discord/message.py index 7e8440f4..c7de78d1 100644 --- a/discord/message.py +++ b/discord/message.py @@ -476,8 +476,7 @@ class Message: author = self.author try: # Update member reference - if author.joined_at is None: - author.joined_at = utils.parse_time(member.get('joined_at')) + author._update_from_message(member) except AttributeError: # It's a user here # TODO: consider adding to cache here From aa258073dc302518a7bd414d91cc86cd7c2ab80a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 17 Sep 2020 22:16:20 -0400 Subject: [PATCH 02/58] Add labels to the issue templates --- .github/ISSUE_TEMPLATE/bug_report.md | 1 + .github/ISSUE_TEMPLATE/feature_request.md | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1f3084ba..aa73aa15 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,6 +1,7 @@ --- name: Bug Report about: Report broken or incorrect behaviour +labels: bug --- ### Summary diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 8bb8edeb..95dea663 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,6 +1,7 @@ --- name: Feature Request about: Suggest a feature for this library +labels: feature request --- ### The Problem From 9978288346366c0a5efd03a97cd1328361f08652 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 18 Sep 2020 04:21:32 -0400 Subject: [PATCH 03/58] Update issue template to redirect to discussions --- .github/ISSUE_TEMPLATE/config.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 4712aa75..5336b869 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false contact_links: - - name: Question about the library - about: Support questions are better answered in our Discord server. Issues asking how to implement a feature in your bot will be closed. + - name: Ask a question + about: Ask questions and discuss with other users of the library. + url: https://github.com/Rapptz/discord.py/discussions + - name: Discord Server + about: Use our official Discord server to ask help and questions as well. url: https://discord.gg/r3sSKJJ From 8f105a441dff397a72c9f3e54f73850b31eafdb6 Mon Sep 17 00:00:00 2001 From: iomintz Date: Fri, 18 Sep 2020 06:40:27 -0500 Subject: [PATCH 04/58] issue/PR templates: use h2 instead of h3 `h2` is the semantically correct heading here, as `h1` is for document titles and `h2` is for the top level headings within a document. `h3` should be used for subheadings of `h2` headings. --- .github/ISSUE_TEMPLATE/bug_report.md | 12 ++++++------ .github/ISSUE_TEMPLATE/feature_request.md | 8 ++++---- .github/PULL_REQUEST_TEMPLATE.md | 4 ++-- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index aa73aa15..fa949245 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -4,24 +4,24 @@ about: Report broken or incorrect behaviour labels: bug --- -### Summary +## Summary -### Reproduction Steps +## Reproduction Steps -### Expected Results +## Expected Results -### Actual Results +## Actual Results -### Checklist +## Checklist @@ -29,7 +29,7 @@ labels: bug - [ ] I have shown the entire traceback, if possible. - [ ] I have removed my token from display, if visible. -### System Information +## System Information diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 95dea663..4badd49e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -4,24 +4,24 @@ about: Suggest a feature for this library labels: feature request --- -### The Problem +## The Problem -### The Ideal Solution +## The Ideal Solution -### The Current Solution +## The Current Solution -### Summary +## Summary diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index e39c651d..55941f4e 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,8 +1,8 @@ -### Summary +## Summary -### Checklist +## Checklist From cc26499237e579d19b43008d898a07324aef0854 Mon Sep 17 00:00:00 2001 From: iDutchy <42503862+iDutchy@users.noreply.github.com> Date: Sun, 20 Sep 2020 06:05:18 +0200 Subject: [PATCH 05/58] Add competing activity type --- discord/enums.py | 1 + docs/api.rst | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index c171e1e3..7fa39289 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -417,6 +417,7 @@ class ActivityType(Enum): listening = 2 watching = 3 custom = 4 + competing = 5 def __int__(self): return self.value diff --git a/docs/api.rst b/docs/api.rst index c97197cd..55f20aa8 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -984,6 +984,11 @@ of :class:`enum.Enum`. .. attribute:: custom A custom activity type. + .. attribute:: competing + + A competing activity type. + + .. versionadded:: 1.5 .. class:: HypeSquadHouse From 6ae615baae061b1bd0fad3e6f99261f3004a934a Mon Sep 17 00:00:00 2001 From: Xua <17090652+XuaTheGrate@users.noreply.github.com> Date: Sun, 20 Sep 2020 16:14:00 +1200 Subject: [PATCH 06/58] Fix allowed_mentions when sending files --- discord/abc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/abc.py b/discord/abc.py index e50726f7..ad148f74 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -877,7 +877,7 @@ class Messageable(metaclass=abc.ABCMeta): raise InvalidArgument('file parameter must be File') try: - data = await state.http.send_files(channel.id, files=[file], + data = await state.http.send_files(channel.id, files=[file], allowed_mentions=allowed_mentions, content=content, tts=tts, embed=embed, nonce=nonce) finally: file.close() From e7b4bbe2f69d96a55ed30b1db41c8ac680c618e8 Mon Sep 17 00:00:00 2001 From: PikalaxALT Date: Sun, 20 Sep 2020 00:15:10 -0400 Subject: [PATCH 07/58] Add bot.listen() suggestion to on_message faq --- docs/faq.rst | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/faq.rst b/docs/faq.rst index 41931d41..55a65239 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -344,6 +344,15 @@ Overriding the default provided ``on_message`` forbids any extra commands from r await bot.process_commands(message) +Alternatively, you can place your ``on_message`` logic into a **listener**. In this setup, you should not +manually call ``bot.process_commands()``. This also allows you to do multiple things asynchronously in response +to a message. Example:: + + @bot.listen('on_message') + async def whatever_you_want_to_call_it(message): + # do stuff here + # do not process commands here + Why do my arguments require quotes? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 7f17dc79a6e2dece4a8e37b8c3ac18bfe3b1d49d Mon Sep 17 00:00:00 2001 From: Tarek <13603398+Taarek@users.noreply.github.com> Date: Mon, 21 Sep 2020 09:36:58 +0200 Subject: [PATCH 08/58] Remove namedtuples to better future guard the library --- discord/client.py | 1 - discord/integrations.py | 9 ++++++--- discord/invite.py | 15 +++++++++++---- discord/widget.py | 14 +++++++++++--- 4 files changed, 28 insertions(+), 11 deletions(-) diff --git a/discord/client.py b/discord/client.py index 59be489a..f0c1fee6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. """ import asyncio -from collections import namedtuple import logging import signal import sys diff --git a/discord/integrations.py b/discord/integrations.py index 91b39d0d..37fda5e9 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -25,13 +25,12 @@ DEALINGS IN THE SOFTWARE. """ import datetime -from collections import namedtuple from .utils import _get_as_snowflake, get, parse_time from .user import User from .errors import InvalidArgument from .enums import try_enum, ExpireBehaviour -class IntegrationAccount(namedtuple('IntegrationAccount', 'id name')): +class IntegrationAccount: """Represents an integration account. .. versionadded:: 1.4 @@ -44,7 +43,11 @@ class IntegrationAccount(namedtuple('IntegrationAccount', 'id name')): The account name. """ - __slots__ = () + __slots__ = ('id', 'name') + + def __init__(self, **kwargs): + self.id = kwargs.pop('id') + self.name = kwargs.pop('name') def __repr__(self): return ''.format(self) diff --git a/discord/invite.py b/discord/invite.py index e8049169..2f7c273d 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -29,9 +29,8 @@ from .utils import parse_time, snowflake_time, _get_as_snowflake from .object import Object from .mixins import Hashable from .enums import ChannelType, VerificationLevel, try_enum -from collections import namedtuple -class PartialInviteChannel(namedtuple('PartialInviteChannel', 'id name type')): +class PartialInviteChannel: """Represents a "partial" invite channel. This model will be given when the user is not part of the @@ -65,11 +64,19 @@ class PartialInviteChannel(namedtuple('PartialInviteChannel', 'id name type')): The partial channel's type. """ - __slots__ = () + __slots__ = ('id', 'name', 'type') + + def __init__(self, **kwargs): + self.id = kwargs.pop('id') + self.name = kwargs.pop('name') + self.type = kwargs.pop('type') def __str__(self): return self.name + def __repr__(self): + return ''.format(self) + @property def mention(self): """:class:`str`: The string that allows you to mention the channel.""" @@ -154,7 +161,7 @@ class PartialInviteGuild: def icon_url(self): """:class:`Asset`: Returns the guild's icon asset.""" return self.icon_url_as() - + def is_icon_animated(self): """:class:`bool`: Returns ``True`` if the guild has an animated icon. diff --git a/discord/widget.py b/discord/widget.py index 291b70ef..0fcf7ec0 100644 --- a/discord/widget.py +++ b/discord/widget.py @@ -29,9 +29,8 @@ from .user import BaseUser from .activity import create_activity from .invite import Invite from .enums import Status, try_enum -from collections import namedtuple -class WidgetChannel(namedtuple('WidgetChannel', 'id name position')): +class WidgetChannel: """Represents a "partial" widget channel. .. container:: operations @@ -61,11 +60,20 @@ class WidgetChannel(namedtuple('WidgetChannel', 'id name position')): position: :class:`int` The channel's position """ - __slots__ = () + __slots__ = ('id', 'name', 'position') + + + def __init__(self, **kwargs): + self.id = kwargs.pop('id') + self.name = kwargs.pop('name') + self.position = kwargs.pop('position') def __str__(self): return self.name + def __repr__(self): + return ''.format(self) + @property def mention(self): """:class:`str`: The string that allows you to mention the channel.""" From ab5e175b25f3470cc6c6dbcbaf3a0d53e02169e3 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 21 Sep 2020 03:39:19 -0400 Subject: [PATCH 09/58] Use quotes when installing in the README Other shells have a hard time with square brackets. Closes #5821 --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 6ec34aa3..bde42ed4 100644 --- a/README.rst +++ b/README.rst @@ -41,7 +41,7 @@ Otherwise to get voice support you should run the following command: .. code:: sh # Linux/macOS - python3 -m pip install -U discord.py[voice] + python3 -m pip install -U "discord.py[voice]" # Windows py -3 -m pip install -U discord.py[voice] From 37c5c583f257e804fbe462bc732ef7d8097c77df Mon Sep 17 00:00:00 2001 From: Sebastian Law <44045823+SebbyLaw@users.noreply.github.com> Date: Tue, 22 Sep 2020 23:29:03 -0700 Subject: [PATCH 10/58] Add support for message_reference on Message object --- discord/__init__.py | 2 +- discord/message.py | 43 ++++++++++++++++++++++++++++++++++++++++++- docs/api.rst | 5 +++++ 3 files changed, 48 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index b936cdbe..78b25e31 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -33,7 +33,7 @@ from .guild import Guild from .flags import * from .relationship import Relationship from .member import Member, VoiceState -from .message import Message, Attachment +from .message import Message, MessageReference, Attachment from .asset import Asset from .errors import * from .calls import CallMessage, GroupCall diff --git a/discord/message.py b/discord/message.py index c7de78d1..8e6fdde7 100644 --- a/discord/message.py +++ b/discord/message.py @@ -208,6 +208,37 @@ class Attachment: data = await self.read(use_cached=use_cached) return File(io.BytesIO(data), filename=self.filename, spoiler=spoiler) +class MessageReference: + """Represents a reference to a :class:`Message`. + + .. versionadded:: 1.5 + + Attributes + ----------- + message_id: Optional[:class:`int`] + The id of the message referenced. + channel_id: :class:`int` + The channel id of the message referenced. + guild_id: Optional[:class:`int`] + The guild id of the message referenced. + """ + + __slots__ = ('message_id', 'channel_id', 'guild_id', '_state') + + def __init__(self, state, **kwargs): + self.message_id = utils._get_as_snowflake(kwargs, 'message_id') + self.channel_id = int(kwargs.pop('channel_id')) + self.guild_id = utils._get_as_snowflake(kwargs, 'guild_id') + self._state = state + + @property + def cached_message(self): + """Optional[:class:`Message`]: The cached message, if found in the internal message cache.""" + return self._state._get_message(self.message_id) + + def __repr__(self): + return ''.format(self) + def flatten_handlers(cls): prefix = len('_handle_') cls._HANDLERS = { @@ -251,6 +282,13 @@ class Message: call: Optional[:class:`CallMessage`] The call that the message refers to. This is only applicable to messages of type :attr:`MessageType.call`. + reference: Optional[:class:`MessageReference`] + The message that this message references. This is only applicable to messages of + type :attr:`MessageType.pins_add` or crossposted messages created by a + followed channel integration. + + .. versionadded:: 1.5 + mention_everyone: :class:`bool` Specifies if the message mentions everyone. @@ -316,7 +354,7 @@ class Message: '_cs_channel_mentions', '_cs_raw_mentions', 'attachments', '_cs_clean_content', '_cs_raw_channel_mentions', 'nonce', 'pinned', 'role_mentions', '_cs_raw_role_mentions', 'type', 'call', 'flags', - '_cs_system_content', '_cs_guild', '_state', 'reactions', + '_cs_system_content', '_cs_guild', '_state', 'reactions', 'reference', 'application', 'activity') def __init__(self, *, state, channel, data): @@ -338,6 +376,9 @@ class Message: self.content = data['content'] self.nonce = data.get('nonce') + ref = data.get('message_reference') + self.reference = MessageReference(state, **ref) if ref is not None else None + for handler in ('author', 'member', 'mentions', 'mention_roles', 'call', 'flags'): try: getattr(self, '_handle_%s' % handler)(data[handler]) diff --git a/docs/api.rst b/docs/api.rst index 55f20aa8..6b843bd1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2736,6 +2736,11 @@ Widget .. autoclass:: Widget() :members: +MessageReference +~~~~~~~~~~~~~~~~~ +.. autoclass:: MessageReference() + :members: + RawMessageDeleteEvent ~~~~~~~~~~~~~~~~~~~~~~~ From 93fa46713a198baf08ab1e760be1adfdcb852c97 Mon Sep 17 00:00:00 2001 From: Michael <8661717+sgtlaggy@users.noreply.github.com> Date: Wed, 23 Sep 2020 00:19:35 -0700 Subject: [PATCH 11/58] Fix and add documentation --- discord/abc.py | 5 +++++ discord/channel.py | 8 ++++---- discord/client.py | 14 ++++++++++++-- discord/ext/commands/cog.py | 8 +++++++- discord/ext/commands/converter.py | 1 + discord/ext/commands/core.py | 12 +++++++++++- discord/ext/commands/errors.py | 6 +++--- discord/ext/commands/help.py | 27 +++++++++++++++++++++++---- discord/member.py | 7 ++++++- discord/message.py | 2 +- discord/partial_emoji.py | 4 ++-- discord/permissions.py | 10 ++++++---- discord/role.py | 2 +- discord/user.py | 16 +++++++++++++--- docs/conf.py | 4 ++-- 15 files changed, 97 insertions(+), 29 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index ad148f74..4024334d 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -698,6 +698,11 @@ class GuildChannel: You do not have the proper permissions to create this channel. ~discord.HTTPException Creating the channel failed. + + Returns + -------- + :class:`.abc.GuildChannel` + The channel that was created. """ raise NotImplementedError diff --git a/discord/channel.py b/discord/channel.py index c02c00f3..c3ccbe0f 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -158,11 +158,11 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): return [m for m in self.guild.members if self.permissions_for(m).read_messages] def is_nsfw(self): - """Checks if the channel is NSFW.""" + """:class:`bool`: Checks if the channel is NSFW.""" return self.nsfw def is_news(self): - """Checks if the channel is a news channel.""" + """:class:`bool`: Checks if the channel is a news channel.""" return self._type == ChannelType.news.value @property @@ -757,7 +757,7 @@ class CategoryChannel(discord.abc.GuildChannel, Hashable): return ChannelType.category def is_nsfw(self): - """Checks if the category is NSFW.""" + """:class:`bool`: Checks if the category is NSFW.""" return self.nsfw async def clone(self, *, name=None, reason=None): @@ -933,7 +933,7 @@ class StoreChannel(discord.abc.GuildChannel, Hashable): permissions_for.__doc__ = discord.abc.GuildChannel.permissions_for.__doc__ def is_nsfw(self): - """Checks if the channel is NSFW.""" + """:class:`bool`: Checks if the channel is NSFW.""" return self.nsfw async def clone(self, *, name=None, reason=None): diff --git a/discord/client.py b/discord/client.py index f0c1fee6..9bc5dd12 100644 --- a/discord/client.py +++ b/discord/client.py @@ -303,7 +303,7 @@ class Client: return self._connection.voice_clients def is_ready(self): - """Specifies if the client's internal cache is ready for use.""" + """:class:`bool`: Specifies if the client's internal cache is ready for use.""" return self._ready.is_set() async def _run_event(self, coro, event_name, *args, **kwargs): @@ -683,7 +683,7 @@ class Client: # properties def is_closed(self): - """Indicates if the websocket connection is closed.""" + """:class:`bool`: Indicates if the websocket connection is closed.""" return self._closed @property @@ -800,6 +800,11 @@ class Client: Just because you receive a :class:`.abc.GuildChannel` does not mean that you can communicate in said channel. :meth:`.abc.GuildChannel.permissions_for` should be used for that. + + Yields + ------ + :class:`.abc.GuildChannel` + A channel the client can 'access'. """ for guild in self.guilds: @@ -814,6 +819,11 @@ class Client: for guild in client.guilds: for member in guild.members: yield member + + Yields + ------ + :class:`.Member` + A member the client can see. """ for guild in self.guilds: for member in guild.members: diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 50573f49..2a836daa 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -216,7 +216,13 @@ class Cog(metaclass=CogMeta): return cleaned def walk_commands(self): - """An iterator that recursively walks through this cog's commands and subcommands.""" + """An iterator that recursively walks through this cog's commands and subcommands. + + Yields + ------ + Union[:class:`.Command`, :class:`.Group`] + A command or group from the cog. + """ from .core import GroupMixin for command in self.__cog_commands__: if command.parent is None: diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 12bb0646..f3d7b257 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -362,6 +362,7 @@ class CategoryChannelConverter(IDConverter): class ColourConverter(Converter): """Converts to a :class:`~discord.Colour`. + .. versionchanged:: 1.5 Add an alias named ColorConverter diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 1d044edd..fc1b932c 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -1180,6 +1180,11 @@ class GroupMixin: .. versionchanged:: 1.4 Duplicates due to aliases are no longer returned + + Yields + ------ + Union[:class:`.Command`, :class:`.Group`] + A command or group from the internal list of commands. """ for command in self.commands: yield command @@ -1233,7 +1238,7 @@ class GroupMixin: Returns -------- Callable[..., :class:`Command`] - A decorator that converts the provided method into a Command, adds it to the bot, then returns it + A decorator that converts the provided method into a Command, adds it to the bot, then returns it. """ def decorator(func): kwargs.setdefault('parent', self) @@ -1246,6 +1251,11 @@ class GroupMixin: def group(self, *args, **kwargs): """A shortcut decorator that invokes :func:`.group` and adds it to the internal command list via :meth:`~.GroupMixin.add_command`. + + Returns + -------- + Callable[..., :class:`Group`] + A decorator that converts the provided method into a Group, adds it to the bot, then returns it. """ def decorator(func): kwargs.setdefault('parent', self) diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index 6a9eb6af..beec98f5 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -273,7 +273,7 @@ class ChannelNotReadable(BadArgument): Attributes ----------- - argument: :class:`Channel` + argument: :class:`.abc.GuildChannel` The channel supplied by the caller that was not readable """ def __init__(self, argument): @@ -403,7 +403,7 @@ class CommandInvokeError(CommandError): Attributes ----------- - original + original: :exc:`Exception` The original exception that was raised. You can also get this via the ``__cause__`` attribute. """ @@ -438,7 +438,7 @@ class MaxConcurrencyReached(CommandError): ------------ number: :class:`int` The maximum number of concurrent invokers allowed. - per: :class:`BucketType` + per: :class:`.BucketType` The bucket type passed to the :func:`.max_concurrency` decorator. """ diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index c7143317..5d567325 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -155,7 +155,7 @@ class Paginator: @property def pages(self): - """class:`list`: Returns the rendered list of pages.""" + """List[:class:`str`]: Returns the rendered list of pages.""" # we have more than just the prefix in our current page if len(self._current_page) > (0 if self.prefix is None else 1): self.close_page() @@ -381,7 +381,7 @@ class HelpCommand: @property def clean_prefix(self): - """The cleaned up invoke prefix. i.e. mentions are ``@name`` instead of ``<@id>``.""" + """:class:`str`: The cleaned up invoke prefix. i.e. mentions are ``@name`` instead of ``<@id>``.""" user = self.context.guild.me if self.context.guild else self.context.bot.user # this breaks if the prefix mention is not the bot itself but I # consider this to be an *incredibly* strange use case. I'd rather go @@ -441,6 +441,11 @@ class HelpCommand: """Removes mentions from the string to prevent abuse. This includes ``@everyone``, ``@here``, member mentions and role mentions. + + Returns + ------- + :class:`str` + The string with mentions removed. """ def replace(obj, *, transforms=self.MENTION_TRANSFORMS): @@ -603,6 +608,11 @@ class HelpCommand: You can override this method to customise the behaviour. By default this returns the context's channel. + + Returns + ------- + :class:`.abc.Messageable` + The destination where the help command will be output. """ return self.context.channel @@ -911,13 +921,13 @@ class DefaultHelpCommand(HelpCommand): super().__init__(**options) def shorten_text(self, text): - """Shortens text to fit into the :attr:`width`.""" + """:class:`str`: Shortens text to fit into the :attr:`width`.""" if len(text) > self.width: return text[:self.width - 3] + '...' return text def get_ending_note(self): - """Returns help command's ending note. This is mainly useful to override for i18n purposes.""" + """:class:`str`: Returns help command's ending note. This is mainly useful to override for i18n purposes.""" command_name = self.invoked_with return "Type {0}{1} command for more info on a command.\n" \ "You can also type {0}{1} category for more info on a category.".format(self.clean_prefix, command_name) @@ -1122,6 +1132,10 @@ class MinimalHelpCommand(HelpCommand): Use `{prefix}{command_name} [command]` for more info on a command. You can also use `{prefix}{command_name} [category]` for more info on a category. + Returns + ------- + :class:`str` + The help command opening note. """ command_name = self.invoked_with return "Use `{0}{1} [command]` for more info on a command.\n" \ @@ -1134,6 +1148,11 @@ class MinimalHelpCommand(HelpCommand): """Return the help command's ending note. This is mainly useful to override for i18n purposes. The default implementation does nothing. + + Returns + ------- + :class:`str` + The help command ending note. """ return None diff --git a/discord/member.py b/discord/member.py index b9d1ffa7..dbcbc492 100644 --- a/discord/member.py +++ b/discord/member.py @@ -309,7 +309,7 @@ class Member(discord.abc.Messageable, _BaseUser): return try_enum(Status, self._client_status.get('web', 'offline')) def is_on_mobile(self): - """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.""" return 'mobile' in self._client_status @property @@ -395,6 +395,11 @@ class Member(discord.abc.Messageable, _BaseUser): ----------- message: :class:`Message` The message to check if you're mentioned in. + + Returns + ------- + :class:`bool` + Indicates if the member is mentioned in the message. """ if message.guild is None or message.guild.id != self.guild.id: return False diff --git a/discord/message.py b/discord/message.py index 8e6fdde7..4ba2a826 100644 --- a/discord/message.py +++ b/discord/message.py @@ -613,7 +613,7 @@ class Message: @utils.cached_slot_property('_cs_clean_content') def clean_content(self): - """A property that returns the content in a "cleaned up" + """:class:`str`: A property that returns the content in a "cleaned up" manner. This basically means that mentions are transformed into the way the client shows it. e.g. ``<#id>`` will transform into ``#name``. diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 42f38a48..1eebf5da 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -124,11 +124,11 @@ class PartialEmoji(_EmojiTag): return hash((self.id, self.name)) def is_custom_emoji(self): - """Checks if this is a custom non-Unicode emoji.""" + """:class:`bool`: Checks if this is a custom non-Unicode emoji.""" return self.id is not None def is_unicode_emoji(self): - """Checks if this is a Unicode emoji.""" + """:class:`bool`: Checks if this is a Unicode emoji.""" return self.id is None def _as_reaction(self): diff --git a/discord/permissions.py b/discord/permissions.py index d550bca6..55e26f91 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -495,10 +495,7 @@ class PermissionOverwrite: self._values[key] = value def pair(self): - """Returns the (allow, deny) pair from this overwrite. - - The value of these pairs is :class:`Permissions`. - """ + """Tuple[:class:`Permissions`, :class:`Permissions`]: Returns the (allow, deny) pair from this overwrite.""" allow = Permissions.none() deny = Permissions.none() @@ -530,6 +527,11 @@ class PermissionOverwrite: An empty permission overwrite is one that has no overwrites set to ``True`` or ``False``. + + Returns + ------- + :class:`bool` + Indicates if the overwrite is empty. """ return all(x is None for x in self._values.values()) diff --git a/discord/role.py b/discord/role.py index 24ae3bdd..882f346e 100644 --- a/discord/role.py +++ b/discord/role.py @@ -148,7 +148,7 @@ class Role(Hashable): self.mentionable = data.get('mentionable', False) def is_default(self): - """Checks if the role is the default role.""" + """:class:`bool`: Checks if the role is the default role.""" return self.guild.id == self.id @property diff --git a/discord/user.py b/discord/user.py index 903a0b00..690cbce9 100644 --- a/discord/user.py +++ b/discord/user.py @@ -150,7 +150,7 @@ class BaseUser(_BaseUser): return self.avatar_url_as(format=None, size=1024) def is_avatar_animated(self): - """Indicates if the user has an animated avatar.""" + """:class:`bool`: Indicates if the user has an animated avatar.""" return bool(self.avatar and self.avatar.startswith('a_')) def avatar_url_as(self, *, format=None, static_format='webp', size=1024): @@ -262,6 +262,11 @@ class BaseUser(_BaseUser): ----------- message: :class:`Message` The message to check if you're mentioned in. + + Returns + ------- + :class:`bool` + Indicates if the user is mentioned in the message. """ if message.mention_everyone: @@ -706,6 +711,11 @@ class User(BaseUser, discord.abc.Messageable): This should be rarely called, as this is done transparently for most people. + + Returns + ------- + :class:`.DMChannel` + The channel that was created. """ found = self.dm_channel if found is not None: @@ -751,7 +761,7 @@ class User(BaseUser, discord.abc.Messageable): return [User(state=state, data=friend) for friend in mutuals] def is_friend(self): - """Checks if the user is your friend. + """:class:`bool`: Checks if the user is your friend. .. note:: @@ -763,7 +773,7 @@ class User(BaseUser, discord.abc.Messageable): return r.type is RelationshipType.friend def is_blocked(self): - """Checks if the user is blocked. + """:class:`bool`: Checks if the user is blocked. .. note:: diff --git a/docs/conf.py b/docs/conf.py index a16c34cf..d87049ac 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -50,7 +50,7 @@ extlinks = { # Links used for cross-referencing stuff in other documentation intersphinx_mapping = { 'py': ('https://docs.python.org/3', None), - 'aio': ('https://aiohttp.readthedocs.io/en/stable/', None), + 'aio': ('https://docs.aiohttp.org/en/stable/', None), 'req': ('http://docs.python-requests.org/en/latest/', 'requests.inv') } @@ -318,6 +318,6 @@ texinfo_documents = [ #texinfo_no_detailmenu = False def setup(app): - app.add_javascript('custom.js') + app.add_js_file('custom.js') if app.config.language == 'ja': app.config.intersphinx_mapping['py'] = ('https://docs.python.org/ja/3', None) From 0b93fa3a82e8b1b1d9f637e7be0333efd0a232b2 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 10 Aug 2020 06:28:36 -0400 Subject: [PATCH 12/58] Implement VoiceProtocol lower level hooks. This allows changing the connect flow and taking control of it without relying on internal events or tricks. --- discord/__init__.py | 2 +- discord/abc.py | 19 ++- discord/client.py | 6 +- discord/ext/commands/context.py | 2 +- discord/guild.py | 2 +- discord/shard.py | 1 + discord/state.py | 14 +- discord/voice_client.py | 287 ++++++++++++++++++++++---------- docs/api.rst | 3 + 9 files changed, 230 insertions(+), 106 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index 78b25e31..c6b21593 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -54,7 +54,7 @@ from .mentions import AllowedMentions from .shard import AutoShardedClient, ShardInfo from .player import * from .webhook import * -from .voice_client import VoiceClient +from .voice_client import VoiceClient, VoiceProtocol from .audit_logs import AuditLogChanges, AuditLogEntry, AuditLogDiff from .raw_models import * from .team import * diff --git a/discord/abc.py b/discord/abc.py index 4024334d..75624e18 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -36,7 +36,7 @@ from .permissions import PermissionOverwrite, Permissions from .role import Role from .invite import Invite from .file import File -from .voice_client import VoiceClient +from .voice_client import VoiceClient, VoiceProtocol from . import utils class _Undefined: @@ -1053,7 +1053,6 @@ class Messageable(metaclass=abc.ABCMeta): """ return HistoryIterator(self, limit=limit, before=before, after=after, around=around, oldest_first=oldest_first) - class Connectable(metaclass=abc.ABCMeta): """An ABC that details the common operations on a channel that can connect to a voice server. @@ -1072,7 +1071,7 @@ class Connectable(metaclass=abc.ABCMeta): def _get_voice_state_pair(self): raise NotImplementedError - async def connect(self, *, timeout=60.0, reconnect=True): + async def connect(self, *, timeout=60.0, reconnect=True, cls=VoiceClient): """|coro| Connects to voice and creates a :class:`VoiceClient` to establish @@ -1086,6 +1085,9 @@ class Connectable(metaclass=abc.ABCMeta): Whether the bot should automatically attempt a reconnect if a part of the handshake fails or the gateway goes down. + cls: Type[:class:`VoiceProtocol`] + A type that subclasses :class:`~discord.VoiceProtocol` to connect with. + Defaults to :class:`~discord.VoiceClient`. Raises ------- @@ -1098,20 +1100,25 @@ class Connectable(metaclass=abc.ABCMeta): Returns -------- - :class:`~discord.VoiceClient` + :class:`~discord.VoiceProtocol` A voice client that is fully connected to the voice server. """ + + if not issubclass(cls, VoiceProtocol): + raise TypeError('Type must meet VoiceProtocol abstract base class.') + key_id, _ = self._get_voice_client_key() state = self._state if state._get_voice_client(key_id): raise ClientException('Already connected to a voice channel.') - voice = VoiceClient(state=state, timeout=timeout, channel=self) + client = state._get_client() + voice = cls(client, self) state._add_voice_client(key_id, voice) try: - await voice.connect(reconnect=reconnect) + await voice.connect(timeout=timeout, reconnect=reconnect) except asyncio.TimeoutError: try: await voice.disconnect(force=True) diff --git a/discord/client.py b/discord/client.py index 9bc5dd12..407fd47f 100644 --- a/discord/client.py +++ b/discord/client.py @@ -238,6 +238,7 @@ class Client: self._closed = False self._ready = asyncio.Event() self._connection._get_websocket = self._get_websocket + self._connection._get_client = lambda: self if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -299,7 +300,10 @@ class Client: @property def voice_clients(self): - """List[:class:`.VoiceClient`]: Represents a list of voice connections.""" + """List[:class:`.VoiceProtocol`]: Represents a list of voice connections. + + These are usually :class:`.VoiceClient` instances. + """ return self._connection.voice_clients def is_ready(self): diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 8b8cf4bc..3cf851c6 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -238,7 +238,7 @@ class Context(discord.abc.Messageable): @property def voice_client(self): - r"""Optional[:class:`.VoiceClient`]: A shortcut to :attr:`.Guild.voice_client`\, if applicable.""" + r"""Optional[:class:`.VoiceProtocol`]: A shortcut to :attr:`.Guild.voice_client`\, if applicable.""" g = self.guild return g.voice_client if g else None diff --git a/discord/guild.py b/discord/guild.py index 4c6013a3..0bf94a28 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -377,7 +377,7 @@ class Guild(Hashable): @property def voice_client(self): - """Optional[:class:`VoiceClient`]: Returns the :class:`VoiceClient` associated with this guild, if any.""" + """Optional[:class:`VoiceProtocol`]: Returns the :class:`VoiceProtocol` associated with this guild, if any.""" return self._state._get_voice_client(self.id) @property diff --git a/discord/shard.py b/discord/shard.py index f6320678..ef29d590 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -292,6 +292,7 @@ class AutoShardedClient(Client): # the key is the shard_id self.__shards = {} self._connection._get_websocket = self._get_websocket + self._connection._get_client = lambda: self self.__queue = asyncio.PriorityQueue() def _get_websocket(self, guild_id=None, *, shard_id=None): diff --git a/discord/state.py b/discord/state.py index f0e93d35..fc297d03 100644 --- a/discord/state.py +++ b/discord/state.py @@ -63,6 +63,12 @@ Listener = namedtuple('Listener', ('type', 'future', 'predicate')) log = logging.getLogger(__name__) ReadyState = namedtuple('ReadyState', ('launch', 'guilds')) +async def logging_coroutine(coroutine, *, info): + try: + await coroutine + except Exception: + log.exception('Exception occurred during %s', info) + class ConnectionState: def __init__(self, *, dispatch, handlers, hooks, syncer, http, loop, **options): self.loop = loop @@ -939,9 +945,8 @@ class ConnectionState: if int(data['user_id']) == self.user.id: voice = self._get_voice_client(guild.id) if voice is not None: - ch = guild.get_channel(channel_id) - if ch is not None: - voice.channel = ch + coro = voice.on_voice_state_update(data) + asyncio.ensure_future(logging_coroutine(coro, info='Voice Protocol voice state update handler')) member, before, after = guild._update_voice_state(data, channel_id) if member is not None: @@ -962,7 +967,8 @@ class ConnectionState: vc = self._get_voice_client(key_id) if vc is not None: - asyncio.ensure_future(vc._create_socket(key_id, data)) + coro = vc.on_voice_server_update(data) + asyncio.ensure_future(logging_coroutine(coro, info='Voice Protocol voice server update handler')) def parse_typing_start(self, data): channel, guild = self._get_guild_channel(data) diff --git a/discord/voice_client.py b/discord/voice_client.py index ab9a6406..a1a7109a 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -45,7 +45,7 @@ import logging import struct import threading -from . import opus +from . import opus, utils from .backoff import ExponentialBackoff from .gateway import * from .errors import ClientException, ConnectionClosed @@ -59,7 +59,110 @@ except ImportError: log = logging.getLogger(__name__) -class VoiceClient: +class VoiceProtocol: + """A class that represents the Discord voice protocol. + + This is an abstract class. The library provides a concrete implementation + under :class:`VoiceClient`. + + This class allows you to implement a protocol to allow for an external + method of sending voice, such as Lavalink_ or a native library implementation. + + These classes are passed to :meth:`abc.Connectable.connect`. + + .. _Lavalink: https://github.com/Frederikam/Lavalink + + Parameters + ------------ + client: :class:`Client` + The client (or its subclasses) that started the connection request. + channel: :class:`abc.Connectable` + The voice channel that is being connected to. + """ + + def __init__(self, client, channel): + self.client = client + self.channel = channel + + async def on_voice_state_update(self, data): + """|coro| + + An abstract method that is called when the client's voice state + has changed. This corresponds to ``VOICE_STATE_UPDATE``. + + Parameters + ------------ + data: :class:`dict` + The raw `voice state payload`_. + + .. _voice state payload: https://discord.com/developers/docs/resources/voice#voice-state-object + """ + raise NotImplementedError + + async def on_voice_server_update(self, data): + """|coro| + + An abstract method that is called when initially connecting to voice. + This corresponds to ``VOICE_SERVER_UPDATE``. + + Parameters + ------------ + data: :class:`dict` + The raw `voice server update payload`__. + + .. _VSU: https://discord.com/developers/docs/topics/gateway#voice-server-update-voice-server-update-event-fields + + __ VSU_ + """ + raise NotImplementedError + + async def connect(self, *, timeout, reconnect): + """|coro| + + An abstract method called when the client initiates the connection request. + + When a connection is requested initially, the library calls the following functions + in order: + + - ``__init__`` + + Parameters + ------------ + timeout: :class:`float` + The timeout for the connection. + reconnect: :class:`bool` + Whether reconnection is expected. + """ + raise NotImplementedError + + async def disconnect(self, *, force): + """|coro| + + An abstract method called when the client terminates the connection. + + See :meth:`cleanup`. + + Parameters + ------------ + force: :class:`bool` + Whether the disconnection was forced. + """ + raise NotImplementedError + + def cleanup(self): + """This method *must* be called to ensure proper clean-up during a disconnect. + + It is advisable to call this from within :meth:`disconnect` when you are + completely done with the voice protocol instance. + + This method removes it from the internal state cache that keeps track of + currently alive voice clients. Failure to clean-up will cause subsequent + connections to report that it's still connected. + """ + key_id, _ = self.channel._get_voice_client_key() + self.client._connection._remove_voice_client(key_id) + +class VoiceClient(VoiceProtocol): """Represents a Discord voice connection. You do not create these, you typically get them from @@ -85,14 +188,13 @@ class VoiceClient: loop: :class:`asyncio.AbstractEventLoop` The event loop that the voice client is running on. """ - def __init__(self, state, timeout, channel): + def __init__(self, client, channel): if not has_nacl: raise RuntimeError("PyNaCl library needed in order to use voice") - self.channel = channel - self.main_ws = None - self.timeout = timeout - self.ws = None + super().__init__(client, channel) + state = client._connection + self.token = None self.socket = None self.loop = state.loop self._state = state @@ -100,8 +202,8 @@ class VoiceClient: self._connected = threading.Event() self._handshaking = False - self._handshake_check = asyncio.Lock() - self._handshake_complete = asyncio.Event() + self._voice_state_complete = asyncio.Event() + self._voice_server_complete = asyncio.Event() self.mode = None self._connections = 0 @@ -138,48 +240,24 @@ class VoiceClient: # connection related - async def start_handshake(self): - log.info('Starting voice handshake...') + async def on_voice_state_update(self, data): + self.session_id = data['session_id'] + channel_id = data['channel_id'] - guild_id, channel_id = self.channel._get_voice_state_pair() - state = self._state - self.main_ws = ws = state._get_websocket(guild_id) - self._connections += 1 + if not self._handshaking: + # If we're done handshaking then we just need to update ourselves + guild = self.guild + self.channel = channel_id and guild and guild.get_channel(int(channel_id)) + else: + self._voice_state_complete.set() - # request joining - await ws.voice_state(guild_id, channel_id) + async def on_voice_server_update(self, data): + if self._voice_server_complete.is_set(): + log.info('Ignoring extraneous voice server update.') + return - try: - await asyncio.wait_for(self._handshake_complete.wait(), timeout=self.timeout) - except asyncio.TimeoutError: - await self.terminate_handshake(remove=True) - raise - - log.info('Voice handshake complete. Endpoint found %s (IP: %s)', self.endpoint, self.endpoint_ip) - - async def terminate_handshake(self, *, remove=False): - guild_id, channel_id = self.channel._get_voice_state_pair() - self._handshake_complete.clear() - await self.main_ws.voice_state(guild_id, None, self_mute=True) - self._handshaking = False - - log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', channel_id, guild_id) - if remove: - log.info('The voice client has been removed for Channel ID %s (Guild ID %s)', channel_id, guild_id) - key_id, _ = self.channel._get_voice_client_key() - self._state._remove_voice_client(key_id) - - async def _create_socket(self, server_id, data): - async with self._handshake_check: - if self._handshaking: - log.info("Ignoring voice server update while handshake is in progress") - return - self._handshaking = True - - self._connected.clear() - self.session_id = self.main_ws.session_id - self.server_id = server_id self.token = data.get('token') + self.server_id = int(data['guild_id']) endpoint = data.get('endpoint') if endpoint is None or self.token is None: @@ -195,23 +273,77 @@ class VoiceClient: # This gets set later self.endpoint_ip = None - if self.socket: - try: - self.socket.close() - except Exception: - pass - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.socket.setblocking(False) - if self._handshake_complete.is_set(): - # terminate the websocket and handle the reconnect loop if necessary. - self._handshake_complete.clear() - self._handshaking = False + if not self._handshaking: + # If we're not handshaking then we need to terminate our previous connection in the websocket await self.ws.close(4000) return - self._handshake_complete.set() + self._voice_server_complete.set() + + async def voice_connect(self): + self._connections += 1 + await self.channel.guild.change_voice_state(channel=self.channel) + + async def voice_disconnect(self): + log.info('The voice handshake is being terminated for Channel ID %s (Guild ID %s)', self.channel.id, self.guild.id) + await self.channel.guild.change_voice_state(channel=None) + + async def connect(self, *, reconnect, timeout): + log.info('Connecting to voice...') + self.timeout = timeout + try: + del self.secret_key + except AttributeError: + pass + + + for i in range(5): + self._voice_state_complete.clear() + self._voice_server_complete.clear() + self._handshaking = True + + # This has to be created before we start the flow. + futures = [ + self._voice_state_complete.wait(), + self._voice_server_complete.wait(), + ] + + # Start the connection flow + log.info('Starting voice handshake... (connection attempt %d)', self._connections + 1) + await self.voice_connect() + + try: + await utils.sane_wait_for(futures, timeout=timeout) + except asyncio.TimeoutError: + await self.disconnect(force=True) + raise + + log.info('Voice handshake complete. Endpoint found %s', self.endpoint) + self._handshaking = False + self._voice_server_complete.clear() + self._voice_state_complete.clear() + + try: + self.ws = await DiscordVoiceWebSocket.from_client(self) + self._connected.clear() + while not hasattr(self, 'secret_key'): + await self.ws.poll_event() + self._connected.set() + break + except (ConnectionClosed, asyncio.TimeoutError): + if reconnect: + log.exception('Failed to connect to voice... Retrying...') + await asyncio.sleep(1 + i * 2.0) + await self.voice_disconnect() + continue + else: + raise + + if self._runner is None: + self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) @property def latency(self): @@ -234,35 +366,6 @@ class VoiceClient: ws = self.ws return float("inf") if not ws else ws.average_latency - async def connect(self, *, reconnect=True, _tries=0, do_handshake=True): - log.info('Connecting to voice...') - try: - del self.secret_key - except AttributeError: - pass - - if do_handshake: - await self.start_handshake() - - try: - self.ws = await DiscordVoiceWebSocket.from_client(self) - self._handshaking = False - self._connected.clear() - while not hasattr(self, 'secret_key'): - await self.ws.poll_event() - self._connected.set() - except (ConnectionClosed, asyncio.TimeoutError): - if reconnect and _tries < 5: - log.exception('Failed to connect to voice... Retrying...') - await asyncio.sleep(1 + _tries * 2.0) - await self.terminate_handshake() - await self.connect(reconnect=reconnect, _tries=_tries + 1) - else: - raise - - if self._runner is None: - self._runner = self.loop.create_task(self.poll_voice_ws(reconnect)) - async def poll_voice_ws(self, reconnect): backoff = ExponentialBackoff() while True: @@ -287,9 +390,9 @@ class VoiceClient: log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry) self._connected.clear() await asyncio.sleep(retry) - await self.terminate_handshake() + await self.voice_disconnect() try: - await self.connect(reconnect=True) + await self.connect(reconnect=True, timeout=self.timeout) except asyncio.TimeoutError: # at this point we've retried 5 times... let's continue the loop. log.warning('Could not connect to voice... Retrying...') @@ -310,8 +413,9 @@ class VoiceClient: if self.ws: await self.ws.close() - await self.terminate_handshake(remove=True) + await self.voice_disconnect() finally: + self.cleanup() if self.socket: self.socket.close() @@ -325,8 +429,7 @@ class VoiceClient: channel: :class:`abc.Snowflake` The channel to move to. Must be a voice channel. """ - guild_id, _ = self.channel._get_voice_state_pair() - await self.main_ws.voice_state(guild_id, channel.id) + await self.channel.guild.change_voice_state(channel=channel) def is_connected(self): """Indicates if the voice client is connected to voice.""" diff --git a/docs/api.rst b/docs/api.rst index 6b843bd1..d4af5ff1 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,6 +54,9 @@ Voice .. autoclass:: VoiceClient() :members: +.. autoclass:: VoiceProtocol + :members: + .. autoclass:: AudioSource :members: From e3922e24d96cd10b2e20b694dc657331980d5fab Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 12 Aug 2020 22:16:39 -0400 Subject: [PATCH 13/58] Correct some protocol errors in v4 of voice gateway --- discord/gateway.py | 17 ++++++++--------- discord/voice_client.py | 10 +++++++--- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index 81ff69b8..382d60f1 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -635,8 +635,8 @@ class DiscordVoiceWebSocket: Sent only. Tells the client to resume its session. HELLO Receive only. Tells you that your websocket connection was acknowledged. - INVALIDATE_SESSION - Sent only. Tells you that your RESUME request has failed and to re-IDENTIFY. + RESUMED + Sent only. Tells you that your RESUME request has succeeded. CLIENT_CONNECT Indicates a user has connected to voice. CLIENT_DISCONNECT @@ -652,7 +652,7 @@ class DiscordVoiceWebSocket: HEARTBEAT_ACK = 6 RESUME = 7 HELLO = 8 - INVALIDATE_SESSION = 9 + RESUMED = 9 CLIENT_CONNECT = 12 CLIENT_DISCONNECT = 13 @@ -755,9 +755,8 @@ class DiscordVoiceWebSocket: await self.initial_connection(data) elif op == self.HEARTBEAT_ACK: self._keep_alive.ack() - elif op == self.INVALIDATE_SESSION: - log.info('Voice RESUME failed.') - await self.identify() + elif op == self.RESUMED: + log.info('Voice RESUME succeeded.') elif op == self.SESSION_DESCRIPTION: self._connection.mode = data['mode'] await self.load_secret_key(data) @@ -773,7 +772,9 @@ class DiscordVoiceWebSocket: state.endpoint_ip = data['ip'] packet = bytearray(70) - struct.pack_into('>I', packet, 0, state.ssrc) + struct.pack_into('>H', packet, 0, 1) # 1 = Send + struct.pack_into('>H', packet, 2, 70) # 70 = Length + struct.pack_into('>I', packet, 4, state.ssrc) state.socket.sendto(packet, (state.endpoint_ip, state.voice_port)) recv = await self.loop.sock_recv(state.socket, 70) log.debug('received packet in initial_connection: %s', recv) @@ -794,8 +795,6 @@ class DiscordVoiceWebSocket: await self.select_protocol(state.ip, state.port, mode) log.info('selected the voice protocol for use (%s)', mode) - await self.client_connect() - @property def latency(self): """:class:`float`: Latency between a HEARTBEAT and its HEARTBEAT_ACK in seconds.""" diff --git a/discord/voice_client.py b/discord/voice_client.py index a1a7109a..58cb81c4 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -121,10 +121,14 @@ class VoiceProtocol: An abstract method called when the client initiates the connection request. - When a connection is requested initially, the library calls the following functions - in order: + When a connection is requested initially, the library calls the constructor + under ``__init__`` and then calls :meth:`connect`. If :meth:`connect` fails at + some point then :meth:`disconnect` is called. - - ``__init__`` + Within this method, to start the voice connection flow it is recommended to + use :meth:`Guild.change_voice_state` to start the flow. After which, + :meth:`on_voice_server_update` and :meth:`on_voice_state_update` will be called. + The order that these two are called is unspecified. Parameters ------------ From 9830e0ed8a45768fd4568d193a02e8210bed54fb Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 23 Aug 2020 06:27:20 +1000 Subject: [PATCH 14/58] Fix issues with VoiceProtocol docstrsings. --- discord/voice_client.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/discord/voice_client.py b/discord/voice_client.py index 58cb81c4..a48a92d1 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -93,9 +93,11 @@ class VoiceProtocol: Parameters ------------ data: :class:`dict` - The raw `voice state payload`_. + The raw `voice state payload`__. - .. _voice state payload: https://discord.com/developers/docs/resources/voice#voice-state-object + .. _voice_state_update_payload: https://discord.com/developers/docs/resources/voice#voice-state-object + + __ voice_state_update_payload_ """ raise NotImplementedError @@ -110,9 +112,9 @@ class VoiceProtocol: data: :class:`dict` The raw `voice server update payload`__. - .. _VSU: https://discord.com/developers/docs/topics/gateway#voice-server-update-voice-server-update-event-fields + .. _voice_server_update_payload: https://discord.com/developers/docs/topics/gateway#voice-server-update-voice-server-update-event-fields - __ VSU_ + __ voice_server_update_payload_ """ raise NotImplementedError From 9036b9651b147c78ffcde211ecf458dceb48a8aa Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 26 Aug 2020 07:49:43 -0400 Subject: [PATCH 15/58] Properly handle disconnects in voice when force disconnected --- discord/voice_client.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/discord/voice_client.py b/discord/voice_client.py index a48a92d1..a16aaf41 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -96,7 +96,7 @@ class VoiceProtocol: The raw `voice state payload`__. .. _voice_state_update_payload: https://discord.com/developers/docs/resources/voice#voice-state-object - + __ voice_state_update_payload_ """ raise NotImplementedError @@ -252,8 +252,12 @@ class VoiceClient(VoiceProtocol): if not self._handshaking: # If we're done handshaking then we just need to update ourselves - guild = self.guild - self.channel = channel_id and guild and guild.get_channel(int(channel_id)) + if channel_id is None: + # We're being disconnected so cleanup + await self.disconnect() + else: + guild = self.guild + self.channel = channel_id and guild and guild.get_channel(int(channel_id)) else: self._voice_state_complete.set() From a6381dcf77f773316ea59767a06d06cfcc57e4d9 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 20 Dec 2019 23:10:46 -0500 Subject: [PATCH 16/58] Add support for guild intents --- discord/client.py | 4 + discord/flags.py | 317 ++++++++++++++++++++++++++++++++++++++++++++- discord/gateway.py | 3 + discord/state.py | 8 ++ 4 files changed, 331 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 407fd47f..91d83da4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -140,6 +140,10 @@ class Client: Integer starting at ``0`` and less than :attr:`.shard_count`. 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 + disabling and enabling certain gateway events from triggering and being sent. + Currently, if no intents are passed then you will receive all data. 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/flags.py b/discord/flags.py index 448bced2..4f5f197e 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -29,7 +29,8 @@ from .enums import UserFlags __all__ = ( 'SystemChannelFlags', 'MessageFlags', - 'PublicUserFlags' + 'PublicUserFlags', + 'Intents', ) class flag_value: @@ -327,3 +328,317 @@ class PublicUserFlags(BaseFlags): def all(self): """List[:class:`UserFlags`]: Returns all public flags the user has.""" return [public_flag for public_flag in UserFlags if self._has_flag(public_flag.value)] + + +@fill_with_flags() +class Intents(BaseFlags): + r"""Wraps up a Discord gateway intent flag. + + Similar to :class:`Permissions`\, the properties provided are two way. + You can set and retrieve individual bits using the properties as if they + were regular bools. + + To construct an object you can pass keyword arguments denoting the flags + to enable or disable. + + This is used to disable certain gateway features that are unnecessary to + run your bot. To make use of this, it is passed to the ``intents`` keyword + argument of :class:`Client`. + + A default instance of this class has everything enabled except :attr:`presences`. + + .. 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): + # Change the default value to everything being enabled + # except presences + bits = max(self.VALID_FLAGS.values()).bit_length() + self.value = (1 << bits) - 1 + self.presences = False + 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:`Intents` 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:`Intents` with everything disabled.""" + self = cls.__new__(cls) + self.value = self.DEFAULT_VALUE + return self + + @flag_value + def guilds(self): + """:class:`bool`: Whether guild related events are enabled. + + This corresponds to the following events: + + - :func:`on_guild_join` + - :func:`on_guild_remove` + - :func:`on_guild_available` + - :func:`on_guild_unavailable` + - :func:`on_guild_channel_update` + - :func:`on_guild_channel_create` + - :func:`on_guild_channel_delete` + - :func:`on_guild_channel_pins_update` + """ + return 1 << 0 + + @flag_value + def members(self): + """:class:`bool`: Whether guild member related events are enabled. + + This corresponds to the following events: + + - :func:`on_member_join` + - :func:`on_member_remove` + - :func:`on_member_update` (nickname, roles) + """ + return 1 << 1 + + @flag_value + def bans(self): + """:class:`bool`: Whether guild ban related events are enabled. + + This corresponds to the following events: + + - :func:`on_member_ban` + - :func:`on_member_unban` + """ + return 1 << 2 + + @flag_value + def emojis(self): + """:class:`bool`: Whether guild emoji related events are enabled. + + This corresponds to the following events: + + - :func:`on_guild_emojis_update` + """ + return 1 << 3 + + @flag_value + def integrations(self): + """:class:`bool`: Whether guild integration related events are enabled. + + This corresponds to the following events: + + - :func:`on_guild_integrations_update` + """ + return 1 << 4 + + @flag_value + def webhooks(self): + """:class:`bool`: Whether guild webhook related events are enabled. + + This corresponds to the following events: + + - :func:`on_webhooks_update` + """ + return 1 << 5 + + @flag_value + def invites(self): + """:class:`bool`: Whether guild invite related events are enabled. + + This corresponds to the following events: + + - :func:`on_invite_create` + - :func:`on_invite_delete` + """ + return 1 << 6 + + @flag_value + def voice_states(self): + """:class:`bool`: Whether guild voice state related events are enabled. + + This corresponds to the following events: + + - :func:`on_voice_state_update` + """ + return 1 << 7 + + @flag_value + def presences(self): + """:class:`bool`: Whether guild voice state related events are enabled. + + This corresponds to the following events: + + - :func:`on_member_update` (activities, status) + - :func:`on_user_update` + + .. note:: + + Currently, this requires opting in explicitly via the dev portal as well. + Bots in over 100 guilds will need to apply to Discord for approval. + """ + return 1 << 8 + + @flag_value + def messages(self): + """:class:`bool`: Whether guild and direct message related events are enabled. + + This is a shortcut to set or get both :attr:`guild_messages` and :attr:`dm_messages`. + + This corresponds to the following events: + + - :func:`on_message` (both guilds and DMs) + - :func:`on_message_update` (both guilds and DMs) + - :func:`on_message_delete` (both guilds and DMs) + - :func:`on_raw_message_delete` (both guilds and DMs) + - :func:`on_raw_message_update` (both guilds and DMs) + - :func:`on_private_channel_create` + """ + return (1 << 9) | (1 << 12) + + @flag_value + def guild_messages(self): + """:class:`bool`: Whether guild message related events are enabled. + + See also :attr:`dm_messages` for DMs or :attr:`messages` for both. + + This corresponds to the following events: + + - :func:`on_message` (only for guilds) + - :func:`on_message_update` (only for guilds) + - :func:`on_message_delete` (only for guilds) + - :func:`on_raw_message_delete` (only for guilds) + - :func:`on_raw_message_update` (only for guilds) + """ + return 1 << 9 + + @flag_value + def dm_messages(self): + """:class:`bool`: Whether direct message related events are enabled. + + See also :attr:`guild_messages` for guilds or :attr:`messages` for both. + + This corresponds to the following events: + + - :func:`on_message` (only for DMs) + - :func:`on_message_update` (only for DMs) + - :func:`on_message_delete` (only for DMs) + - :func:`on_raw_message_delete` (only for DMs) + - :func:`on_raw_message_update` (only for DMs) + - :func:`on_private_channel_create` + """ + return 1 << 12 + + @flag_value + def reactions(self): + """:class:`bool`: Whether guild and direct message reaction related events are enabled. + + This is a shortcut to set or get both :attr:`guild_reactions` and :attr:`dm_reactions`. + + This corresponds to the following events: + + - :func:`on_reaction_add` (both guilds and DMs) + - :func:`on_reaction_remove` (both guilds and DMs) + - :func:`on_reaction_clear` (both guilds and DMs) + - :func:`on_raw_reaction_add` (both guilds and DMs) + - :func:`on_raw_reaction_remove` (both guilds and DMs) + - :func:`on_raw_reaction_clear` (both guilds and DMs) + """ + return (1 << 10) | (1 << 13) + + @flag_value + def guild_reactions(self): + """:class:`bool`: Whether guild message reaction related events are enabled. + + See also :attr:`dm_reactions` for DMs or :attr:`reactions` for both. + + This corresponds to the following events: + + - :func:`on_reaction_add` (only for guilds) + - :func:`on_reaction_remove` (only for guilds) + - :func:`on_reaction_clear` (only for guilds) + - :func:`on_raw_reaction_add` (only for guilds) + - :func:`on_raw_reaction_remove` (only for guilds) + - :func:`on_raw_reaction_clear` (only for guilds) + """ + return 1 << 10 + + @flag_value + def dm_reactions(self): + """:class:`bool`: Whether direct message reaction related events are enabled. + + See also :attr:`guild_reactions` for guilds or :attr:`reactions` for both. + + This corresponds to the following events: + + - :func:`on_reaction_add` (only for DMs) + - :func:`on_reaction_remove` (only for DMs) + - :func:`on_reaction_clear` (only for DMs) + - :func:`on_raw_reaction_add` (only for DMs) + - :func:`on_raw_reaction_remove` (only for DMs) + - :func:`on_raw_reaction_clear` (only for DMs) + """ + return 1 << 13 + + @flag_value + def typing(self): + """:class:`bool`: Whether guild and direct message typing related events are enabled. + + This is a shortcut to set or get both :attr:`guild_typing` and :attr:`dm_typing`. + + This corresponds to the following events: + + - :func:`on_typing` (both guilds and DMs) + """ + return (1 << 11) | (1 << 14) + + @flag_value + def guild_typing(self): + """:class:`bool`: Whether guild and direct message typing related events are enabled. + + See also :attr:`dm_typing` for DMs or :attr:`typing` for both. + + This corresponds to the following events: + + - :func:`on_typing` (only for guilds) + """ + return 1 << 11 + + @flag_value + def dm_typing(self): + """:class:`bool`: Whether guild and direct message typing related events are enabled. + + See also :attr:`guild_typing` for guilds or :attr:`typing` for both. + + This corresponds to the following events: + + - :func:`on_typing` (only for DMs) + """ + return 1 << 14 diff --git a/discord/gateway.py b/discord/gateway.py index 382d60f1..ab595566 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -343,6 +343,9 @@ class DiscordWebSocket: 'afk': False } + if state._intents is not None: + payload['d']['intents'] = state._intents + await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) await self.send_as_json(payload) log.info('Shard ID %s has sent the IDENTIFY payload.', self.shard_id) diff --git a/discord/state.py b/discord/state.py index fc297d03..6c0f1e89 100644 --- a/discord/state.py +++ b/discord/state.py @@ -51,6 +51,7 @@ from .member import Member from .role import Role from .enums import ChannelType, try_enum, Status, Enum from . import utils +from .flags import Intents from .embeds import Embed from .object import Object from .invite import Invite @@ -115,8 +116,15 @@ class ConnectionState: else: status = str(status) + intents = options.get('intents', None) + if intents is not None: + if not isinstance(intents, Intents): + raise TypeError('intents parameter must be Intent not %r' % type(intents)) + intents = intents.value + self._activity = activity self._status = status + self._intents = intents self.parsers = parsers = {} for attr, func in inspect.getmembers(self): From e7db9cfdf7f0488308b61ccc621a1caf3f1e5e68 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 27 Aug 2020 20:00:28 -0400 Subject: [PATCH 17/58] Handle gateway rate limits by using a rate limiter. With the new chunking changes this will become necessary and we don't want to disconnect from having too many outwards requests. --- discord/gateway.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/discord/gateway.py b/discord/gateway.py index ab595566..9a605cd6 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -66,6 +66,31 @@ class WebSocketClosure(Exception): EventListener = namedtuple('EventListener', 'predicate event result future') +class GatewayRatelimiter: + def __init__(self, count=120, per=60.0): + self.max = count + self.remaining = count + self.window = 0.0 + self.per = per + + def get_delay(self): + current = time.time() + + if current > self.window + self.per: + self.remaining = self.max + + if self.remaining == self.max: + self.window = current + + if self.remaining == 0: + return self.per - (current - self.window) + + self.remaining -= 1 + if self.remaining == 0: + self.window = current + + return 0.0 + class KeepAliveHandler(threading.Thread): def __init__(self, *args, **kwargs): ws = kwargs.pop('ws', None) @@ -240,6 +265,7 @@ class DiscordWebSocket: self._zlib = zlib.decompressobj() self._buffer = bytearray() self._close_code = None + self._rate_limiter = GatewayRatelimiter() @property def open(self): @@ -532,6 +558,11 @@ class DiscordWebSocket: raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None async def send(self, data): + delay = self._rate_limiter.get_delay() + if delay: + log.warning('WebSocket is ratelimited, waiting %.2f seconds', delay) + await asyncio.sleep(delay) + self._dispatch('socket_raw_send', data) await self.socket.send_str(data) From 11d8a6fa3c36e8544ea9fbd049fe44fa72717019 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Sep 2020 02:09:10 -0400 Subject: [PATCH 18/58] Change unknown cache log warnings from WARNING -> DEBUG --- discord/state.py | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/discord/state.py b/discord/state.py index 6c0f1e89..61f66457 100644 --- a/discord/state.py +++ b/discord/state.py @@ -575,7 +575,7 @@ class ConnectionState: guild_id = utils._get_as_snowflake(data, 'guild_id') guild = self._get_guild(guild_id) if guild is None: - log.warning('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 user = data['user'] @@ -643,14 +643,14 @@ class ConnectionState: channel._update(guild, data) self.dispatch('guild_channel_update', old_channel, channel) else: - log.warning('CHANNEL_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) + log.debug('CHANNEL_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) else: - log.warning('CHANNEL_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) + log.debug('CHANNEL_UPDATE referencing an unknown guild ID: %s. Discarding.', guild_id) def parse_channel_create(self, data): factory, ch_type = _channel_factory(data['type']) if factory is None: - log.warning('CHANNEL_CREATE referencing an unknown channel type %s. Discarding.', data['type']) + log.debug('CHANNEL_CREATE referencing an unknown channel type %s. Discarding.', data['type']) return channel = None @@ -669,14 +669,14 @@ class ConnectionState: guild._add_channel(channel) self.dispatch('guild_channel_create', channel) else: - log.warning('CHANNEL_CREATE referencing an unknown guild ID: %s. Discarding.', guild_id) + log.debug('CHANNEL_CREATE referencing an unknown guild ID: %s. Discarding.', guild_id) return def parse_channel_pins_update(self, data): channel_id = int(data['channel_id']) channel = self.get_channel(channel_id) if channel is None: - log.warning('CHANNEL_PINS_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) + log.debug('CHANNEL_PINS_UPDATE referencing an unknown channel ID: %s. Discarding.', channel_id) return last_pin = utils.parse_time(data['last_pin_timestamp']) if data['last_pin_timestamp'] else None @@ -710,7 +710,7 @@ class ConnectionState: def parse_guild_member_add(self, data): guild = self._get_guild(int(data['guild_id'])) if guild is None: - log.warning('GUILD_MEMBER_ADD referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_MEMBER_ADD referencing an unknown guild ID: %s. Discarding.', data['guild_id']) return member = Member(guild=guild, data=data, state=self) @@ -729,14 +729,14 @@ class ConnectionState: guild._remove_member(member) self.dispatch('member_remove', member) else: - log.warning('GUILD_MEMBER_REMOVE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_MEMBER_REMOVE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_member_update(self, data): guild = self._get_guild(int(data['guild_id'])) user = data['user'] user_id = int(user['id']) if guild is None: - log.warning('GUILD_MEMBER_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_MEMBER_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) return member = guild.get_member(user_id) @@ -745,12 +745,12 @@ class ConnectionState: member._update(data) self.dispatch('member_update', old_member, member) else: - log.warning('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.', user_id) + log.debug('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.', user_id) def parse_guild_emojis_update(self, data): guild = self._get_guild(int(data['guild_id'])) if guild is None: - log.warning('GUILD_EMOJIS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_EMOJIS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) return before_emojis = guild.emojis @@ -836,12 +836,12 @@ class ConnectionState: guild._from_data(data) self.dispatch('guild_update', old_guild, guild) else: - log.warning('GUILD_UPDATE referencing an unknown guild ID: %s. Discarding.', data['id']) + log.debug('GUILD_UPDATE referencing an unknown guild ID: %s. Discarding.', data['id']) def parse_guild_delete(self, data): guild = self._get_guild(int(data['id'])) if guild is None: - log.warning('GUILD_DELETE referencing an unknown guild ID: %s. Discarding.', data['id']) + log.debug('GUILD_DELETE referencing an unknown guild ID: %s. Discarding.', data['id']) return if data.get('unavailable', False) and guild is not None: @@ -884,7 +884,7 @@ class ConnectionState: def parse_guild_role_create(self, data): guild = self._get_guild(int(data['guild_id'])) if guild is None: - log.warning('GUILD_ROLE_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_ROLE_CREATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) return role_data = data['role'] @@ -903,7 +903,7 @@ class ConnectionState: else: self.dispatch('guild_role_delete', role) else: - log.warning('GUILD_ROLE_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_ROLE_DELETE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_role_update(self, data): guild = self._get_guild(int(data['guild_id'])) @@ -916,7 +916,7 @@ class ConnectionState: role._update(role_data) self.dispatch('guild_role_update', old_role, role) else: - log.warning('GUILD_ROLE_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_ROLE_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_guild_members_chunk(self, data): guild_id = int(data['guild_id']) @@ -937,14 +937,14 @@ class ConnectionState: if guild is not None: self.dispatch('guild_integrations_update', guild) else: - log.warning('GUILD_INTEGRATIONS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) + log.debug('GUILD_INTEGRATIONS_UPDATE referencing an unknown guild ID: %s. Discarding.', data['guild_id']) def parse_webhooks_update(self, data): channel = self.get_channel(int(data['channel_id'])) if channel is not None: self.dispatch('webhooks_update', channel) else: - log.warning('WEBHOOKS_UPDATE referencing an unknown channel ID: %s. Discarding.', data['channel_id']) + log.debug('WEBHOOKS_UPDATE referencing an unknown channel ID: %s. Discarding.', data['channel_id']) def parse_voice_state_update(self, data): guild = self._get_guild(utils._get_as_snowflake(data, 'guild_id')) @@ -960,7 +960,7 @@ class ConnectionState: if member is not None: self.dispatch('voice_state_update', member, before, after) else: - log.warning('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) + log.debug('VOICE_STATE_UPDATE referencing an unknown member ID: %s. Discarding.', data['user_id']) else: # in here we're either at private or group calls call = self._calls.get(channel_id) From ec7b8edcc2933a8d68f036a17d70c7868b0d65df Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Sep 2020 08:02:52 -0400 Subject: [PATCH 19/58] Add more close codes that can't be handled for reconnecting. --- discord/gateway.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/gateway.py b/discord/gateway.py index 9a605cd6..e7274520 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -517,7 +517,7 @@ class DiscordWebSocket: def _can_handle_close(self): code = self._close_code or self.socket.close_code - return code not in (1000, 4004, 4010, 4011) + return code not in (1000, 4004, 4010, 4011, 4012, 4013, 4014) async def poll_event(self): """Polls for a DISPATCH event and handles the general gateway loop. From 930761e058597b8fd8a5aaf7c747ca78b324bbc9 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Sep 2020 08:09:41 -0400 Subject: [PATCH 20/58] Rewrite chunking to work with intents. This slows down chunking significantly for bots in a large number of guilds since it goes down from 75 guilds/request to 1 guild/request. However the logic was rewritten to fire the chunking request immediately after receiving the GUILD_CREATE rather than waiting for all the guilds in the ready stream before doing it. --- discord/gateway.py | 2 +- discord/guild.py | 24 ++--- discord/state.py | 239 +++++++++++++++------------------------------ 3 files changed, 91 insertions(+), 174 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index e7274520..ce64c501 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -370,7 +370,7 @@ class DiscordWebSocket: } if state._intents is not None: - payload['d']['intents'] = state._intents + payload['d']['intents'] = state._intents.value await self.call_hooks('before_identify', self.shard_id, initial=self._initial_identify) await self.send_as_json(payload) diff --git a/discord/guild.py b/discord/guild.py index 0bf94a28..c15b78cf 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2045,11 +2045,6 @@ class Guild(Hashable): This is a websocket operation and can be slow. - .. warning:: - - Most bots do not need to use this. It's mainly a helper - for bots who have disabled ``guild_subscriptions``. - .. versionadded:: 1.3 Parameters @@ -2059,7 +2054,7 @@ class Guild(Hashable): requests all members. limit: :class:`int` The maximum number of members to send back. This must be - a number between 1 and 1000. + a number between 1 and 100. cache: :class:`bool` Whether to cache the members internally. This makes operations such as :meth:`get_member` work for those that matched. @@ -2073,19 +2068,26 @@ class Guild(Hashable): ------- asyncio.TimeoutError The query timed out waiting for the members. + ValueError + Invalid parameters were passed to the function Returns -------- List[:class:`Member`] The list of members that have matched the query. """ + + if query is None: + if query == '': + raise ValueError('Cannot pass empty query string.') + + if user_ids is None: + raise ValueError('Must pass either query or user_ids') + if user_ids is not None and query is not None: - raise TypeError('Cannot pass both query and user_ids') + raise ValueError('Cannot pass both query and user_ids') - if user_ids is None and query is None: - raise TypeError('Must pass either query or user_ids') - - limit = limit or 5 + limit = min(100, limit or 5) return await self._state.query_members(self, query=query, limit=limit, user_ids=user_ids, cache=cache) async def change_voice_state(self, *, channel, self_mute=False, self_deaf=False): diff --git a/discord/state.py b/discord/state.py index 61f66457..bcbc70b0 100644 --- a/discord/state.py +++ b/discord/state.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. """ import asyncio -from collections import deque, namedtuple, OrderedDict +from collections import deque, OrderedDict import copy import datetime import itertools @@ -49,20 +49,22 @@ from .channel import * from .raw_models import * from .member import Member from .role import Role -from .enums import ChannelType, try_enum, Status, Enum +from .enums import ChannelType, try_enum, Status from . import utils from .flags import Intents from .embeds import Embed from .object import Object from .invite import Invite -class ListenerType(Enum): - chunk = 0 - query_members = 1 +class ChunkRequest: + __slots__ = ('guild_id', 'nonce', 'future') + + def __init__(self, guild_id, future): + self.guild_id = guild_id + self.nonce = os.urandom(16).hex() + self.future = future -Listener = namedtuple('Listener', ('type', 'future', 'predicate')) log = logging.getLogger(__name__) -ReadyState = namedtuple('ReadyState', ('launch', 'guilds')) async def logging_coroutine(coroutine, *, info): try: @@ -100,7 +102,7 @@ class ConnectionState: 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._listeners = [] + self._chunk_requests = [] activity = options.get('activity', None) if activity: @@ -120,7 +122,9 @@ class ConnectionState: if intents is not None: if not isinstance(intents, Intents): raise TypeError('intents parameter must be Intent not %r' % type(intents)) - intents = intents.value + + if not intents.members and self._fetch_offline: + raise ValueError('Intents.members has be enabled to fetch offline members.') self._activity = activity self._status = status @@ -152,34 +156,20 @@ class ConnectionState: # to reconnect loops which cause mass allocations and deallocations. gc.collect() - def get_nonce(self): - return os.urandom(16).hex() - - def process_listeners(self, listener_type, argument, result): + def process_chunk_requests(self, guild_id, nonce, members): removed = [] - for i, listener in enumerate(self._listeners): - if listener.type != listener_type: - continue - - future = listener.future + for i, request in enumerate(self._chunk_requests): + future = request.future if future.cancelled(): removed.append(i) continue - try: - passed = listener.predicate(argument) - except Exception as exc: - future.set_exception(exc) + if request.guild_id == guild_id and request.nonce == nonce: + future.set_result(members) removed.append(i) - else: - if passed: - future.set_result(result) - removed.append(i) - if listener.type == ListenerType.chunk: - break for index in reversed(removed): - del self._listeners[index] + del self._chunk_requests[index] def call_handlers(self, key, *args, **kwargs): try: @@ -313,10 +303,6 @@ class ConnectionState: self._add_guild(guild) return guild - def chunks_needed(self, guild): - for _ in range(math.ceil(guild._member_count / 1000)): - yield self.receive_chunk(guild.id) - def _get_guild_channel(self, data): channel_id = int(data['channel_id']) try: @@ -333,43 +319,20 @@ class ConnectionState: ws = self._get_websocket(guild_id) # This is ignored upstream await ws.request_chunks(guild_id, query=query, limit=limit, nonce=nonce) - async def request_offline_members(self, guilds): - # get all the chunks - chunks = [] - for guild in guilds: - chunks.extend(self.chunks_needed(guild)) - - # we only want to request ~75 guilds per chunk request. - splits = [guilds[i:i + 75] for i in range(0, len(guilds), 75)] - for split in splits: - await self.chunker([g.id for g in split]) - - # wait for the chunks - if chunks: - try: - await utils.sane_wait_for(chunks, timeout=len(chunks) * 30.0) - except asyncio.TimeoutError: - log.warning('Somehow timed out waiting for chunks.') - else: - log.info('Finished requesting guild member chunks for %d guilds.', len(guilds)) - async def query_members(self, guild, query, limit, user_ids, cache): guild_id = guild.id ws = self._get_websocket(guild_id) if ws is None: raise RuntimeError('Somehow do not have a websocket for this guild_id') - # Limits over 1000 cannot be supported since - # the main use case for this is guild_subscriptions being disabled - # and they don't receive GUILD_MEMBER events which make computing - # member_count impossible. The only way to fix it is by limiting - # the limit parameter to 1 to 1000. - nonce = self.get_nonce() - future = self.receive_member_query(guild_id, nonce) + future = self.loop.create_future() + request = ChunkRequest(guild.id, future) + self._chunk_requests.append(request) + try: # start the query operation - await ws.request_chunks(guild_id, query=query, limit=limit, user_ids=user_ids, nonce=nonce) - members = await asyncio.wait_for(future, timeout=5.0) + await ws.request_chunks(guild_id, query=query, limit=limit, user_ids=user_ids, nonce=request.nonce) + members = await asyncio.wait_for(future, timeout=30.0) if cache: for member in members: @@ -382,29 +345,26 @@ class ConnectionState: async def _delay_ready(self): try: - launch = self._ready_state.launch - # only real bots wait for GUILD_CREATE streaming if self.is_bot: while True: # this snippet of code is basically waiting N seconds # until the last GUILD_CREATE was sent try: - await asyncio.wait_for(launch.wait(), timeout=self.guild_ready_timeout) + guild = await asyncio.wait_for(self._ready_state.get(), timeout=self.guild_ready_timeout) except asyncio.TimeoutError: break else: - launch.clear() - - guilds = next(zip(*self._ready_state.guilds), []) - if self._fetch_offline: - await self.request_offline_members(guilds) - - for guild, unavailable in self._ready_state.guilds: - if unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) + try: + if self._fetch_offline: + await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) + except asyncio.TimeoutError: + log.info('Timed out waiting for chunks while launching ready event.') + finally: + if guild.unavailable is False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) # remove the state try: @@ -429,16 +389,13 @@ class ConnectionState: if self._ready_task is not None: self._ready_task.cancel() - self._ready_state = ReadyState(launch=asyncio.Event(), guilds=[]) + self._ready_state = asyncio.Queue() self.clear() self.user = user = ClientUser(state=self, data=data['user']) self._users[user.id] = user - guilds = self._ready_state.guilds for guild_data in data['guilds']: - guild = self._add_guild_from_data(guild_data) - if (not self.is_bot and not guild.unavailable) or guild.large: - guilds.append((guild, guild.unavailable)) + self._add_guild_from_data(guild_data) for relationship in data.get('relationships', []): try: @@ -772,14 +729,18 @@ class ConnectionState: return self._add_guild_from_data(data) + async def chunk_guild(self, guild): + future = self.loop.create_future() + request = ChunkRequest(guild.id, future) + self._chunk_requests.append(request) + await self.chunker(guild.id, nonce=request.nonce) + await request.future + async def _chunk_and_dispatch(self, guild, unavailable): - chunks = list(self.chunks_needed(guild)) - await self.chunker(guild.id) - if chunks: - try: - await utils.sane_wait_for(chunks, timeout=len(chunks)) - except asyncio.TimeoutError: - log.info('Somehow timed out waiting for chunks.') + try: + await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) + except asyncio.TimeoutError: + log.info('Somehow timed out waiting for chunks.') if unavailable is False: self.dispatch('guild_available', guild) @@ -794,25 +755,17 @@ class ConnectionState: guild = self._get_create_guild(data) + try: + # Notify the on_ready state, if any, that this guild is complete. + self._ready_state.put_nowait(guild) + except AttributeError: + pass + else: + # If we're waiting for the event, put the rest on hold + return + # check if it requires chunking if guild.large: - if unavailable is False: - # check if we're waiting for 'useful' READY - # and if we are, we don't want to dispatch any - # event such as guild_join or guild_available - # because we're still in the 'READY' phase. Or - # so we say. - try: - state = self._ready_state - state.launch.set() - state.guilds.append((guild, unavailable)) - except AttributeError: - # the _ready_state attribute is only there during - # processing of useful READY. - pass - else: - return - # since we're not waiting for 'useful' READY we'll just # do the chunk request here if wanted if self._fetch_offline: @@ -929,8 +882,8 @@ class ConnectionState: if existing is None or existing.joined_at is None: guild._add_member(member) - self.process_listeners(ListenerType.chunk, guild, len(members)) - self.process_listeners(ListenerType.query_members, (guild_id, data.get('nonce')), members) + if data.get('chunk_index', 0) + 1 == data.get('chunk_count'): + self.process_chunk_requests(guild_id, data.get('nonce'), members) def parse_guild_integrations_update(self, data): guild = self._get_guild(int(data['guild_id'])) @@ -1054,21 +1007,6 @@ class ConnectionState: def create_message(self, *, channel, data): return Message(state=self, channel=channel, data=data) - def receive_chunk(self, guild_id): - future = self.loop.create_future() - listener = Listener(ListenerType.chunk, future, lambda s: s.id == guild_id) - self._listeners.append(listener) - return future - - def receive_member_query(self, guild_id, nonce): - def predicate(args, *, guild_id=guild_id, nonce=nonce): - return args == (guild_id, nonce) - - future = self.loop.create_future() - listener = Listener(ListenerType.query_members, future, predicate) - self._listeners.append(listener) - return future - class AutoShardedConnectionState(ConnectionState): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -1091,51 +1029,31 @@ class AutoShardedConnectionState(ConnectionState): ws = self._get_websocket(guild_id, shard_id=shard_id) await ws.request_chunks(guild_id, query=query, limit=limit, nonce=nonce) - async def request_offline_members(self, guilds, *, shard_id): - # get all the chunks - chunks = [] - for guild in guilds: - chunks.extend(self.chunks_needed(guild)) - - # we only want to request ~75 guilds per chunk request. - splits = [guilds[i:i + 75] for i in range(0, len(guilds), 75)] - for split in splits: - await self.chunker([g.id for g in split], shard_id=shard_id) - - # wait for the chunks - if chunks: - try: - await utils.sane_wait_for(chunks, timeout=len(chunks) * 30.0) - except asyncio.TimeoutError: - log.info('Somehow timed out waiting for chunks.') - else: - log.info('Finished requesting guild member chunks for %d guilds.', len(guilds)) - async def _delay_ready(self): await self.shards_launched.wait() - launch = self._ready_state.launch + processed = [] while True: # this snippet of code is basically waiting N seconds # until the last GUILD_CREATE was sent try: - await asyncio.wait_for(launch.wait(), timeout=self.guild_ready_timeout) + guild = await asyncio.wait_for(self._ready_state.get(), timeout=self.guild_ready_timeout) except asyncio.TimeoutError: break else: - launch.clear() + try: + if self._fetch_offline: + await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) + except asyncio.TimeoutError: + log.info('Timed out waiting for chunks while launching ready event.') + finally: + processed.append(guild) + if guild.unavailable is False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) - guilds = sorted(self._ready_state.guilds, key=lambda g: g[0].shard_id) - - for shard_id, sub_guilds_info in itertools.groupby(guilds, key=lambda g: g[0].shard_id): - sub_guilds, sub_available = zip(*sub_guilds_info) - if self._fetch_offline: - await self.request_offline_members(sub_guilds, shard_id=shard_id) - - for guild, unavailable in zip(sub_guilds, sub_available): - if unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) + guilds = sorted(processed, key=lambda g: g.shard_id) + for shard_id, _ in itertools.groupby(guilds, key=lambda g: g.shard_id): self.dispatch('shard_ready', shard_id) # remove the state @@ -1155,16 +1073,13 @@ class AutoShardedConnectionState(ConnectionState): def parse_ready(self, data): if not hasattr(self, '_ready_state'): - self._ready_state = ReadyState(launch=asyncio.Event(), guilds=[]) + self._ready_state = asyncio.Queue() self.user = user = ClientUser(state=self, data=data['user']) self._users[user.id] = user - guilds = self._ready_state.guilds for guild_data in data['guilds']: - guild = self._add_guild_from_data(guild_data) - if guild.large: - guilds.append((guild, guild.unavailable)) + self._add_guild_from_data(guild_data) if self._messages: self._update_message_references() From c261f7c4a35cfc9258036793bfa8b7d4315f2547 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Sep 2020 08:32:02 -0400 Subject: [PATCH 21/58] Handle user updates within GUILD_MEMBER_UPDATE --- discord/member.py | 21 ++++++++++++--------- discord/state.py | 6 +++++- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/discord/member.py b/discord/member.py index dbcbc492..00f72fb2 100644 --- a/discord/member.py +++ b/discord/member.py @@ -272,17 +272,20 @@ class Member(discord.abc.Messageable, _BaseUser): self._client_status[None] = data['status'] if len(user) > 1: - u = self._user - original = (u.name, u.avatar, u.discriminator) - # These keys seem to always be available - modified = (user['username'], user['avatar'], user['discriminator']) - if original != modified: - to_return = User._copy(self._user) - u.name, u.avatar, u.discriminator = modified - # Signal to dispatch on_user_update - return to_return, u + return self._update_inner_user(user) return False + def _update_inner_user(self, user): + u = self._user + original = (u.name, u.avatar, u.discriminator) + # These keys seem to always be available + modified = (user['username'], user['avatar'], user['discriminator']) + if original != modified: + to_return = User._copy(self._user) + u.name, u.avatar, u.discriminator = modified + # Signal to dispatch on_user_update + return to_return, u + @property def status(self): """:class:`Status`: The member's overall status. If the value is unknown, then it will be a :class:`str` instead.""" diff --git a/discord/state.py b/discord/state.py index bcbc70b0..a2a2dee6 100644 --- a/discord/state.py +++ b/discord/state.py @@ -698,8 +698,12 @@ class ConnectionState: member = guild.get_member(user_id) if member is not None: - old_member = copy.copy(member) + old_member = Member._copy(member) member._update(data) + user_update = member._update_inner_user(user) + if user_update: + self.dispatch('user_update', user_update[0], user_update[1]) + self.dispatch('member_update', old_member, member) else: log.debug('GUILD_MEMBER_UPDATE referencing an unknown member ID: %s. Discarding.', user_id) From 3df269daa33b1db1097b76aea573c1d338df232d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 4 Sep 2020 23:39:54 -0400 Subject: [PATCH 22/58] All guilds require chunking if opting into it --- discord/state.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/discord/state.py b/discord/state.py index a2a2dee6..b4b72d3a 100644 --- a/discord/state.py +++ b/discord/state.py @@ -769,12 +769,9 @@ class ConnectionState: return # check if it requires chunking - if guild.large: - # since we're not waiting for 'useful' READY we'll just - # do the chunk request here if wanted - if self._fetch_offline: - asyncio.ensure_future(self._chunk_and_dispatch(guild, unavailable), loop=self.loop) - return + if self._fetch_offline: + asyncio.ensure_future(self._chunk_and_dispatch(guild, unavailable), loop=self.loop) + return # Dispatch available if newly available if unavailable is False: From 5fe998ac19760739cde0c042f5acd10179258eee Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 00:28:05 -0400 Subject: [PATCH 23/58] Heartbeats bypass the rate limits for gateway --- discord/gateway.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index ce64c501..467bd3c8 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -67,7 +67,8 @@ class WebSocketClosure(Exception): EventListener = namedtuple('EventListener', 'predicate event result future') class GatewayRatelimiter: - def __init__(self, count=120, per=60.0): + def __init__(self, count=110, per=60.0): + # The default is 110 to give room for at least 10 heartbeats per minute self.max = count self.remaining = count self.window = 0.0 @@ -128,7 +129,7 @@ class KeepAliveHandler(threading.Thread): data = self.get_payload() log.debug(self.msg, self.shard_id, data['d']) - coro = self.ws.send_as_json(data) + coro = self.ws.send_heartbeat(data) f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) try: # block until sending is complete @@ -560,7 +561,7 @@ class DiscordWebSocket: async def send(self, data): delay = self._rate_limiter.get_delay() if delay: - log.warning('WebSocket is ratelimited, waiting %.2f seconds', delay) + log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delay) await asyncio.sleep(delay) self._dispatch('socket_raw_send', data) @@ -573,6 +574,14 @@ class DiscordWebSocket: if not self._can_handle_close(): raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc + async def send_heartbeat(self, data): + # This bypasses the rate limit handling code since it has a higher priority + try: + await self.socket.send_str(utils.to_json(data)) + except RuntimeError as exc: + if not self._can_handle_close(): + raise ConnectionClosed(self.socket, shard_id=self.shard_id) from exc + async def change_presence(self, *, activity=None, status=None, afk=False, since=0.0): if activity is not None: if not isinstance(activity, BaseActivity): @@ -700,6 +709,8 @@ class DiscordVoiceWebSocket: log.debug('Sending voice websocket frame: %s.', data) await self.ws.send_str(utils.to_json(data)) + send_heartbeat = send_as_json + async def resume(self): state = self._connection payload = { From af8fc32329219f8e6e5a145f430c297e7e181a85 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 12:20:58 -0400 Subject: [PATCH 24/58] Use a lock for the gateway rate limiter. This will allow for higher concurrency in AutoSharded situations where I can mostly "fire and forget" the chunk requests. --- discord/gateway.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index 467bd3c8..03498ff2 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -73,6 +73,8 @@ class GatewayRatelimiter: self.remaining = count self.window = 0.0 self.per = per + self.lock = asyncio.Lock() + self.shard_id = None def get_delay(self): current = time.time() @@ -92,6 +94,14 @@ class GatewayRatelimiter: return 0.0 + async def block(self): + async with self.lock: + delta = self.get_delay() + if delta: + log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delta) + await asyncio.sleep(delta) + + class KeepAliveHandler(threading.Thread): def __init__(self, *args, **kwargs): ws = kwargs.pop('ws', None) @@ -291,6 +301,7 @@ class DiscordWebSocket: ws.call_hooks = client._connection.call_hooks ws._initial_identify = initial ws.shard_id = shard_id + ws._rate_limiter.shard_id = shard_id ws.shard_count = client._connection.shard_count ws.session_id = session ws.sequence = sequence @@ -559,11 +570,7 @@ class DiscordWebSocket: raise ConnectionClosed(self.socket, shard_id=self.shard_id, code=code) from None async def send(self, data): - delay = self._rate_limiter.get_delay() - if delay: - log.warning('WebSocket in shard ID %s is ratelimited, waiting %.2f seconds', self.shard_id, delay) - await asyncio.sleep(delay) - + await self._rate_limiter.block() self._dispatch('socket_raw_send', data) await self.socket.send_str(data) From 92e1816114a840e74348635b40d91f9dc7c8157d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 13:48:02 -0400 Subject: [PATCH 25/58] Maximize concurrency when chunking on AutoSharded clients --- discord/state.py | 66 +++++++++++++++++++++++++++++++++--------------- 1 file changed, 45 insertions(+), 21 deletions(-) diff --git a/discord/state.py b/discord/state.py index b4b72d3a..7d4acbb9 100644 --- a/discord/state.py +++ b/discord/state.py @@ -347,6 +347,7 @@ class ConnectionState: try: # only real bots wait for GUILD_CREATE streaming if self.is_bot: + states = [] while True: # this snippet of code is basically waiting N seconds # until the last GUILD_CREATE was sent @@ -355,17 +356,26 @@ class ConnectionState: except asyncio.TimeoutError: break else: - try: - if self._fetch_offline: - await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) - except asyncio.TimeoutError: - log.info('Timed out waiting for chunks while launching ready event.') - finally: + if self._fetch_offline: + future = await self.chunk_guild(guild, wait=False) + states.append((guild, future)) + else: if guild.unavailable is False: self.dispatch('guild_available', guild) else: self.dispatch('guild_join', guild) + for guild, future in states: + try: + await asyncio.wait_for(future, timeout=5.0) + except asyncio.TimeoutError: + log.warning('Shard ID %s timed out waiting for chunks for guild_id %s.', guild.shard_id, guild.id) + + if guild.unavailable is False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) + # remove the state try: del self._ready_state @@ -733,12 +743,14 @@ class ConnectionState: return self._add_guild_from_data(data) - async def chunk_guild(self, guild): + async def chunk_guild(self, guild, *, wait=True): future = self.loop.create_future() request = ChunkRequest(guild.id, future) self._chunk_requests.append(request) await self.chunker(guild.id, nonce=request.nonce) - await request.future + if wait: + await request.future + return request.future async def _chunk_and_dispatch(self, guild, unavailable): try: @@ -1041,20 +1053,32 @@ class AutoShardedConnectionState(ConnectionState): except asyncio.TimeoutError: break else: - try: - if self._fetch_offline: - await asyncio.wait_for(self.chunk_guild(guild), timeout=60.0) - except asyncio.TimeoutError: - log.info('Timed out waiting for chunks while launching ready event.') - finally: - processed.append(guild) - if guild.unavailable is False: - self.dispatch('guild_available', guild) - else: - self.dispatch('guild_join', guild) + if self._fetch_offline: + # Chunk the guild in the background while we wait for GUILD_CREATE streaming + future = asyncio.ensure_future(self.chunk_guild(guild)) + else: + future = self.loop.create_future() + future.set_result(True) + + processed.append((guild, future)) + + guilds = sorted(processed, key=lambda g: g[0].shard_id) + for shard_id, info in itertools.groupby(guilds, key=lambda g: g[0].shard_id): + children, futures = zip(*info) + # 110 reqs/minute w/ 1 req/guild plus some buffer + timeout = 61 * (len(children) / 110) + try: + await utils.sane_wait_for(futures, timeout=timeout) + except asyncio.TimeoutError: + log.warning('Shard ID %s failed to wait for chunks (timeout=%.2f) for %d guilds', self.shard_id, + timeout, + len(guilds)) + for guild in children: + if guild.unavailable is False: + self.dispatch('guild_available', guild) + else: + self.dispatch('guild_join', guild) - guilds = sorted(processed, key=lambda g: g.shard_id) - for shard_id, _ in itertools.groupby(guilds, key=lambda g: g.shard_id): self.dispatch('shard_ready', shard_id) # remove the state From 0ec72660cf608c0b825a1a734261dbc44ad29845 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 13:57:50 -0400 Subject: [PATCH 26/58] Speed up chunking for guilds with presence intent enabled --- discord/state.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/discord/state.py b/discord/state.py index 7d4acbb9..b565ae28 100644 --- a/discord/state.py +++ b/discord/state.py @@ -303,6 +303,10 @@ class ConnectionState: self._add_guild(guild) return guild + def _guild_needs_chunking(self, guild): + # If presences are enabled then we get back the old guild.large behaviour + return self._fetch_offline and not guild.chunked and not (self._intents.presences and not guild.large) + def _get_guild_channel(self, data): channel_id = int(data['channel_id']) try: @@ -356,7 +360,8 @@ class ConnectionState: except asyncio.TimeoutError: break else: - if self._fetch_offline: + + if self._guild_needs_chunking(guild): future = await self.chunk_guild(guild, wait=False) states.append((guild, future)) else: @@ -781,7 +786,7 @@ class ConnectionState: return # check if it requires chunking - if self._fetch_offline: + if self._guild_needs_chunking(guild): asyncio.ensure_future(self._chunk_and_dispatch(guild, unavailable), loop=self.loop) return @@ -1053,7 +1058,7 @@ class AutoShardedConnectionState(ConnectionState): except asyncio.TimeoutError: break else: - if self._fetch_offline: + if self._guild_needs_chunking(guild): # Chunk the guild in the background while we wait for GUILD_CREATE streaming future = asyncio.ensure_future(self.chunk_guild(guild)) else: From 6bae52f4bb168cda24ff479d499dcd50f9a1050e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 21:28:33 -0400 Subject: [PATCH 27/58] Check for zombie connections through last received payload The previous code would check zombie connections depending on whether HEARTBEAT_ACK was received. Unfortunately when there's exceeding backpressure the connection can terminate since the HEARTBEAT_ACK is buffered very far away despite it being there, just not received yet. --- discord/gateway.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/discord/gateway.py b/discord/gateway.py index 03498ff2..92fa4f56 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -119,12 +119,13 @@ class KeepAliveHandler(threading.Thread): self._stop_ev = threading.Event() self._last_ack = time.perf_counter() self._last_send = time.perf_counter() + self._last_recv = time.perf_counter() self.latency = float('inf') self.heartbeat_timeout = ws._max_heartbeat_timeout def run(self): while not self._stop_ev.wait(self.interval): - if self._last_ack + self.heartbeat_timeout < time.perf_counter(): + if self._last_recv + self.heartbeat_timeout < time.perf_counter(): log.warning("Shard ID %s has stopped responding to the gateway. Closing and restarting.", self.shard_id) coro = self.ws.close(4000) f = asyncio.run_coroutine_threadsafe(coro, loop=self.ws.loop) @@ -173,6 +174,9 @@ class KeepAliveHandler(threading.Thread): def stop(self): self._stop_ev.set() + def tick(self): + self._last_recv = time.perf_counter() + def ack(self): ack_time = time.perf_counter() self._last_ack = ack_time @@ -197,6 +201,7 @@ class VoiceKeepAliveHandler(KeepAliveHandler): def ack(self): ack_time = time.perf_counter() self._last_ack = ack_time + self._last_recv = ack_time self.latency = ack_time - self._last_send self.recent_ack_latencies.append(self.latency) @@ -429,6 +434,9 @@ class DiscordWebSocket: if seq is not None: self.sequence = seq + if self._keep_alive: + self._keep_alive.tick() + if op != self.DISPATCH: if op == self.RECONNECT: # "reconnect" can only be handled by the Client From 7db00081903a82157026b9bfd575377a6214888a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 5 Sep 2020 21:33:45 -0400 Subject: [PATCH 28/58] Maximize the amount of concurrency while chunking. In order to reduce our amount of backpressure we need to limit the amount of concurrent chunk requests we can have so the gateway buffer has some time to breathe. --- discord/state.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index b565ae28..760a9a8e 100644 --- a/discord/state.py +++ b/discord/state.py @@ -360,7 +360,6 @@ class ConnectionState: except asyncio.TimeoutError: break else: - if self._guild_needs_chunking(guild): future = await self.chunk_guild(guild, wait=False) states.append((guild, future)) @@ -1050,6 +1049,8 @@ class AutoShardedConnectionState(ConnectionState): async def _delay_ready(self): await self.shards_launched.wait() processed = [] + max_concurrency = len(self.shard_ids) * 2 + current_bucket = [] while True: # this snippet of code is basically waiting N seconds # until the last GUILD_CREATE was sent @@ -1059,8 +1060,19 @@ class AutoShardedConnectionState(ConnectionState): break else: if self._guild_needs_chunking(guild): + log.debug('Guild ID %d requires chunking, will be done in the background.', guild.id) + if len(current_bucket) >= max_concurrency: + try: + await utils.sane_wait_for(current_bucket, timeout=max_concurrency * 10) + except asyncio.TimeoutError: + fmt = 'Shard ID %s failed to wait for chunks from a sub-bucket with length %d' + log.warning(fmt, self.shard_id, len(current_bucket)) + finally: + current_bucket = [] + # Chunk the guild in the background while we wait for GUILD_CREATE streaming future = asyncio.ensure_future(self.chunk_guild(guild)) + current_bucket.append(future) else: future = self.loop.create_future() future.set_result(True) From 0ea0f483bc8174e9350d3322576d562a49bb3858 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 7 Sep 2020 21:50:00 -0400 Subject: [PATCH 29/58] Fix timeouts due to hitting the gateway rate limit --- discord/state.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/state.py b/discord/state.py index 760a9a8e..08863336 100644 --- a/discord/state.py +++ b/discord/state.py @@ -753,7 +753,7 @@ class ConnectionState: self._chunk_requests.append(request) await self.chunker(guild.id, nonce=request.nonce) if wait: - await request.future + return await request.future return request.future async def _chunk_and_dispatch(self, guild, unavailable): @@ -1063,7 +1063,7 @@ class AutoShardedConnectionState(ConnectionState): log.debug('Guild ID %d requires chunking, will be done in the background.', guild.id) if len(current_bucket) >= max_concurrency: try: - await utils.sane_wait_for(current_bucket, timeout=max_concurrency * 10) + await utils.sane_wait_for(current_bucket, timeout=max_concurrency * 70.0) except asyncio.TimeoutError: fmt = 'Shard ID %s failed to wait for chunks from a sub-bucket with length %d' log.warning(fmt, self.shard_id, len(current_bucket)) From bec34c11107a9d63f7d4af6a7b47830cf3e267bd Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 7 Sep 2020 22:14:54 -0400 Subject: [PATCH 30/58] Explicitly disable the members presence by default --- discord/flags.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 4f5f197e..ccb75d68 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -345,7 +345,8 @@ class Intents(BaseFlags): run your bot. To make use of this, it is passed to the ``intents`` keyword argument of :class:`Client`. - A default instance of this class has everything enabled except :attr:`presences`. + A default instance of this class has everything enabled except :attr:`presences` + and :attr:`members`. .. container:: operations @@ -374,10 +375,11 @@ class Intents(BaseFlags): def __init__(self, **kwargs): # Change the default value to everything being enabled - # except presences + # except presences and members bits = max(self.VALID_FLAGS.values()).bit_length() self.value = (1 << bits) - 1 self.presences = False + self.members = False for key, value in kwargs.items(): if key not in self.VALID_FLAGS: raise TypeError('%r is not a valid flag name.' % key) @@ -425,6 +427,12 @@ class Intents(BaseFlags): - :func:`on_member_join` - :func:`on_member_remove` - :func:`on_member_update` (nickname, roles) + - :func:`on_user_update` + + .. note:: + + Currently, this requires opting in explicitly via the dev portal as well. + Bots in over 100 guilds will need to apply to Discord for verification. """ return 1 << 1 @@ -497,12 +505,11 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_member_update` (activities, status) - - :func:`on_user_update` .. note:: Currently, this requires opting in explicitly via the dev portal as well. - Bots in over 100 guilds will need to apply to Discord for approval. + Bots in over 100 guilds will need to apply to Discord for verification. """ return 1 << 8 From e10942a9ee4082b4d1ce903d31e6276baad9d273 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 7 Sep 2020 22:15:24 -0400 Subject: [PATCH 31/58] Add versionadded for intents enum --- discord/flags.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/flags.py b/discord/flags.py index ccb75d68..bc2a52ed 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -348,6 +348,8 @@ class Intents(BaseFlags): A default instance of this class has everything enabled except :attr:`presences` and :attr:`members`. + .. versionadded:: 1.5 + .. container:: operations .. describe:: x == y From 055fe7624193a3271ebfb2e9e32f90e148fdcbf2 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 10 Sep 2020 05:26:35 -0400 Subject: [PATCH 32/58] Fix Client.request_offline_members no longer working --- discord/client.py | 13 +++++++++---- discord/shard.py | 12 ++++++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/discord/client.py b/discord/client.py index 91d83da4..dc8b360d 100644 --- a/discord/client.py +++ b/discord/client.py @@ -395,6 +395,10 @@ class Client: in the guild is larger than 250. You can check if a guild is large if :attr:`.Guild.large` is ``True``. + .. warning:: + + This method is deprecated. + Parameters ----------- \*guilds: :class:`.Guild` @@ -403,12 +407,13 @@ class Client: Raises ------- :exc:`.InvalidArgument` - If any guild is unavailable or not large in the collection. + If any guild is unavailable in the collection. """ - if any(not g.large or g.unavailable for g in guilds): - raise InvalidArgument('An unavailable or non-large guild was passed.') + if any(g.unavailable for g in guilds): + raise InvalidArgument('An unavailable guild was passed.') - await self._connection.request_offline_members(guilds) + for guild in guilds: + await self._connection.chunk_guild(guild) # hooks diff --git a/discord/shard.py b/discord/shard.py index ef29d590..1b635c1c 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -347,6 +347,10 @@ class AutoShardedClient(Client): in the guild is larger than 250. You can check if a guild is large if :attr:`Guild.large` is ``True``. + .. warning:: + + This method is deprecated. + Parameters ----------- \*guilds: :class:`Guild` @@ -355,15 +359,15 @@ class AutoShardedClient(Client): Raises ------- InvalidArgument - If any guild is unavailable or not large in the collection. + If any guild is unavailable in the collection. """ - if any(not g.large or g.unavailable for g in guilds): + if any(g.unavailable for g in guilds): raise InvalidArgument('An unavailable or non-large guild was passed.') _guilds = sorted(guilds, key=lambda g: g.shard_id) for shard_id, sub_guilds in itertools.groupby(_guilds, key=lambda g: g.shard_id): - sub_guilds = list(sub_guilds) - await self._connection.request_offline_members(sub_guilds, shard_id=shard_id) + for guild in sub_guilds: + await self._connection.chunk_guild(guild) async def launch_shard(self, gateway, shard_id, *, initial=False): try: From cb8cb557f539baf41640cad3037be02ff9b79818 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 10 Sep 2020 05:56:48 -0400 Subject: [PATCH 33/58] Add Guild.chunk and deprecated Client.request_offline_members --- discord/client.py | 3 ++- discord/guild.py | 26 ++++++++++++++++----- discord/shard.py | 3 ++- discord/state.py | 57 ++++++++++++++++++++++++++--------------------- 4 files changed, 57 insertions(+), 32 deletions(-) diff --git a/discord/client.py b/discord/client.py index dc8b360d..c39a26da 100644 --- a/discord/client.py +++ b/discord/client.py @@ -382,6 +382,7 @@ class Client: print('Ignoring exception in {}'.format(event_method), file=sys.stderr) traceback.print_exc() + @utils.deprecated('Guild.chunk') async def request_offline_members(self, *guilds): r"""|coro| @@ -397,7 +398,7 @@ class Client: .. warning:: - This method is deprecated. + This method is deprecated. Use :meth:`Guild.chunk` instead. Parameters ----------- diff --git a/discord/guild.py b/discord/guild.py index c15b78cf..fcc4e4b3 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2037,6 +2037,23 @@ class Guild(Hashable): return Widget(state=self._state, data=data) + async def chunk(self, *, cache=True): + """|coro| + + Requests all members that belong to this guild. In order to use this, + :meth:`Intents.members` must be enabled. + + This is a websocket operation and can be slow. + + .. versionadded:: 1.5 + + Parameters + ----------- + cache: :class:`bool` + Whether to cache the members as well. + """ + return await self._state.chunk_guild(self, cache=cache) + async def query_members(self, query=None, *, limit=5, user_ids=None, cache=True): """|coro| @@ -2049,16 +2066,15 @@ class Guild(Hashable): Parameters ----------- - query: :class:`str` - The string that the username's start with. An empty string - requests all members. + query: Optional[:class:`str`] + The string that the username's start with. limit: :class:`int` The maximum number of members to send back. This must be - a number between 1 and 100. + a number between 5 and 100. cache: :class:`bool` Whether to cache the members internally. This makes operations such as :meth:`get_member` work for those that matched. - user_ids: List[:class:`int`] + user_ids: Optional[List[:class:`int`]] List of user IDs to search for. If the user ID is not in the guild then it won't be returned. .. versionadded:: 1.4 diff --git a/discord/shard.py b/discord/shard.py index 1b635c1c..00a7a117 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -334,6 +334,7 @@ class AutoShardedClient(Client): """Mapping[int, :class:`ShardInfo`]: Returns a mapping of shard IDs to their respective info object.""" return { shard_id: ShardInfo(parent, self.shard_count) for shard_id, parent in self.__shards.items() } + @utils.deprecated('Guild.chunk') async def request_offline_members(self, *guilds): r"""|coro| @@ -349,7 +350,7 @@ class AutoShardedClient(Client): .. warning:: - This method is deprecated. + This method is deprecated. Use :meth:`Guild.chunk` instead. Parameters ----------- diff --git a/discord/state.py b/discord/state.py index 08863336..ad491197 100644 --- a/discord/state.py +++ b/discord/state.py @@ -57,12 +57,28 @@ from .object import Object from .invite import Invite class ChunkRequest: - __slots__ = ('guild_id', 'nonce', 'future') - - def __init__(self, guild_id, future): + def __init__(self, guild_id, future, resolver, *, cache=True): self.guild_id = guild_id + self.resolver = resolver + self.cache = cache self.nonce = os.urandom(16).hex() self.future = future + self.buffer = [] # List[Member] + + def add_members(self, members): + self.buffer.extend(members) + if self.cache: + guild = self.resolver(self.guild_id) + if guild is None: + return + + for member in members: + existing = guild.get_member(member.id) + if existing is None or existing.joined_at is None: + guild._add_member(member) + + def done(self): + self.future.set_result(self.buffer) log = logging.getLogger(__name__) @@ -156,7 +172,7 @@ class ConnectionState: # to reconnect loops which cause mass allocations and deallocations. gc.collect() - def process_chunk_requests(self, guild_id, nonce, members): + def process_chunk_requests(self, guild_id, nonce, members, complete): removed = [] for i, request in enumerate(self._chunk_requests): future = request.future @@ -165,8 +181,10 @@ class ConnectionState: continue if request.guild_id == guild_id and request.nonce == nonce: - future.set_result(members) - removed.append(i) + request.add_members(members) + if complete: + request.done() + removed.append(i) for index in reversed(removed): del self._chunk_requests[index] @@ -330,19 +348,13 @@ class ConnectionState: raise RuntimeError('Somehow do not have a websocket for this guild_id') future = self.loop.create_future() - request = ChunkRequest(guild.id, future) + request = ChunkRequest(guild.id, future, self._get_guild, cache=cache) self._chunk_requests.append(request) try: # start the query operation await ws.request_chunks(guild_id, query=query, limit=limit, user_ids=user_ids, nonce=request.nonce) - members = await asyncio.wait_for(future, timeout=30.0) - - if cache: - for member in members: - guild._add_member(member) - - return members + return await asyncio.wait_for(future, timeout=30.0) except asyncio.TimeoutError: log.warning('Timed out waiting for chunks with query %r and limit %d for guild_id %d', query, limit, guild_id) raise @@ -747,9 +759,10 @@ class ConnectionState: return self._add_guild_from_data(data) - async def chunk_guild(self, guild, *, wait=True): + async def chunk_guild(self, guild, *, wait=True, cache=None): + cache = cache or self._cache_members future = self.loop.create_future() - request = ChunkRequest(guild.id, future) + request = ChunkRequest(guild.id, future, self._get_guild, cache=cache) self._chunk_requests.append(request) await self.chunker(guild.id, nonce=request.nonce) if wait: @@ -893,14 +906,8 @@ class ConnectionState: guild = self._get_guild(guild_id) members = [Member(guild=guild, data=member, state=self) for member in data.get('members', [])] log.debug('Processed a chunk for %s members in guild ID %s.', len(members), guild_id) - if self._cache_members: - for member in members: - existing = guild.get_member(member.id) - if existing is None or existing.joined_at is None: - guild._add_member(member) - - if data.get('chunk_index', 0) + 1 == data.get('chunk_count'): - self.process_chunk_requests(guild_id, data.get('nonce'), members) + complete = data.get('chunk_index', 0) + 1 == data.get('chunk_count') + self.process_chunk_requests(guild_id, data.get('nonce'), members, complete) def parse_guild_integrations_update(self, data): guild = self._get_guild(int(data['guild_id'])) @@ -1075,7 +1082,7 @@ class AutoShardedConnectionState(ConnectionState): current_bucket.append(future) else: future = self.loop.create_future() - future.set_result(True) + future.set_result([]) processed.append((guild, future)) From ad917ca02d42e153802989fa6a97cc05f9439cb5 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 10 Sep 2020 05:58:24 -0400 Subject: [PATCH 34/58] Don't cache members during guild start up if cache is disabled. This is mainly a half-implemented commit. There are a few more places where cache consistency is necessary. In the future there will probably be a member cache policy enum that will be used and cache consistency will be tackled in part of that larger refactoring. --- discord/guild.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index fcc4e4b3..1c4b64ec 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -305,9 +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 + self_id = self._state.self_id for mdata in guild.get('members', []): member = Member(data=mdata, guild=self, state=state) - self._add_member(member) + if cache_members or member.id == self_id: + self._add_member(member) self._sync(guild) self._large = None if member_count is None else self._member_count >= 250 From e6edc44f3d2e9234d073ef4ea8c04ea0355232a2 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 10 Sep 2020 06:03:26 -0400 Subject: [PATCH 35/58] Raise if member intent is not enabled --- discord/guild.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 1c4b64ec..2ab4afb2 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -2054,7 +2054,16 @@ class Guild(Hashable): ----------- cache: :class:`bool` Whether to cache the members as well. + + Raises + ------- + ClientException + The members intent is not enabled. """ + + if not self._state._intents.members: + raise ClientException('Intents.members must be enabled to use this.') + return await self._state.chunk_guild(self, cache=cache) async def query_members(self, query=None, *, limit=5, user_ids=None, cache=True): From 23ae084b8cb3bb5c9d815b791c1a94b0cf53e516 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 14 Sep 2020 02:52:53 -0400 Subject: [PATCH 36/58] 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 c39a26da..861617b7 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 7fa39289..e74e6698 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 bc2a52ed..a204937c 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 2ab4afb2..4247f105 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 00f72fb2..4b8fb8a0 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 ad491197..39190f1e 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 d4af5ff1..b3c10991 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 ~~~~~ From f2de794bdebc65dff99fd4ce13abde2cedf978dd Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 14 Sep 2020 03:14:57 -0400 Subject: [PATCH 37/58] Intern status and overwrite strings --- discord/abc.py | 3 ++- discord/member.py | 9 +++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 75624e18..0ac87d13 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 { diff --git a/discord/member.py b/discord/member.py index 4b8fb8a0..59bd7bda 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 @@ -221,10 +222,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 @@ -266,10 +267,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) From 4d813da9e18c886fa0cb4f0480759eabdc734a45 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 14 Sep 2020 03:18:25 -0400 Subject: [PATCH 38/58] Pass default intents if not explicitly given --- discord/state.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/state.py b/discord/state.py index 39190f1e..db47059b 100644 --- a/discord/state.py +++ b/discord/state.py @@ -139,6 +139,8 @@ 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: From bf8ca5899689863487a4ec1c343ead9a874cbde9 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 14 Sep 2020 03:49:21 -0400 Subject: [PATCH 39/58] Add a special exception for required privileged intents --- discord/client.py | 2 ++ discord/errors.py | 24 ++++++++++++++++++++++++ discord/shard.py | 20 +++++++++++++++++--- docs/api.rst | 3 +++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/discord/client.py b/discord/client.py index 861617b7..6be3e982 100644 --- a/discord/client.py +++ b/discord/client.py @@ -573,6 +573,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/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/shard.py b/discord/shard.py index 00a7a117..3587c097 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 @@ -408,8 +419,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/docs/api.rst b/docs/api.rst index b3c10991..d697b4f3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2937,6 +2937,8 @@ The following exceptions are thrown by the library. .. autoexception:: ConnectionClosed +.. autoexception:: PrivilegedIntentsRequired + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -2953,6 +2955,7 @@ Exception Hierarchy - :exc:`InvalidArgument` - :exc:`LoginFailure` - :exc:`ConnectionClosed` + - :exc:`PrivilegedIntentsRequired` - :exc:`NoMoreItems` - :exc:`GatewayNotFound` - :exc:`HTTPException` From 8d5c05ed20af0598750d61c5b9c62f4d37e4aeb1 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 15 Sep 2020 01:59:51 -0400 Subject: [PATCH 40/58] Default MemberCacheFlags based on intents --- discord/client.py | 2 ++ discord/flags.py | 26 ++++++++++++++++++++++++++ discord/state.py | 4 ++-- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index 6be3e982..e44e5e21 100644 --- a/discord/client.py +++ b/discord/client.py @@ -147,6 +147,8 @@ class Client: .. versionadded:: 1.5 member_cache_flags: :class:`MemberCacheFlags` Allows for finer control over how the library caches members. + If not given, defaults to cache as much as possible is with the + currently selected intents. .. versionadded:: 1.5 fetch_offline_members: :class:`bool` diff --git a/discord/flags.py b/discord/flags.py index a204937c..4607b2ce 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -754,6 +754,32 @@ class MemberCacheFlags(BaseFlags): """ return 4 + @classmethod + def from_intents(cls, intents): + """A factory method that creates a :class:`MemberCacheFlags` based on + the currently selected :class:`Intents`. + + Parameters + ------------ + intents: :class:`Intents` + The intents to select from. + + Returns + --------- + :class:`MemberCacheFlags` + The resulting member cache flags. + """ + + self = cls.none() + if intents.members: + self.joined = True + if intents.presences: + self.online = True + if intents.voice_states: + self.voice = True + + return self + def _verify_intents(self, intents): if self.online and not intents.presences: raise ValueError('MemberCacheFlags.online requires Intents.presences enabled') diff --git a/discord/state.py b/discord/state.py index db47059b..22bfdcca 100644 --- a/discord/state.py +++ b/discord/state.py @@ -144,12 +144,12 @@ class ConnectionState: cache_flags = options.get('member_cache_flags', None) if cache_flags is None: - cache_flags = MemberCacheFlags.all() + cache_flags = MemberCacheFlags.from_intents(intents) 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) + cache_flags._verify_intents(intents) self._member_cache_flags = cache_flags self._activity = activity From 625a5c2a0b0ed407b7db4c30cacfca77265f8e80 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 15 Sep 2020 02:03:41 -0400 Subject: [PATCH 41/58] Some documentation fixes for MemberCacheFlags and Intents --- discord/client.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index e44e5e21..dc6091bd 100644 --- a/discord/client.py +++ b/discord/client.py @@ -143,11 +143,12 @@ class Client: intents: :class:`Intents` 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. + If not given, defaults to a regularly constructed :class:`Intents` class. .. versionadded:: 1.5 member_cache_flags: :class:`MemberCacheFlags` Allows for finer control over how the library caches members. - If not given, defaults to cache as much as possible is with the + If not given, defaults to cache as much as possible with the currently selected intents. .. versionadded:: 1.5 From e644a5a060b069155a8a1f1508fbb1c57043c1a4 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 15 Sep 2020 02:20:39 -0400 Subject: [PATCH 42/58] Fix up wording in MemberCacheFlags exception --- discord/flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/flags.py b/discord/flags.py index 4607b2ce..1487a231 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -791,7 +791,7 @@ class MemberCacheFlags(BaseFlags): 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 ' \ + msg = 'Setting both MemberCacheFlags.voice and MemberCacheFlags.online requires MemberCacheFlags.joined ' \ 'to properly evict members from the cache.' raise ValueError(msg) From 213f55ffc7eb77557050234953f7baaa83f63bb2 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 15 Sep 2020 02:23:38 -0400 Subject: [PATCH 43/58] Disable voice cache in weird intent configurations. --- discord/flags.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/flags.py b/discord/flags.py index 1487a231..95445517 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -778,6 +778,9 @@ class MemberCacheFlags(BaseFlags): if intents.voice_states: self.voice = True + if not self.joined and self.online and self.voice: + self.voice = False + return self def _verify_intents(self, intents): From 11aaa03ec28fc4eb1474901dbe48a75a6709882e Mon Sep 17 00:00:00 2001 From: Muhammad Hamza Date: Thu, 17 Sep 2020 18:34:17 -0700 Subject: [PATCH 44/58] Fix presence intent docstring --- discord/flags.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/flags.py b/discord/flags.py index 95445517..47a3bcae 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -503,7 +503,7 @@ class Intents(BaseFlags): @flag_value def presences(self): - """:class:`bool`: Whether guild voice state related events are enabled. + """:class:`bool`: Whether guild presence related events are enabled. This corresponds to the following events: From 9dd4dc78c31b4763e309579474fcd72f6bb77f77 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 21 Sep 2020 03:54:54 -0400 Subject: [PATCH 45/58] Mention potential Guild.member_count accuracy issues --- discord/guild.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index 4247f105..21dd1262 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -706,7 +706,14 @@ class Guild(Hashable): @property def member_count(self): - """:class:`int`: Returns the true member count regardless of it being loaded fully or not.""" + """:class:`int`: Returns the true member count regardless of it being loaded fully or not. + + .. warning:: + + Due to a Discord limitation, in order for this attribute to remain up-to-date and + accurate, it requires :attr:`Intent.members` to be specified. + + """ return self._member_count @property From 2e06239d3c9c62887e479a21a9fd5bd4e7c6e473 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 23 Sep 2020 01:54:14 -0400 Subject: [PATCH 46/58] Add documentation for gateway intents --- discord/flags.py | 109 ++++++++++++++++++++- docs/images/discord_bot_tab.png | Bin 0 -> 9430 bytes docs/images/discord_privileged_intents.png | Bin 0 -> 51849 bytes docs/index.rst | 1 + docs/intents.rst | 98 ++++++++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) create mode 100644 docs/images/discord_bot_tab.png create mode 100644 docs/images/discord_privileged_intents.png create mode 100644 docs/intents.rst diff --git a/discord/flags.py b/discord/flags.py index 47a3bcae..f568c26f 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -418,6 +418,15 @@ class Intents(BaseFlags): - :func:`on_guild_channel_create` - :func:`on_guild_channel_delete` - :func:`on_guild_channel_pins_update` + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Client.guilds` + - :class:`Guild` and all its attributes. + - :meth:`Client.get_channel` + - :meth:`Client.get_all_channels` + + It is highly advisable to leave this intent enabled for your bot to function. """ return 1 << 0 @@ -432,9 +441,25 @@ class Intents(BaseFlags): - :func:`on_member_update` (nickname, roles) - :func:`on_user_update` + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Client.get_all_members` + - :meth:`Guild.chunk` + - :meth:`Guild.fetch_members` + - :meth:`Guild.members` + - :meth:`Guild.get_member` + - :attr:`Member.roles` + - :attr:`Member.nick` + - :attr:`Member.premium_since` + - :attr:`User.name` + - :attr:`User.avatar` (:meth:`User.avatar_url` and :meth:`User.avatar_url_as`) + - :attr:`User.discriminator` + + For more information go to the :ref:`member intent documentation `. + .. note:: - Currently, this requires opting in explicitly via the dev portal as well. + Currently, this requires opting in explicitly via the developer portal as well. Bots in over 100 guilds will need to apply to Discord for verification. """ return 1 << 1 @@ -447,6 +472,8 @@ class Intents(BaseFlags): - :func:`on_member_ban` - :func:`on_member_unban` + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 2 @@ -457,6 +484,13 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_guild_emojis_update` + + This also corresponds to the following attributes and classes in terms of cache: + + - :class:`Emoji` + - :meth:`Client.get_emoji` + - :meth:`Client.emojis` + - :attr:`Guild.emojis` """ return 1 << 3 @@ -467,6 +501,8 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_guild_integrations_update` + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 4 @@ -477,6 +513,8 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_webhooks_update` + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 5 @@ -488,6 +526,8 @@ class Intents(BaseFlags): - :func:`on_invite_create` - :func:`on_invite_delete` + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 6 @@ -498,6 +538,12 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_voice_state_update` + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`VoiceChannel.members` + - :attr:`VoiceChannel.voice_states` + - :attr:`Member.voice` """ return 1 << 7 @@ -509,9 +555,17 @@ class Intents(BaseFlags): - :func:`on_member_update` (activities, status) + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Member.activities` + - :attr:`Member.status` + - :attr:`Member.raw_status` + + For more information go to the :ref:`presence intent documentation `. + .. note:: - Currently, this requires opting in explicitly via the dev portal as well. + Currently, this requires opting in explicitly via the developer portal as well. Bots in over 100 guilds will need to apply to Discord for verification. """ return 1 << 8 @@ -530,6 +584,17 @@ class Intents(BaseFlags): - :func:`on_raw_message_delete` (both guilds and DMs) - :func:`on_raw_message_update` (both guilds and DMs) - :func:`on_private_channel_create` + + This also corresponds to the following attributes and classes in terms of cache: + + - :class:`Message` + - :attr:`Client.cached_messages` + + Note that due to an implicit relationship this also corresponds to the following events: + + - :func:`on_reaction_add` (both guilds and DMs) + - :func:`on_reaction_remove` (both guilds and DMs) + - :func:`on_reaction_clear` (both guilds and DMs) """ return (1 << 9) | (1 << 12) @@ -546,6 +611,17 @@ class Intents(BaseFlags): - :func:`on_message_delete` (only for guilds) - :func:`on_raw_message_delete` (only for guilds) - :func:`on_raw_message_update` (only for guilds) + + This also corresponds to the following attributes and classes in terms of cache: + + - :class:`Message` + - :attr:`Client.cached_messages` (only for guilds) + + Note that due to an implicit relationship this also corresponds to the following events: + + - :func:`on_reaction_add` (only for guilds) + - :func:`on_reaction_remove` (only for guilds) + - :func:`on_reaction_clear` (only for guilds) """ return 1 << 9 @@ -563,6 +639,17 @@ class Intents(BaseFlags): - :func:`on_raw_message_delete` (only for DMs) - :func:`on_raw_message_update` (only for DMs) - :func:`on_private_channel_create` + + This also corresponds to the following attributes and classes in terms of cache: + + - :class:`Message` + - :attr:`Client.cached_messages` (only for DMs) + + Note that due to an implicit relationship this also corresponds to the following events: + + - :func:`on_reaction_add` (only for DMs) + - :func:`on_reaction_remove` (only for DMs) + - :func:`on_reaction_clear` (only for DMs) """ return 1 << 12 @@ -580,6 +667,10 @@ class Intents(BaseFlags): - :func:`on_raw_reaction_add` (both guilds and DMs) - :func:`on_raw_reaction_remove` (both guilds and DMs) - :func:`on_raw_reaction_clear` (both guilds and DMs) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Message.reactions` (both guild and DM messages) """ return (1 << 10) | (1 << 13) @@ -597,6 +688,10 @@ class Intents(BaseFlags): - :func:`on_raw_reaction_add` (only for guilds) - :func:`on_raw_reaction_remove` (only for guilds) - :func:`on_raw_reaction_clear` (only for guilds) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Message.reactions` (only for guild messages) """ return 1 << 10 @@ -614,6 +709,10 @@ class Intents(BaseFlags): - :func:`on_raw_reaction_add` (only for DMs) - :func:`on_raw_reaction_remove` (only for DMs) - :func:`on_raw_reaction_clear` (only for DMs) + + This also corresponds to the following attributes and classes in terms of cache: + + - :attr:`Message.reactions` (only for DM messages) """ return 1 << 13 @@ -626,6 +725,8 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_typing` (both guilds and DMs) + + This does not correspond to any attributes or classes in the library in terms of cache. """ return (1 << 11) | (1 << 14) @@ -638,6 +739,8 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_typing` (only for guilds) + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 11 @@ -650,6 +753,8 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_typing` (only for DMs) + + This does not correspond to any attributes or classes in the library in terms of cache. """ return 1 << 14 diff --git a/docs/images/discord_bot_tab.png b/docs/images/discord_bot_tab.png new file mode 100644 index 0000000000000000000000000000000000000000..835682448e3fc42afb5a6480b12e9c559e04acdc GIT binary patch literal 9430 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 literal 0 HcmV?d00001 diff --git a/docs/images/discord_privileged_intents.png b/docs/images/discord_privileged_intents.png new file mode 100644 index 0000000000000000000000000000000000000000..297eabbb5e2d64b36df6246cea370b52125f0cbb GIT binary patch literal 51849 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 Tuj_vS|5P7oJuH9l?9KlIyuJ*i literal 0 HcmV?d00001 diff --git a/docs/index.rst b/docs/index.rst index 99868756..20c5d183 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -49,6 +49,7 @@ Additional Information :maxdepth: 2 discord + intents faq whats_new version_guarantees diff --git a/docs/intents.rst b/docs/intents.rst new file mode 100644 index 00000000..fd6d0bbe --- /dev/null +++ b/docs/intents.rst @@ -0,0 +1,98 @@ +.. currentmodule:: discord +.. versionadded:: 1.5 +.. _intents_primer: + +A Primer to Gateway Intents +============================= + +In version 1.5 comes with the introduction of :class:`Intents`. This is a radical change in how bots are written. An intent basically allows a bot to subscribe into specific buckets of events. The events that correspond to each intent is documented in the individual attribute of the :class:`Intents` documentation. + +These intents are passed to the constructor of :class:`Client` or its subclasses (:class:`AutoShardedClient`, :class:`~.AutoShardedBot`, :class:`~.Bot`) with the ``intents`` argument. + +If intents are not passed, then the library defaults to every intent being enabled except the privileged intents, currently :attr:`Intents.members` and :attr:`Intents.presences`. + +What intents are needed? +-------------------------- + +The intents that are necessary for your bot can only be dictated by yourself. Each attribute in the :class:`Intents` class documents what :ref:`events ` it corresponds to and what kind of cache it enables. + +For example, if you want a bot that functions without spammy events like presences or typing then we could do the following: + +.. code-block:: python3 + + import discord + intents = Intents(typing=False, presences=False) + +Note that this doesn't enable :attr:`Intents.members` since it's a privileged intent. + +Another example showing a bot that only deals with messages and guild information: + +.. code-block:: python3 + + import discord + intents = discord.Intents(messages=True, guilds=True) + # If you also want reaction events enable the following: + # intents.reactions = True + +.. _privileged_intents: + +Privileged Intents +--------------------- + +With the API change requiring bot authors to specify intents, some intents were restricted further and require more manual steps. These intents are called **privileged intents**. + +A privileged intent is one that requires you to go to the developer portal and manually enable it. To enable privileged intents do the following: + +1. Make sure you're logged on to the `Discord website `_. +2. Navigate to the `application page `_ +3. Click on the bot you want to enable privileged intents for. +4. Navigate to the bot tab on the left side of the screen. + + .. image:: /images/discord_bot_tab.png + :alt: The bot tab in the application page. + +5. Scroll down to the "Privileged Gateway Intents" section and enable the ones you want. + + .. image:: /images/discord_privileged_intents.png + :alt: The privileged gateway intents selector. + +.. warning:: + + Enabling privileged intents when your bot is in over 100 guilds requires going through `bot verification `_. If your bot is already verified and you would like to enable a privileged intent you must go through `discord support `_ and talk to them about it. + +Do I need privileged intents? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +This is a quick checklist to see if you need specific privileged intents. + +Presence Intent ++++++++++++++++++ + +- Whether you use :attr:`Member.status` at all to track member statuses. +- Whether you use :attr:`Member.activity` or :attr:`Member.activities` to check member's activities. + +Member Intent ++++++++++++++++ + +- Whether you track member joins or member leaves, corresponds to :func:`on_member_join` and :func:`on_member_remove` events. +- Whether you want to track member updates such as nickname or role changes. +- Whether you want to track user updates such as usernames, avatars, discriminators, etc. +- Whether you want to request the guild member list through :meth:`Guild.chunk` or :meth:`Guild.fetch_members`. +- Whether you want high accuracy member cache under :attr:`Guild.members`. + +Member Cache +------------- + +Along with intents, Discord now further restricts the ability to cache members and expects bot authors to cache as little as is necessary. However, to properly maintain a cache the :attr:`Intents.members` intent is required in order to track the members who left and properly evict them. + +To aid with member cache where we don't need members to be cached, the library now has a :class:`MemberCacheFlags` flag to control the member cache. The documentation page for the class goes over the specific policies that are possible. + +It should be noted that certain things do not need a member cache since Discord will provide full member information if possible. For example: + +- :func:`on_message` will have :attr:`Message.author` be a member even if cache is disabled. +- :func:`on_voice_state_update` will have the ``member`` parameter be a member even if cache is disabled. +- :func:`on_reaction_add` will have the ``user`` parameter be a member even if cache is disabled. +- :func:`on_raw_reaction_add` will have :attr:`RawReactionActionEvent.member` be a member even if cache is disabled. +- The reaction removal events do not have the member information. This is a Discord limitation. + +Other events that take a :class:`Member` will require the use of the member cache. If absolute accuracy over the member cache is desirable, then it is advisable to have the :attr:`Intents.members` intent enabled. From e8eb19b2b4ea4ebf9447590dc37c5bae7a871ae3 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 23 Sep 2020 07:58:57 -0400 Subject: [PATCH 47/58] Use /invites/ instead of /invite/ --- discord/http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/http.py b/discord/http.py index 1ad11d9e..6e726f87 100644 --- a/discord/http.py +++ b/discord/http.py @@ -810,7 +810,7 @@ class HTTPClient: params = { 'with_counts': int(with_counts) } - return self.request(Route('GET', '/invite/{invite_id}', invite_id=invite_id), params=params) + return self.request(Route('GET', '/invites/{invite_id}', invite_id=invite_id), params=params) def invites_from(self, guild_id): return self.request(Route('GET', '/guilds/{guild_id}/invites', guild_id=guild_id)) @@ -819,7 +819,7 @@ class HTTPClient: return self.request(Route('GET', '/channels/{channel_id}/invites', channel_id=channel_id)) def delete_invite(self, invite_id, *, reason=None): - return self.request(Route('DELETE', '/invite/{invite_id}', invite_id=invite_id), reason=reason) + return self.request(Route('DELETE', '/invites/{invite_id}', invite_id=invite_id), reason=reason) # Role management From 7126f5a78c4d08833ee42e3ba75cc5b8b5ac84c4 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 23 Sep 2020 08:03:06 -0400 Subject: [PATCH 48/58] Use delete_message_days instead of delete-message-days --- discord/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/http.py b/discord/http.py index 6e726f87..d1556e30 100644 --- a/discord/http.py +++ b/discord/http.py @@ -497,7 +497,7 @@ class HTTPClient: def ban(self, user_id, guild_id, delete_message_days=1, reason=None): r = Route('PUT', '/guilds/{guild_id}/bans/{user_id}', guild_id=guild_id, user_id=user_id) params = { - 'delete-message-days': delete_message_days, + 'delete_message_days': delete_message_days, } if reason: From 0ebf5b2fa729414af3e6ecaf169a33d55304ced9 Mon Sep 17 00:00:00 2001 From: apple502j <33279053+apple502j@users.noreply.github.com> Date: Thu, 24 Sep 2020 13:16:37 +0900 Subject: [PATCH 49/58] Add support for flag alias --- discord/flags.py | 24 +++++++++++++++++++++++- discord/permissions.py | 12 ++---------- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index f568c26f..57695912 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -50,6 +50,9 @@ class flag_value: def __repr__(self): return ''.format(self) +class alias_flag_value(flag_value): + pass + def fill_with_flags(*, inverted=False): def decorator(cls): cls.VALID_FLAGS = { @@ -98,6 +101,9 @@ class BaseFlags: def __iter__(self): for name, value in self.__class__.__dict__.items(): + if isinstance(value, alias_flag_value): + continue + if isinstance(value, flag_value): yield (name, self._has_flag(value.flag)) @@ -248,6 +254,14 @@ class PublicUserFlags(BaseFlags): .. describe:: x != y Checks if two PublicUserFlags 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. + Note that aliases are not shown. .. versionadded:: 1.4 @@ -323,7 +337,15 @@ class PublicUserFlags(BaseFlags): @flag_value def verified_bot_developer(self): - """:class:`bool`: Returns ``True`` if the user is a Verified Bot Developer.""" + """:class:`bool`: Returns ``True`` if the user is an Early Verified Bot Developer.""" + return UserFlags.verified_bot_developer.value + + @alias_flag_value + def early_verified_bot_developer(self): + """:class:`bool`: An alias for :attr:`verified_bot_developer`. + + .. versionadded:: 1.5 + """ return UserFlags.verified_bot_developer.value def all(self): diff --git a/discord/permissions.py b/discord/permissions.py index 55e26f91..9bd9f4e7 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -24,7 +24,7 @@ FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. """ -from .flags import BaseFlags, flag_value, fill_with_flags +from .flags import BaseFlags, flag_value, fill_with_flags, alias_flag_value __all__ = ( 'Permissions', @@ -33,7 +33,7 @@ __all__ = ( # A permission alias works like a regular flag but is marked # So the PermissionOverwrite knows to work with it -class permission_alias(flag_value): +class permission_alias(alias_flag_value): pass def make_permission_alias(alias): @@ -131,14 +131,6 @@ class Permissions(BaseFlags): __lt__ = is_strict_subset __gt__ = is_strict_superset - def __iter__(self): - for name, value in self.__class__.__dict__.items(): - if isinstance(value, permission_alias): - continue - - if isinstance(value, flag_value): - yield (name, self._has_flag(value.flag)) - @classmethod def none(cls): """A factory method that creates a :class:`Permissions` with all From 2974663367dfc924fbfb0fa5b7f2c39b71742ce9 Mon Sep 17 00:00:00 2001 From: Sebastian Law <44045823+SebbyLaw@users.noreply.github.com> Date: Wed, 23 Sep 2020 21:17:37 -0700 Subject: [PATCH 50/58] Fix typos in Intents documentation --- discord/flags.py | 16 ++++++++-------- docs/intents.rst | 6 +++++- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 57695912..3c5da0fb 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -465,11 +465,11 @@ class Intents(BaseFlags): This also corresponds to the following attributes and classes in terms of cache: - - :attr:`Client.get_all_members` + - :meth:`Client.get_all_members` - :meth:`Guild.chunk` - :meth:`Guild.fetch_members` - - :meth:`Guild.members` - :meth:`Guild.get_member` + - :attr:`Guild.members` - :attr:`Member.roles` - :attr:`Member.nick` - :attr:`Member.premium_since` @@ -601,10 +601,10 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_message` (both guilds and DMs) - - :func:`on_message_update` (both guilds and DMs) + - :func:`on_message_edit` (both guilds and DMs) - :func:`on_message_delete` (both guilds and DMs) - :func:`on_raw_message_delete` (both guilds and DMs) - - :func:`on_raw_message_update` (both guilds and DMs) + - :func:`on_raw_message_edit` (both guilds and DMs) - :func:`on_private_channel_create` This also corresponds to the following attributes and classes in terms of cache: @@ -629,10 +629,10 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_message` (only for guilds) - - :func:`on_message_update` (only for guilds) + - :func:`on_message_edit` (only for guilds) - :func:`on_message_delete` (only for guilds) - :func:`on_raw_message_delete` (only for guilds) - - :func:`on_raw_message_update` (only for guilds) + - :func:`on_raw_message_edit` (only for guilds) This also corresponds to the following attributes and classes in terms of cache: @@ -656,10 +656,10 @@ class Intents(BaseFlags): This corresponds to the following events: - :func:`on_message` (only for DMs) - - :func:`on_message_update` (only for DMs) + - :func:`on_message_edit` (only for DMs) - :func:`on_message_delete` (only for DMs) - :func:`on_raw_message_delete` (only for DMs) - - :func:`on_raw_message_update` (only for DMs) + - :func:`on_raw_message_edit` (only for DMs) - :func:`on_private_channel_create` This also corresponds to the following attributes and classes in terms of cache: diff --git a/docs/intents.rst b/docs/intents.rst index fd6d0bbe..ece5fac6 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -21,7 +21,7 @@ For example, if you want a bot that functions without spammy events like presenc .. code-block:: python3 import discord - intents = Intents(typing=False, presences=False) + intents = discord.Intents(typing=False, presences=False) Note that this doesn't enable :attr:`Intents.members` since it's a privileged intent. @@ -65,12 +65,16 @@ Do I need privileged intents? This is a quick checklist to see if you need specific privileged intents. +.. _need_presence_intent: + Presence Intent +++++++++++++++++ - Whether you use :attr:`Member.status` at all to track member statuses. - Whether you use :attr:`Member.activity` or :attr:`Member.activities` to check member's activities. +.. _need_members_intent: + Member Intent +++++++++++++++ From 005a80303f6b8e0199da5d9685d860c9b529172e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 24 Sep 2020 01:17:04 -0400 Subject: [PATCH 51/58] Add chunk_guilds_at_startup and deprecate fetch_offline_members --- discord/client.py | 20 +++++++++++++------- discord/shard.py | 9 +++++---- discord/state.py | 21 ++++++++++++++++----- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/discord/client.py b/discord/client.py index dc6091bd..fb255719 100644 --- a/discord/client.py +++ b/discord/client.py @@ -153,10 +153,14 @@ class Client: .. 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 - no offline members are received and :meth:`request_offline_members` - must be used to fetch the offline members of the guild. + A deprecated alias of ``chunk_guilds_at_startup``. + chunk_guilds_at_startup: :class:`bool` + Indicates if :func:`.on_ready` should be delayed to chunk all guilds + at start-up if necessary. This operation is incredibly slow for large + amounts of guilds. The default is ``True`` if :attr:`Intents.members` + is ``True``. + + .. versionadded:: 1.5 status: Optional[:class:`.Status`] A status to start your presence with upon logging on to Discord. activity: Optional[:class:`.BaseActivity`] @@ -243,9 +247,7 @@ class Client: 'before_identify': self._call_before_identify_hook } - self._connection = ConnectionState(dispatch=self.dispatch, handlers=self._handlers, - hooks=self._hooks, syncer=self._syncer, http=self.http, loop=self.loop, **options) - + self._connection = self._get_state(**options) self._connection.shard_count = self.shard_count self._closed = False self._ready = asyncio.Event() @@ -261,6 +263,10 @@ class Client: def _get_websocket(self, guild_id=None, *, shard_id=None): return self.ws + def _get_state(self, **options): + return ConnectionState(dispatch=self.dispatch, handlers=self._handlers, + hooks=self._hooks, syncer=self._syncer, http=self.http, loop=self.loop, **options) + async def _syncer(self, guilds): await self.ws.request_sync(guilds) diff --git a/discord/shard.py b/discord/shard.py index 3587c097..6985d797 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -295,10 +295,6 @@ class AutoShardedClient(Client): elif not isinstance(self.shard_ids, (list, tuple)): raise ClientException('shard_ids parameter must be a list or a tuple.') - self._connection = AutoShardedConnectionState(dispatch=self.dispatch, - handlers=self._handlers, syncer=self._syncer, - hooks=self._hooks, http=self.http, loop=self.loop, **kwargs) - # instead of a single websocket, we have multiple # the key is the shard_id self.__shards = {} @@ -311,6 +307,11 @@ class AutoShardedClient(Client): shard_id = (guild_id >> 22) % self.shard_count return self.__shards[shard_id].ws + def _get_state(self, **options): + return AutoShardedConnectionState(dispatch=self.dispatch, + handlers=self._handlers, syncer=self._syncer, + hooks=self._hooks, http=self.http, loop=self.loop, **options) + @property def latency(self): """:class:`float`: Measures latency between a HEARTBEAT and a HEARTBEAT_ACK in seconds. diff --git a/discord/state.py b/discord/state.py index 22bfdcca..22966d16 100644 --- a/discord/state.py +++ b/discord/state.py @@ -32,6 +32,7 @@ import itertools import logging import math import weakref +import warnings import inspect import gc @@ -103,7 +104,6 @@ class ConnectionState: self.hooks = hooks self.shard_count = None self._ready_task = None - self._fetch_offline = options.get('fetch_offline_members', True) self.heartbeat_timeout = options.get('heartbeat_timeout', 60.0) self.guild_ready_timeout = options.get('guild_ready_timeout', 2.0) if self.guild_ready_timeout < 0: @@ -136,12 +136,23 @@ class ConnectionState: if intents is not None: if not isinstance(intents, Intents): raise TypeError('intents parameter must be Intent not %r' % type(intents)) - - if not intents.members and self._fetch_offline: - raise ValueError('Intents.members has be enabled to fetch offline members.') else: intents = Intents() + try: + chunk_guilds = options['fetch_offline_members'] + except KeyError: + chunk_guilds = options.get('chunk_guilds_at_startup', intents.members) + else: + msg = 'fetch_offline_members is deprecated, use chunk_guilds_at_startup instead' + warnings.warn(msg, DeprecationWarning, stacklevel=4) + + self._chunk_guilds = chunk_guilds + + # Ensure these two are set properly + if not intents.members and self._chunk_guilds: + raise ValueError('Intents.members has be enabled to chunk guilds at startup.') + cache_flags = options.get('member_cache_flags', None) if cache_flags is None: cache_flags = MemberCacheFlags.from_intents(intents) @@ -333,7 +344,7 @@ class ConnectionState: def _guild_needs_chunking(self, guild): # If presences are enabled then we get back the old guild.large behaviour - return self._fetch_offline and not guild.chunked and not (self._intents.presences and not guild.large) + return self._chunk_guilds and not guild.chunked and not (self._intents.presences and not guild.large) def _get_guild_channel(self, data): channel_id = int(data['channel_id']) From 59d514fcdffac184e5dae45c8b4d4ebe9eb12427 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 24 Sep 2020 01:36:02 -0400 Subject: [PATCH 52/58] More intent related documentation --- discord/flags.py | 8 +++--- docs/intents.rst | 63 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 3c5da0fb..981d1b30 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -103,7 +103,7 @@ class BaseFlags: for name, value in self.__class__.__dict__.items(): if isinstance(value, alias_flag_value): continue - + if isinstance(value, flag_value): yield (name, self._has_flag(value.flag)) @@ -339,7 +339,7 @@ class PublicUserFlags(BaseFlags): def verified_bot_developer(self): """:class:`bool`: Returns ``True`` if the user is an Early Verified Bot Developer.""" return UserFlags.verified_bot_developer.value - + @alias_flag_value def early_verified_bot_developer(self): """:class:`bool`: An alias for :attr:`verified_bot_developer`. @@ -785,8 +785,8 @@ 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. + Note that the bot's own member is always cached. This class is passed + to the ``member_cache_flags`` parameter in :class:`Client`. 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` diff --git a/docs/intents.rst b/docs/intents.rst index ece5fac6..30725319 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -84,6 +84,8 @@ Member Intent - Whether you want to request the guild member list through :meth:`Guild.chunk` or :meth:`Guild.fetch_members`. - Whether you want high accuracy member cache under :attr:`Guild.members`. +.. _intents_member_cache: + Member Cache ------------- @@ -100,3 +102,64 @@ It should be noted that certain things do not need a member cache since Discord - The reaction removal events do not have the member information. This is a Discord limitation. Other events that take a :class:`Member` will require the use of the member cache. If absolute accuracy over the member cache is desirable, then it is advisable to have the :attr:`Intents.members` intent enabled. + +.. _retrieving_members: + +Retrieving Members +-------------------- + +If cache is disabled or you disable chunking guilds at startup, we might still need a way to load members. The library offers a few ways to do this: + +- :meth:`Guild.query_members` + - Used to query members by a prefix matching nickname or username. + - This can also be used to query members by their user ID. + - This uses the gateway and not the HTTP. +- :meth:`Guild.chunk` + - This can be used to fetch the entire member list through the gateway. +- :meth:`Guild.fetch_member` + - Used to fetch a member by ID through the HTTP API. +- :meth:`Guild.fetch_members` + - used to fetch a large number of members through the HTTP API. + +It should be noted that the gateway has a strict rate limit of 120 requests per 60 seconds. + +Troubleshooting +------------------ + +Some common issues relating to the mandatory intent change. + +Where'd my members go? +~~~~~~~~~~~~~~~~~~~~~~~~ + +Due to an :ref:`API change ` Discord is now forcing developers who want member caching to explicitly opt-in to it. This is a Discord mandated change and there is no way to bypass it. In order to get members back you have to explicitly enable the :ref:`members privileged intent ` and change the :attr:`Intents.members` attribute to true. + +For example: + +.. code-block:: python3 + + import discord + intents = discord.Intents() + intents.members = True + + # Somewhere else: + # client = discord.Client(intents=intents) + # or + # from discord.ext import commands + # bot = commands.Bot(intents=intents) + +Why does ``on_ready`` take so long to fire? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +As part of the API change regarding intents, Discord also changed how members are loaded in the beginning. Originally the library could request 75 guilds at once and only request members from guilds that have the :attr:`Guild.large` attribute set to ``True``. With the new intent changes, Discord mandates that we can only send 1 guild per request. This causes a 75x slowdown which is further compounded by the fact that *all* guilds, not just large guilds are being requested. + +There are a few solutions to fix this. + +The first solution is to request the privileged presences intent along with the privileged members intent and enable both of them. This allows the initial member list to contain online members just like the old gateway. Note that we're still limited to 1 guild per request but the number of guilds we request is significantly reduced. + +The second solution is to disable member chunking by setting ``chunk_guilds_at_startup`` to ``False`` when constructing a client. Then, when chunking for a guild is necessary you can use the various techniques to :ref:`retrieve members `. + +To illustrate the slowdown caused the API change, take a bot who is in 840 guilds and 95 of these guilds are "large" (over 250 members). + +Under the original system this would result in 2 requests to fetch the member list (75 guilds, 20 guilds) roughly taking 60 seconds. With :attr:`Intents.members` but not :attr:`Intents.presences` this requires 840 requests, with a rate limit of 120 requests per 60 seconds means that due to waiting for the rate limit it totals to around 7 minutes of waiting for the rate limit to fetch all the members. With both :attr:`Intents.members` and :attr:`Intents.presences` we mostly get the old behaviour so we're only required to request for the 95 guilds that are large, this is slightly less than our rate limit so it's close to the original timing to fetch the member list. + +Unfortunately due to this change being required from Discord there is nothing that the library can do to mitigate this. From de6c5ef675de4d53d597cb53d710584324dbc570 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 24 Sep 2020 02:00:37 -0400 Subject: [PATCH 53/58] More intent documentation. Mention that you can downgrade the library to revert. Also mention that intents need to be updated in the code as well as the developer portal. --- docs/intents.rst | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/intents.rst b/docs/intents.rst index 30725319..13d58221 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -60,6 +60,11 @@ A privileged intent is one that requires you to go to the developer portal and m Enabling privileged intents when your bot is in over 100 guilds requires going through `bot verification `_. If your bot is already verified and you would like to enable a privileged intent you must go through `discord support `_ and talk to them about it. +.. note:: + + Even if you enable intents through the developer portal, you still have to enable the intents + through code as well. + Do I need privileged intents? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -163,3 +168,22 @@ To illustrate the slowdown caused the API change, take a bot who is in 840 guild Under the original system this would result in 2 requests to fetch the member list (75 guilds, 20 guilds) roughly taking 60 seconds. With :attr:`Intents.members` but not :attr:`Intents.presences` this requires 840 requests, with a rate limit of 120 requests per 60 seconds means that due to waiting for the rate limit it totals to around 7 minutes of waiting for the rate limit to fetch all the members. With both :attr:`Intents.members` and :attr:`Intents.presences` we mostly get the old behaviour so we're only required to request for the 95 guilds that are large, this is slightly less than our rate limit so it's close to the original timing to fetch the member list. Unfortunately due to this change being required from Discord there is nothing that the library can do to mitigate this. + +I don't like this, can I go back? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For now, the old gateway will still work so downgrading to discord.py v1.4 is still possible and will continue to be supported until Discord officially kills the v6 gateway, which is imminent. However it is paramount that for the future of your bot that you upgrade your code to the new way things are done. + +To downgrade you can do the following: + +.. code-block:: python3 + + python3 -m pip install -U "discord.py>=1.4,<1.5" + +On Windows use ``py -3`` instead of ``python3``. + +.. warning:: + + There is no date in which the old gateway will stop working so it is recommended to update your code instead. + +If you truly dislike the direction Discord is going with their API, you can contact them via `support `_ From dc6e9e7fd6b6a62bfd21a190f66672ea3d2b5735 Mon Sep 17 00:00:00 2001 From: apple502j <33279053+apple502j@users.noreply.github.com> Date: Thu, 24 Sep 2020 22:00:47 +0900 Subject: [PATCH 54/58] Fix typo in ValueError message --- discord/state.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/state.py b/discord/state.py index 22966d16..3257d41d 100644 --- a/discord/state.py +++ b/discord/state.py @@ -151,7 +151,7 @@ class ConnectionState: # Ensure these two are set properly if not intents.members and self._chunk_guilds: - raise ValueError('Intents.members has be enabled to chunk guilds at startup.') + raise ValueError('Intents.members must be enabled to chunk guilds at startup.') cache_flags = options.get('member_cache_flags', None) if cache_flags is None: From e4d7f44aa59d2bbd0f98f461e4b6504426a7eb8b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 24 Sep 2020 09:00:07 -0400 Subject: [PATCH 55/58] Make Intent class creation more intuitive --- discord/flags.py | 20 +++++++++++--------- discord/state.py | 2 +- docs/intents.rst | 4 +++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/discord/flags.py b/discord/flags.py index 981d1b30..38a6ac3f 100644 --- a/discord/flags.py +++ b/discord/flags.py @@ -368,9 +368,6 @@ class Intents(BaseFlags): run your bot. To make use of this, it is passed to the ``intents`` keyword argument of :class:`Client`. - A default instance of this class has everything enabled except :attr:`presences` - and :attr:`members`. - .. versionadded:: 1.5 .. container:: operations @@ -399,12 +396,7 @@ class Intents(BaseFlags): __slots__ = () def __init__(self, **kwargs): - # Change the default value to everything being enabled - # except presences and members - bits = max(self.VALID_FLAGS.values()).bit_length() - self.value = (1 << bits) - 1 - self.presences = False - self.members = False + self.value = self.DEFAULT_VALUE for key, value in kwargs.items(): if key not in self.VALID_FLAGS: raise TypeError('%r is not a valid flag name.' % key) @@ -426,6 +418,16 @@ class Intents(BaseFlags): self.value = self.DEFAULT_VALUE return self + @classmethod + def default(cls): + """A factory method that creates a :class:`Intents` with everything enabled + except :attr:`presences` and :attr:`members`. + """ + self = cls.all() + self.presences = False + self.members = False + return self + @flag_value def guilds(self): """:class:`bool`: Whether guild related events are enabled. diff --git a/discord/state.py b/discord/state.py index 3257d41d..507226cc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -137,7 +137,7 @@ class ConnectionState: if not isinstance(intents, Intents): raise TypeError('intents parameter must be Intent not %r' % type(intents)) else: - intents = Intents() + intents = Intents.default() try: chunk_guilds = options['fetch_offline_members'] diff --git a/docs/intents.rst b/docs/intents.rst index 13d58221..6e18e738 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -21,7 +21,9 @@ For example, if you want a bot that functions without spammy events like presenc .. code-block:: python3 import discord - intents = discord.Intents(typing=False, presences=False) + intents = discord.Intents.default() + intents.typing = False + intents.presences = False Note that this doesn't enable :attr:`Intents.members` since it's a privileged intent. From 3084deee182bcdb5eda9092a531eb1bd17f7754a Mon Sep 17 00:00:00 2001 From: Nekokatt Date: Sat, 26 Sep 2020 11:34:01 +0100 Subject: [PATCH 56/58] Fixed incorrectly named 'Intent' class in doc. `Intent.members` -> `Intents.members`. --- discord/guild.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/guild.py b/discord/guild.py index 21dd1262..4faa0125 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -711,7 +711,7 @@ class Guild(Hashable): .. warning:: Due to a Discord limitation, in order for this attribute to remain up-to-date and - accurate, it requires :attr:`Intent.members` to be specified. + accurate, it requires :attr:`Intents.members` to be specified. """ return self._member_count From 28e5b2a5e133970aff7d0f4db1d973c7516e429a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 26 Sep 2020 06:23:24 -0400 Subject: [PATCH 57/58] Add Client.intents to query the current intents Closes #5854 --- discord/client.py | 8 ++++++++ discord/state.py | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/discord/client.py b/discord/client.py index fb255719..1ddb09d6 100644 --- a/discord/client.py +++ b/discord/client.py @@ -749,6 +749,14 @@ class Client: else: raise TypeError('allowed_mentions must be AllowedMentions not {0.__class__!r}'.format(value)) + @property + def intents(self): + """:class:`Intents`: The intents configured for this connection. + + .. versionadded:: 1.5 + """ + return self._connection.intents + # helpers/getters @property diff --git a/discord/state.py b/discord/state.py index 507226cc..aec723d4 100644 --- a/discord/state.py +++ b/discord/state.py @@ -231,6 +231,12 @@ class ConnectionState: u = self.user return u.id if u else None + @property + def intents(self): + ret = Intents.none() + ret.value = self._intents.value + return ret + @property def voice_clients(self): return list(self._voice_clients.values()) From 759fb52e56d460b6f79c95f47dca4401d708edb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 28 Sep 2020 00:08:19 +0100 Subject: [PATCH 58/58] Update intents with small typo fixes --- docs/intents.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/intents.rst b/docs/intents.rst index 6e18e738..17c43592 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -5,7 +5,7 @@ A Primer to Gateway Intents ============================= -In version 1.5 comes with the introduction of :class:`Intents`. This is a radical change in how bots are written. An intent basically allows a bot to subscribe into specific buckets of events. The events that correspond to each intent is documented in the individual attribute of the :class:`Intents` documentation. +In version 1.5 comes the introduction of :class:`Intents`. This is a radical change in how bots are written. An intent basically allows a bot to subscribe into specific buckets of events. The events that correspond to each intent is documented in the individual attribute of the :class:`Intents` documentation. These intents are passed to the constructor of :class:`Client` or its subclasses (:class:`AutoShardedClient`, :class:`~.AutoShardedBot`, :class:`~.Bot`) with the ``intents`` argument. @@ -152,7 +152,7 @@ For example: # client = discord.Client(intents=intents) # or # from discord.ext import commands - # bot = commands.Bot(intents=intents) + # bot = commands.Bot(command_prefix="!", intents=intents) Why does ``on_ready`` take so long to fire? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~