diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 1f3084ba..fa949245 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,26 +1,27 @@ --- name: Bug Report about: Report broken or incorrect behaviour +labels: bug --- -### Summary +## Summary -### Reproduction Steps +## Reproduction Steps -### Expected Results +## Expected Results -### Actual Results +## Actual Results -### Checklist +## Checklist @@ -28,7 +29,7 @@ about: Report broken or incorrect behaviour - [ ] 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/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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md index 8bb8edeb..4badd49e 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.md +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -1,26 +1,27 @@ --- name: Feature Request 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 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] diff --git a/discord/__init__.py b/discord/__init__.py index b936cdbe..c6b21593 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 @@ -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 452e1a1d..0ac87d13 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -37,7 +37,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: @@ -699,6 +699,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 @@ -878,7 +883,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() @@ -1049,7 +1054,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. @@ -1068,7 +1072,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 @@ -1082,6 +1086,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 ------- @@ -1094,20 +1101,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/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 36df67b6..1ddb09d6 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 @@ -144,17 +143,24 @@ 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 with the + currently selected intents. .. 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`] @@ -241,13 +247,12 @@ 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() self._connection._get_websocket = self._get_websocket + self._connection._get_client = lambda: self if VoiceClient.warn_nacl: VoiceClient.warn_nacl = False @@ -258,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) @@ -309,11 +318,14 @@ 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): - """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): @@ -701,7 +713,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 @@ -737,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 @@ -818,6 +838,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: @@ -832,6 +857,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/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/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/flags.py b/discord/flags.py index acf61302..0c81b103 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): @@ -346,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 @@ -377,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) @@ -404,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. @@ -418,6 +442,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 +465,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: + + - :meth:`Client.get_all_members` + - :meth:`Guild.chunk` + - :meth:`Guild.fetch_members` + - :meth:`Guild.get_member` + - :attr:`Guild.members` + - :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 +496,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 +508,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 +525,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 +537,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 +550,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,20 +562,35 @@ 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 @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: - :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 @@ -525,11 +604,22 @@ 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: + + - :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) @@ -542,10 +632,21 @@ 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: + + - :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 @@ -558,11 +659,22 @@ 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: + + - :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 +692,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 +713,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 +734,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 +750,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 +764,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 +778,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 @@ -658,8 +788,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` @@ -754,6 +884,35 @@ 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 + + if not self.joined and self.online and self.voice: + self.voice = False + + return self + def _verify_intents(self, intents): if self.online and not intents.presences: raise ValueError('MemberCacheFlags.online requires Intents.presences enabled') @@ -765,7 +924,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) diff --git a/discord/gateway.py b/discord/gateway.py index 9db98301..92fa4f56 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -693,8 +693,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 @@ -710,7 +710,7 @@ class DiscordVoiceWebSocket: HEARTBEAT_ACK = 6 RESUME = 7 HELLO = 8 - INVALIDATE_SESSION = 9 + RESUMED = 9 CLIENT_CONNECT = 12 CLIENT_DISCONNECT = 13 @@ -815,9 +815,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) @@ -833,7 +832,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) @@ -854,8 +855,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/guild.py b/discord/guild.py index 695898b1..becba235 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -381,7 +381,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 @@ -716,7 +716,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:`Intents.members` to be specified. + + """ return self._member_count @property diff --git a/discord/http.py b/discord/http.py index 1ad11d9e..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: @@ -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 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/member.py b/discord/member.py index 3d45e5df..59bd7bda 100644 --- a/discord/member.py +++ b/discord/member.py @@ -200,6 +200,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 @@ -315,7 +321,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 @@ -401,6 +407,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 7e8440f4..4ba2a826 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]) @@ -476,8 +517,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 @@ -573,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..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 @@ -495,10 +487,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 +519,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/shard.py b/discord/shard.py index 8e5b75cd..6985d797 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -295,14 +295,11 @@ 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 = {} 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): @@ -310,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 7e2b260a..aec723d4 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 @@ -82,6 +83,12 @@ class ChunkRequest: log = logging.getLogger(__name__) +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 @@ -97,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: @@ -130,21 +136,31 @@ 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() + intents = Intents.default() + + 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 must be enabled to chunk guilds at startup.') 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 @@ -215,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()) @@ -328,7 +350,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']) @@ -941,9 +963,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: @@ -971,7 +992,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/user.py b/discord/user.py index b010e3d7..135eb7e9 100644 --- a/discord/user.py +++ b/discord/user.py @@ -147,7 +147,7 @@ class BaseUser(_BaseUser): return str(self.avatar_url_as(static_format="png", 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): @@ -259,6 +259,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: @@ -703,6 +708,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: @@ -748,7 +758,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:: @@ -760,7 +770,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/discord/voice_client.py b/discord/voice_client.py index ab9a6406..a16aaf41 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,116 @@ 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_update_payload: https://discord.com/developers/docs/resources/voice#voice-state-object + + __ voice_state_update_payload_ + """ + 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`__. + + .. _voice_server_update_payload: https://discord.com/developers/docs/topics/gateway#voice-server-update-voice-server-update-event-fields + + __ voice_server_update_payload_ + """ + 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 constructor + under ``__init__`` and then calls :meth:`connect`. If :meth:`connect` fails at + some point then :meth:`disconnect` is called. + + 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 + ------------ + 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 +194,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 +208,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 +246,28 @@ 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 + 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() - # 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 +283,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 +376,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 +400,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 +423,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 +439,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/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.""" diff --git a/docs/api.rst b/docs/api.rst index 0a2f5521..d697b4f3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -54,6 +54,9 @@ Voice .. autoclass:: VoiceClient() :members: +.. autoclass:: VoiceProtocol + :members: + .. autoclass:: AudioSource :members: @@ -984,6 +987,11 @@ of :class:`enum.Enum`. .. attribute:: custom A custom activity type. + .. attribute:: competing + + A competing activity type. + + .. versionadded:: 1.5 .. class:: HypeSquadHouse @@ -2731,6 +2739,11 @@ Widget .. autoclass:: Widget() :members: +MessageReference +~~~~~~~~~~~~~~~~~ +.. autoclass:: MessageReference() + :members: + RawMessageDeleteEvent ~~~~~~~~~~~~~~~~~~~~~~~ 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) 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? ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/docs/images/discord_bot_tab.png b/docs/images/discord_bot_tab.png new file mode 100644 index 00000000..83568244 Binary files /dev/null and b/docs/images/discord_bot_tab.png differ diff --git a/docs/images/discord_privileged_intents.png b/docs/images/discord_privileged_intents.png new file mode 100644 index 00000000..297eabbb Binary files /dev/null and b/docs/images/discord_privileged_intents.png differ 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..17c43592 --- /dev/null +++ b/docs/intents.rst @@ -0,0 +1,191 @@ +.. currentmodule:: discord +.. versionadded:: 1.5 +.. _intents_primer: + +A Primer to Gateway Intents +============================= + +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. + +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 = discord.Intents.default() + intents.typing = False + intents.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. + +.. 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? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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 ++++++++++++++++ + +- 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`. + +.. _intents_member_cache: + +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. + +.. _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(command_prefix="!", 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. + +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 `_