diff --git a/discord/__init__.py b/discord/__init__.py index e3148e51378b..765719b68731 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.4.0a' +__version__ = '2.5.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -80,7 +80,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=4, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=5, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) diff --git a/discord/abc.py b/discord/abc.py index 656a38659f90..fec57b52af98 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -49,7 +49,7 @@ from .object import OLDEST_OBJECT, Object from .context_managers import Typing from .enums import ChannelType, InviteTarget -from .errors import ClientException +from .errors import ClientException, NotFound from .mentions import AllowedMentions from .permissions import PermissionOverwrite, Permissions from .role import Role @@ -122,7 +122,14 @@ def __repr__(self) -> str: async def _single_delete_strategy(messages: Iterable[Message], *, reason: Optional[str] = None): for m in messages: - await m.delete() + try: + await m.delete() + except NotFound as exc: + if exc.code == 10008: + continue # bulk deletion ignores not found messages, single deletion does not. + # several other race conditions with deletion should fail without continuing, + # such as the channel being deleted and not found. + raise async def _purge_helper( @@ -699,6 +706,7 @@ def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: - Member overrides - Implicit permissions - Member timeout + - User installed app If a :class:`~discord.Role` is passed, then it checks the permissions someone with that role would have, which is essentially: @@ -714,6 +722,12 @@ def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: .. versionchanged:: 2.0 ``obj`` parameter is now positional-only. + .. versionchanged:: 2.4 + User installed apps are now taken into account. + The permissions returned for a user installed app mirrors the + permissions Discord returns in :attr:`~discord.Interaction.app_permissions`, + though it is recommended to use that attribute instead. + Parameters ---------- obj: Union[:class:`~discord.Member`, :class:`~discord.Role`] @@ -745,6 +759,13 @@ def permissions_for(self, obj: Union[Member, Role], /) -> Permissions: return Permissions.all() default = self.guild.default_role + if default is None: + + if self._state.self_id == obj.id: + return Permissions._user_installed_permissions(in_guild=True) + else: + return Permissions.none() + base = Permissions(default.permissions.value) # Handle the role case first diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index c54fff4087c8..6e898143c9ca 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -2372,6 +2372,12 @@ def guilds(*guild_ids: Union[Snowflake, int]) -> Callable[[T], T]: with the :meth:`CommandTree.command` or :meth:`CommandTree.context_menu` decorator then this must go below that decorator. + .. note :: + + Due to a Discord limitation, this decorator cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + Example: .. code-block:: python3 @@ -2545,6 +2551,8 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]] Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + .. versionadded:: 2.4 + Examples --------- @@ -2636,6 +2644,8 @@ def allowed_contexts(guilds: bool = MISSING, dms: bool = MISSING, private_channe Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + .. versionadded:: 2.4 + Examples --------- @@ -2687,6 +2697,8 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + .. versionadded:: 2.4 + Examples --------- @@ -2735,6 +2747,8 @@ def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + .. versionadded:: 2.4 + Examples --------- @@ -2777,6 +2791,8 @@ def allowed_installs( Due to a Discord limitation, this decorator does nothing in subcommands and is ignored. + .. versionadded:: 2.4 + Examples --------- diff --git a/discord/app_commands/installs.py b/discord/app_commands/installs.py index 7d9b2f049245..5ac033245ab7 100644 --- a/discord/app_commands/installs.py +++ b/discord/app_commands/installs.py @@ -78,8 +78,8 @@ def user(self, value: bool) -> None: def merge(self, other: AppInstallationType) -> AppInstallationType: # Merging is similar to AllowedMentions where `self` is the base # and the `other` is the override preference - guild = self.guild if other.guild is None else other.guild - user = self.user if other.user is None else other.user + guild = self._guild if other._guild is None else other._guild + user = self._user if other._user is None else other._user return AppInstallationType(guild=guild, user=user) def _is_unset(self) -> bool: @@ -170,9 +170,9 @@ def private_channel(self, value: bool) -> None: self._private_channel = bool(value) def merge(self, other: AppCommandContext) -> AppCommandContext: - guild = self.guild if other.guild is None else other.guild - dm_channel = self.dm_channel if other.dm_channel is None else other.dm_channel - private_channel = self.private_channel if other.private_channel is None else other.private_channel + guild = self._guild if other._guild is None else other._guild + dm_channel = self._dm_channel if other._dm_channel is None else other._dm_channel + private_channel = self._private_channel if other._private_channel is None else other._private_channel return AppCommandContext(guild=guild, dm_channel=dm_channel, private_channel=private_channel) def _is_unset(self) -> bool: diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index abd8924806fd..bc0d68ec7938 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -307,10 +307,24 @@ def add_command( guild: Optional[:class:`~discord.abc.Snowflake`] The guild to add the command to. If not given or ``None`` then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + guilds: List[:class:`~discord.abc.Snowflake`] The list of guilds to add the command to. This cannot be mixed with the ``guild`` parameter. If no guilds are given at all then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + override: :class:`bool` Whether to override a command with the same name. If ``False`` an exception is raised. Default is ``False``. @@ -877,10 +891,24 @@ def command( guild: Optional[:class:`~discord.abc.Snowflake`] The guild to add the command to. If not given or ``None`` then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + guilds: List[:class:`~discord.abc.Snowflake`] The list of guilds to add the command to. This cannot be mixed with the ``guild`` parameter. If no guilds are given at all then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + auto_locale_strings: :class:`bool` If this is set to ``True``, then all translatable strings will implicitly be wrapped into :class:`locale_str` rather than :class:`str`. This could @@ -960,10 +988,24 @@ async def ban(interaction: discord.Interaction, user: discord.Member): guild: Optional[:class:`~discord.abc.Snowflake`] The guild to add the command to. If not given or ``None`` then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + guilds: List[:class:`~discord.abc.Snowflake`] The list of guilds to add the command to. This cannot be mixed with the ``guild`` parameter. If no guilds are given at all then it becomes a global command instead. + + .. note :: + + Due to a Discord limitation, this keyword argument cannot be used in conjunction with + contexts (e.g. :func:`.app_commands.allowed_contexts`) or installation types + (e.g. :func:`.app_commands.allowed_installs`). + auto_locale_strings: :class:`bool` If this is set to ``True``, then all translatable strings will implicitly be wrapped into :class:`locale_str` rather than :class:`str`. This could diff --git a/discord/channel.py b/discord/channel.py index f60e22c0d91a..55b25a03c4ca 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -1702,6 +1702,7 @@ async def edit( *, name: str = ..., nsfw: bool = ..., + bitrate: int = ..., user_limit: int = ..., position: int = ..., sync_permissions: int = ..., @@ -1738,6 +1739,8 @@ async def edit(self, *, reason: Optional[str] = None, **options: Any) -> Optiona ---------- name: :class:`str` The new channel's name. + bitrate: :class:`int` + The new channel's bitrate. position: :class:`int` The new channel's position. nsfw: :class:`bool` diff --git a/discord/components.py b/discord/components.py index 43a8f6ffc6bf..2af2d6d20d8b 100644 --- a/discord/components.py +++ b/discord/components.py @@ -170,6 +170,10 @@ class Button(Component): The label of the button, if any. emoji: Optional[:class:`PartialEmoji`] The emoji of the button, if available. + sku_id: Optional[:class:`int`] + The SKU ID this button sends you to, if available. + + .. versionadded:: 2.4 """ __slots__: Tuple[str, ...] = ( @@ -179,6 +183,7 @@ class Button(Component): 'disabled', 'label', 'emoji', + 'sku_id', ) __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ @@ -195,6 +200,11 @@ def __init__(self, data: ButtonComponentPayload, /) -> None: except KeyError: self.emoji = None + try: + self.sku_id: Optional[int] = int(data['sku_id']) + except KeyError: + self.sku_id = None + @property def type(self) -> Literal[ComponentType.button]: """:class:`ComponentType`: The type of component.""" @@ -207,6 +217,9 @@ def to_dict(self) -> ButtonComponentPayload: 'disabled': self.disabled, } + if self.sku_id: + payload['sku_id'] = str(self.sku_id) + if self.label: payload['label'] = self.label diff --git a/discord/enums.py b/discord/enums.py index f7989a195e0a..eaf8aef5e058 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -603,7 +603,7 @@ class InteractionResponseType(Enum): message_update = 7 # for components autocomplete_result = 8 modal = 9 # for modals - premium_required = 10 + # premium_required = 10 (deprecated) class VideoQualityMode(Enum): @@ -635,6 +635,7 @@ class ButtonStyle(Enum): success = 3 danger = 4 link = 5 + premium = 6 # Aliases blurple = 1 @@ -823,6 +824,17 @@ class PollLayoutType(Enum): default = 1 +class InviteType(Enum): + guild = 0 + group_dm = 1 + friend = 2 + + +class ReactionType(Enum): + normal = 0 + burst = 1 + + def create_unknown_value(cls: Type[E], val: Any) -> E: value_cls = cls._enum_value_cls_ # type: ignore # This is narrowed below name = f'unknown_{val}' diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index 41e0f6c4a5b5..830c58662bc4 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -438,19 +438,36 @@ class GuildChannelConverter(IDConverter[discord.abc.GuildChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name. + 3. Lookup by channel URL. + 4. Lookup by name. .. versionadded:: 2.0 + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.abc.GuildChannel: return self._resolve_channel(ctx, argument, 'channels', discord.abc.GuildChannel) + @staticmethod + def _parse_from_url(argument: str) -> Optional[re.Match[str]]: + link_regex = re.compile( + r'https?://(?:(?:ptb|canary|www)\.)?discord(?:app)?\.com/channels/' + r'(?:[0-9]{15,20}|@me)' + r'/([0-9]{15,20})(?:/(?:[0-9]{15,20})/?)?$' + ) + return link_regex.match(argument) + @staticmethod def _resolve_channel(ctx: Context[BotT], argument: str, attribute: str, type: Type[CT]) -> CT: bot = ctx.bot - match = IDConverter._get_id_match(argument) or re.match(r'<#([0-9]{15,20})>$', argument) + match = ( + IDConverter._get_id_match(argument) + or re.match(r'<#([0-9]{15,20})>$', argument) + or GuildChannelConverter._parse_from_url(argument) + ) result = None guild = ctx.guild @@ -480,7 +497,11 @@ def check(c): @staticmethod def _resolve_thread(ctx: Context[BotT], argument: str, attribute: str, type: Type[TT]) -> TT: - match = IDConverter._get_id_match(argument) or re.match(r'<#([0-9]{15,20})>$', argument) + match = ( + IDConverter._get_id_match(argument) + or re.match(r'<#([0-9]{15,20})>$', argument) + or GuildChannelConverter._parse_from_url(argument) + ) result = None guild = ctx.guild @@ -510,10 +531,14 @@ class TextChannelConverter(IDConverter[discord.TextChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name + 3. Lookup by channel URL. + 4. Lookup by name .. versionchanged:: 1.5 Raise :exc:`.ChannelNotFound` instead of generic :exc:`.BadArgument` + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.TextChannel: @@ -530,10 +555,14 @@ class VoiceChannelConverter(IDConverter[discord.VoiceChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name + 3. Lookup by channel URL. + 4. Lookup by name .. versionchanged:: 1.5 Raise :exc:`.ChannelNotFound` instead of generic :exc:`.BadArgument` + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.VoiceChannel: @@ -552,7 +581,11 @@ class StageChannelConverter(IDConverter[discord.StageChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name + 3. Lookup by channel URL. + 4. Lookup by name + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.StageChannel: @@ -569,7 +602,11 @@ class CategoryChannelConverter(IDConverter[discord.CategoryChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name + 3. Lookup by channel URL. + 4. Lookup by name + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. .. versionchanged:: 1.5 Raise :exc:`.ChannelNotFound` instead of generic :exc:`.BadArgument` @@ -588,9 +625,13 @@ class ThreadConverter(IDConverter[discord.Thread]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name. + 3. Lookup by channel URL. + 4. Lookup by name. .. versionadded: 2.0 + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.Thread: @@ -607,9 +648,13 @@ class ForumChannelConverter(IDConverter[discord.ForumChannel]): 1. Lookup by ID. 2. Lookup by mention. - 3. Lookup by name + 3. Lookup by channel URL. + 4. Lookup by name .. versionadded:: 2.0 + + .. versionchanged:: 2.4 + Add lookup by channel URL, accessed via "Copy Link" in the Discord client within channels. """ async def convert(self, ctx: Context[BotT], argument: str) -> discord.ForumChannel: diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index 163bc2694936..d06fbd8bf27d 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -297,6 +297,11 @@ def _eject_cog(self) -> None: # Revert `on_error` to use the original one in case of race conditions self.on_error = self._injected.on_help_command_error + def update(self, **kwargs: Any) -> None: + cog = self.cog + self.__init__(self._original, **dict(self.__original_kwargs__, **kwargs)) + self.cog = cog + class HelpCommand: r"""The base implementation for help command formatting. @@ -377,9 +382,8 @@ def copy(self) -> Self: return obj def _add_to_bot(self, bot: BotBase) -> None: - command = _HelpCommandImpl(self, **self.command_attrs) - bot.add_command(command) - self._command_impl = command + self._command_impl.update(**self.command_attrs) + bot.add_command(self._command_impl) def _remove_from_bot(self, bot: BotBase) -> None: bot.remove_command(self._command_impl.name) diff --git a/discord/guild.py b/discord/guild.py index 59c216f2f768..8dca0f4986d4 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -432,27 +432,11 @@ def _update_voice_state(self, data: GuildVoiceState, channel_id: int) -> Tuple[O return member, before, after def _add_role(self, role: Role, /) -> None: - # roles get added to the bottom (position 1, pos 0 is @everyone) - # so since self.roles has the @everyone role, we can't increment - # its position because it's stuck at position 0. Luckily x += False - # is equivalent to adding 0. So we cast the position to a bool and - # increment it. - for r in self._roles.values(): - r.position += not r.is_default() - self._roles[role.id] = role def _remove_role(self, role_id: int, /) -> Role: # this raises KeyError if it fails.. - role = self._roles.pop(role_id) - - # since it didn't, we can change the positions now - # basically the same as above except we only decrement - # the position if we're above the role we deleted. - for r in self._roles.values(): - r.position -= r.position > role.position - - return role + return self._roles.pop(role_id) @classmethod def _create_unavailable(cls, *, state: ConnectionState, guild_id: int, data: Optional[Dict[str, Any]]) -> Guild: @@ -4390,7 +4374,7 @@ async def create_automod_rule( actions=[a.to_dict() for a in actions], enabled=enabled, exempt_roles=[str(r.id) for r in exempt_roles] if exempt_roles else None, - exempt_channel=[str(c.id) for c in exempt_channels] if exempt_channels else None, + exempt_channels=[str(c.id) for c in exempt_channels] if exempt_channels else None, reason=reason, ) diff --git a/discord/http.py b/discord/http.py index 0c3c36572dd9..b914ef3cd146 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1059,6 +1059,7 @@ def get_reaction_users( emoji: str, limit: int, after: Optional[Snowflake] = None, + type: Optional[message.ReactionType] = None, ) -> Response[List[user.User]]: r = Route( 'GET', @@ -1073,6 +1074,10 @@ def get_reaction_users( } if after: params['after'] = after + + if type is not None: + params['type'] = type + return self.request(r, params=params) def clear_reactions(self, channel_id: Snowflake, message_id: Snowflake) -> Response[None]: diff --git a/discord/interactions.py b/discord/interactions.py index 5702e8b8d7b6..e0fb7ed8654b 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -807,6 +807,10 @@ async def send_message( then it is silently ignored. .. versionadded:: 2.1 + poll: :class:`~discord.Poll` + The poll to send with this message. + + .. versionadded:: 2.4 Raises ------- @@ -1046,38 +1050,6 @@ async def send_modal(self, modal: Modal, /) -> None: self._parent._state.store_view(modal) self._response_type = InteractionResponseType.modal - async def require_premium(self) -> None: - """|coro| - - Sends a message to the user prompting them that a premium purchase is required for this interaction. - - This type of response is only available for applications that have a premium SKU set up. - - Raises - ------- - HTTPException - Sending the response failed. - InteractionResponded - This interaction has already been responded to before. - """ - if self._response_type: - raise InteractionResponded(self._parent) - - parent = self._parent - adapter = async_context.get() - http = parent._state.http - - params = interaction_response_params(InteractionResponseType.premium_required.value) - await adapter.create_interaction_response( - parent.id, - parent.token, - session=parent._session, - proxy=http.proxy, - proxy_auth=http.proxy_auth, - params=params, - ) - self._response_type = InteractionResponseType.premium_required - async def autocomplete(self, choices: Sequence[Choice[ChoiceT]]) -> None: """|coro| diff --git a/discord/invite.py b/discord/invite.py index 1c18e41875d4..1d8dd1c8ef73 100644 --- a/discord/invite.py +++ b/discord/invite.py @@ -29,7 +29,7 @@ from .utils import parse_time, snowflake_time, _get_as_snowflake from .object import Object from .mixins import Hashable -from .enums import ChannelType, NSFWLevel, VerificationLevel, InviteTarget, try_enum +from .enums import ChannelType, NSFWLevel, VerificationLevel, InviteTarget, InviteType, try_enum from .appinfo import PartialAppInfo from .scheduled_event import ScheduledEvent @@ -296,6 +296,10 @@ class Invite(Hashable): Attributes ----------- + type: :class:`InviteType` + The type of the invite. + + .. versionadded: 2.4 max_age: Optional[:class:`int`] How long before the invite expires in seconds. A value of ``0`` indicates that it doesn't expire. @@ -374,6 +378,7 @@ class Invite(Hashable): 'expires_at', 'scheduled_event', 'scheduled_event_id', + 'type', ) BASE = 'https://discord.gg' @@ -387,6 +392,7 @@ def __init__( channel: Optional[Union[PartialInviteChannel, GuildChannel]] = None, ): self._state: ConnectionState = state + self.type: InviteType = try_enum(InviteType, data.get('type', 0)) self.max_age: Optional[int] = data.get('max_age') self.code: str = data['code'] self.guild: Optional[InviteGuildType] = self._resolve_guild(data.get('guild'), guild) @@ -496,7 +502,7 @@ def __str__(self) -> str: def __repr__(self) -> str: return ( - f'' ) diff --git a/discord/message.py b/discord/message.py index ea62b87f6784..1d1a3c96c19c 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1839,12 +1839,11 @@ def __init__( # This updates the poll so it has the counts, if the message # was previously cached. - self.poll: Optional[Poll] = state._get_poll(self.id) - if self.poll is None: - try: - self.poll = Poll._from_data(data=data['poll'], message=self, state=state) - except KeyError: - pass + self.poll: Optional[Poll] = None + try: + self.poll = Poll._from_data(data=data['poll'], message=self, state=state) + except KeyError: + self.poll = state._get_poll(self.id) try: # if the channel doesn't have a guild attribute, we handle that diff --git a/discord/permissions.py b/discord/permissions.py index 39b0b1a5e4c8..17c7b38c95dc 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -208,6 +208,22 @@ def _dm_permissions(cls) -> Self: base.send_messages_in_threads = False return base + @classmethod + def _user_installed_permissions(cls, *, in_guild: bool) -> Self: + base = cls.none() + base.send_messages = True + base.attach_files = True + base.embed_links = True + base.external_emojis = True + base.send_voice_messages = True + if in_guild: + # Logically this is False but if not set to True, + # permissions just become 0. + base.read_messages = True + base.send_tts_messages = True + base.send_messages_in_threads = True + return base + @classmethod def all_channel(cls) -> Self: """A :class:`Permissions` with all channel-specific permissions set to @@ -241,10 +257,10 @@ def all_channel(cls) -> Self: Added :attr:`use_soundboard`, :attr:`create_expressions` permissions. .. versionchanged:: 2.4 - Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, and - :attr:`use_embedded_activities` permissions. + Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, + :attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions. """ - return cls(0b0000_0000_0000_0010_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) + return cls(0b0000_0000_0000_0110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) @classmethod def general(cls) -> Self: @@ -291,9 +307,9 @@ def text(cls) -> Self: Added :attr:`send_voice_messages` permission. .. versionchanged:: 2.4 - Added :attr:`send_polls` permission. + Added :attr:`send_polls` and :attr:`use_external_apps` permissions. """ - return cls(0b0000_0000_0000_0010_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) + return cls(0b0000_0000_0000_0110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) @classmethod def voice(cls) -> Self: @@ -760,6 +776,14 @@ def create_polls(self) -> int: """ return 1 << 49 + @flag_value + def use_external_apps(self) -> int: + """:class:`bool`: Returns ``True`` if a user can use external apps. + + .. versionadded:: 2.4 + """ + return 1 << 50 + def _augment_from_permissions(cls): cls.VALID_NAMES = set(Permissions.VALID_FLAGS) @@ -882,6 +906,7 @@ class PermissionOverwrite: create_events: Optional[bool] send_polls: Optional[bool] create_polls: Optional[bool] + use_external_apps: Optional[bool] def __init__(self, **kwargs: Optional[bool]): self._values: Dict[str, Optional[bool]] = {} diff --git a/discord/poll.py b/discord/poll.py index 0d8e90366679..c523f16098f7 100644 --- a/discord/poll.py +++ b/discord/poll.py @@ -202,7 +202,7 @@ def vote_count(self) -> int: @property def poll(self) -> Poll: - """:class:`Poll`: Returns the parent poll of this answer""" + """:class:`Poll`: Returns the parent poll of this answer.""" return self._poll def _to_dict(self) -> PollAnswerPayload: @@ -310,7 +310,7 @@ class Poll: The duration of the poll. Duration must be in hours. multiple: :class:`bool` Whether users are allowed to select more than one answer. - Defaultsto ``False``. + Defaults to ``False``. layout_type: :class:`PollLayoutType` The layout type of the poll. Defaults to :attr:`PollLayoutType.default`. """ @@ -431,7 +431,7 @@ def question(self) -> str: @property def answers(self) -> List[PollAnswer]: - """List[:class:`PollAnswer`]: Returns a read-only copy of the answers""" + """List[:class:`PollAnswer`]: Returns a read-only copy of the answers.""" return list(self._answers.values()) @property @@ -446,7 +446,12 @@ def expires_at(self) -> Optional[datetime.datetime]: @property def created_at(self) -> Optional[datetime.datetime]: - """:class:`datetime.datetime`: Returns the poll's creation time, or ``None`` if user-created.""" + """Optional[:class:`datetime.datetime`]: Returns the poll's creation time. + + .. note:: + + This will **always** be ``None`` for stateless polls. + """ if not self._message: return diff --git a/discord/raw_models.py b/discord/raw_models.py index 571be38f1f5a..8d3ad328fb4c 100644 --- a/discord/raw_models.py +++ b/discord/raw_models.py @@ -27,7 +27,7 @@ import datetime from typing import TYPE_CHECKING, Literal, Optional, Set, List, Tuple, Union -from .enums import ChannelType, try_enum +from .enums import ChannelType, try_enum, ReactionType from .utils import _get_as_snowflake from .app_commands import AppCommandPermissions from .colour import Colour @@ -221,6 +221,10 @@ class RawReactionActionEvent(_RawReprMixin): and if ``event_type`` is ``REACTION_ADD``. .. versionadded:: 2.0 + type: :class:`ReactionType` + The type of the reaction. + + .. versionadded:: 2.4 """ __slots__ = ( @@ -234,6 +238,7 @@ class RawReactionActionEvent(_RawReprMixin): 'message_author_id', 'burst', 'burst_colours', + 'type', ) def __init__(self, data: ReactionActionEvent, emoji: PartialEmoji, event_type: ReactionActionType) -> None: @@ -246,6 +251,7 @@ def __init__(self, data: ReactionActionEvent, emoji: PartialEmoji, event_type: R self.message_author_id: Optional[int] = _get_as_snowflake(data, 'message_author_id') self.burst: bool = data.get('burst', False) self.burst_colours: List[Colour] = [Colour.from_str(c) for c in data.get('burst_colours', [])] + self.type: ReactionType = try_enum(ReactionType, data['type']) try: self.guild_id: Optional[int] = int(data['guild_id']) diff --git a/discord/reaction.py b/discord/reaction.py index cd0fbef10268..9fd933b0a57a 100644 --- a/discord/reaction.py +++ b/discord/reaction.py @@ -27,6 +27,7 @@ from .user import User from .object import Object +from .enums import ReactionType # fmt: off __all__ = ( @@ -185,7 +186,7 @@ async def clear(self) -> None: await self.message.clear_reaction(self.emoji) async def users( - self, *, limit: Optional[int] = None, after: Optional[Snowflake] = None + self, *, limit: Optional[int] = None, after: Optional[Snowflake] = None, type: Optional[ReactionType] = None ) -> AsyncIterator[Union[Member, User]]: """Returns an :term:`asynchronous iterator` representing the users that have reacted to the message. @@ -220,6 +221,11 @@ async def users( reacted to the message. after: Optional[:class:`abc.Snowflake`] For pagination, reactions are sorted by member. + type: Optional[:class:`ReactionType`] + The type of reaction to return users from. + If not provided, Discord only returns users of reactions with type ``normal``. + + .. versionadded:: 2.4 Raises -------- @@ -251,7 +257,14 @@ async def users( state = message._state after_id = after.id if after else None - data = await state.http.get_reaction_users(message.channel.id, message.id, emoji, retrieve, after=after_id) + data = await state.http.get_reaction_users( + message.channel.id, + message.id, + emoji, + retrieve, + after=after_id, + type=type.value if type is not None else None, + ) if data: limit -= len(data) diff --git a/discord/state.py b/discord/state.py index b0d05aa3acdd..3761b4cf25c8 100644 --- a/discord/state.py +++ b/discord/state.py @@ -1649,13 +1649,8 @@ def parse_message_poll_vote_add(self, data: gw.PollVoteActionEvent) -> None: if message and user: poll = self._update_poll_counts(message, raw.answer_id, True, raw.user_id == self.self_id) - if not poll: - _log.warning( - 'POLL_VOTE_ADD referencing message with ID: %s does not have a poll. Discarding.', raw.message_id - ) - return - - self.dispatch('poll_vote_add', user, poll.get_answer(raw.answer_id)) + if poll: + self.dispatch('poll_vote_add', user, poll.get_answer(raw.answer_id)) def parse_message_poll_vote_remove(self, data: gw.PollVoteActionEvent) -> None: raw = RawPollVoteActionEvent(data) @@ -1672,13 +1667,8 @@ def parse_message_poll_vote_remove(self, data: gw.PollVoteActionEvent) -> None: if message and user: poll = self._update_poll_counts(message, raw.answer_id, False, raw.user_id == self.self_id) - if not poll: - _log.warning( - 'POLL_VOTE_REMOVE referencing message with ID: %s does not have a poll. Discarding.', raw.message_id - ) - return - - self.dispatch('poll_vote_remove', user, poll.get_answer(raw.answer_id)) + if poll: + self.dispatch('poll_vote_remove', user, poll.get_answer(raw.answer_id)) def _get_reaction_user(self, channel: MessageableChannel, user_id: int) -> Optional[Union[User, Member]]: if isinstance(channel, (TextChannel, Thread, VoiceChannel)): diff --git a/discord/threads.py b/discord/threads.py index bbf476dc80ec..27288693457b 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -121,6 +121,10 @@ class Thread(Messageable, Hashable): This is always ``True`` for public threads. archiver_id: Optional[:class:`int`] The user's ID that archived this thread. + + .. note:: + Due to an API change, the ``archiver_id`` will always be ``None`` and can only be obtained via the audit log. + auto_archive_duration: :class:`int` The duration in minutes until the thread is automatically hidden from the channel list. Usually a value of 60, 1440, 4320 and 10080. diff --git a/discord/types/components.py b/discord/types/components.py index 218f5cef07bf..3b1295c1393c 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -31,7 +31,7 @@ from .channel import ChannelType ComponentType = Literal[1, 2, 3, 4] -ButtonStyle = Literal[1, 2, 3, 4, 5] +ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -49,6 +49,7 @@ class ButtonComponent(TypedDict): disabled: NotRequired[bool] emoji: NotRequired[PartialEmoji] label: NotRequired[str] + sku_id: NotRequired[str] class SelectOption(TypedDict): diff --git a/discord/types/gateway.py b/discord/types/gateway.py index b79bd9ca9fb3..ff43a5f25e70 100644 --- a/discord/types/gateway.py +++ b/discord/types/gateway.py @@ -37,7 +37,7 @@ from .emoji import Emoji, PartialEmoji from .member import MemberWithUser from .snowflake import Snowflake -from .message import Message +from .message import Message, ReactionType from .sticker import GuildSticker from .appinfo import GatewayAppInfo, PartialAppInfo from .guild import Guild, UnavailableGuild @@ -104,6 +104,7 @@ class MessageReactionAddEvent(TypedDict): message_author_id: NotRequired[Snowflake] burst: bool burst_colors: NotRequired[List[str]] + type: ReactionType class MessageReactionRemoveEvent(TypedDict): @@ -113,6 +114,7 @@ class MessageReactionRemoveEvent(TypedDict): emoji: PartialEmoji guild_id: NotRequired[Snowflake] burst: bool + type: ReactionType class MessageReactionRemoveAllEvent(TypedDict): diff --git a/discord/types/invite.py b/discord/types/invite.py index b53ca374c6b2..f5f00078e950 100644 --- a/discord/types/invite.py +++ b/discord/types/invite.py @@ -35,6 +35,7 @@ from .appinfo import PartialAppInfo InviteTargetType = Literal[1, 2] +InviteType = Literal[0, 1, 2] class _InviteMetadata(TypedDict, total=False): @@ -63,6 +64,7 @@ class Invite(IncompleteInvite, total=False): target_type: InviteTargetType target_application: PartialAppInfo guild_scheduled_event: GuildScheduledEvent + type: InviteType class InviteWithCounts(Invite, _GuildPreviewUnique): diff --git a/discord/types/message.py b/discord/types/message.py index 16912d628715..bdb3f10ef9e6 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -57,6 +57,9 @@ class ReactionCountDetails(TypedDict): normal: int +ReactionType = Literal[0, 1] + + class Reaction(TypedDict): count: int me: bool diff --git a/discord/ui/button.py b/discord/ui/button.py index 28238a6f06aa..43bd3a8b0f9d 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -77,6 +77,11 @@ class Button(Item[V]): like to control the relative positioning of the row then passing an index is advised. For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic ordering. The row number must be between 0 and 4 (i.e. zero indexed). + sku_id: Optional[:class:`int`] + The SKU ID this button sends you to. Can't be combined with ``url``, ``label``, ``emoji`` + nor ``custom_id``. + + .. versionadded:: 2.4 """ __item_repr_attributes__: Tuple[str, ...] = ( @@ -86,6 +91,7 @@ class Button(Item[V]): 'label', 'emoji', 'row', + 'sku_id', ) def __init__( @@ -98,13 +104,18 @@ def __init__( url: Optional[str] = None, emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, row: Optional[int] = None, + sku_id: Optional[int] = None, ): super().__init__() - if custom_id is not None and url is not None: - raise TypeError('cannot mix both url and custom_id with Button') + if custom_id is not None and (url is not None or sku_id is not None): + raise TypeError('cannot mix both url or sku_id and custom_id with Button') + + if url is not None and sku_id is not None: + raise TypeError('cannot mix both url and sku_id') + requires_custom_id = url is None and sku_id is None self._provided_custom_id = custom_id is not None - if url is None and custom_id is None: + if requires_custom_id and custom_id is None: custom_id = os.urandom(16).hex() if custom_id is not None and not isinstance(custom_id, str): @@ -113,6 +124,9 @@ def __init__( if url is not None: style = ButtonStyle.link + if sku_id is not None: + style = ButtonStyle.premium + if emoji is not None: if isinstance(emoji, str): emoji = PartialEmoji.from_str(emoji) @@ -128,6 +142,7 @@ def __init__( label=label, style=style, emoji=emoji, + sku_id=sku_id, ) self.row = row @@ -202,6 +217,20 @@ def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]) -> None: else: self._underlying.emoji = None + @property + def sku_id(self) -> Optional[int]: + """Optional[:class:`int`]: The SKU ID this button sends you to. + + .. versionadded:: 2.4 + """ + return self._underlying.sku_id + + @sku_id.setter + def sku_id(self, value: Optional[int]) -> None: + if value is not None: + self.style = ButtonStyle.premium + self._underlying.sku_id = value + @classmethod def from_component(cls, button: ButtonComponent) -> Self: return cls( @@ -212,6 +241,7 @@ def from_component(cls, button: ButtonComponent) -> Self: url=button.url, emoji=button.emoji, row=None, + sku_id=button.sku_id, ) @property @@ -250,11 +280,11 @@ def button( .. note:: - Buttons with a URL cannot be created with this function. + Buttons with a URL or an SKU cannot be created with this function. Consider creating a :class:`Button` manually instead. - This is because buttons with a URL do not have a callback + This is because these buttons cannot have a callback associated with them since Discord does not do any processing - with it. + with them. Parameters ------------ @@ -293,6 +323,7 @@ def decorator(func: ItemCallbackType[V, Button[V]]) -> ItemCallbackType[V, Butto 'label': label, 'emoji': emoji, 'row': row, + 'sku_id': None, } return func diff --git a/discord/voice_state.py b/discord/voice_state.py index f4a5f76b37ba..f10a307d6715 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -1,690 +1,688 @@ -""" -The MIT License (MIT) - -Copyright (c) 2015-present Rapptz - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the "Software"), -to deal in the Software without restriction, including without limitation -the rights to use, copy, modify, merge, publish, distribute, sublicense, -and/or sell copies of the Software, and to permit persons to whom the -Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER -DEALINGS IN THE SOFTWARE. - - -Some documentation to refer to: - -- Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID. -- The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. -- We pull the session_id from VOICE_STATE_UPDATE. -- We pull the token, endpoint and server_id from VOICE_SERVER_UPDATE. -- Then we initiate the voice web socket (vWS) pointing to the endpoint. -- We send opcode 0 with the user_id, server_id, session_id and token using the vWS. -- The vWS sends back opcode 2 with an ssrc, port, modes(array) and heartbeat_interval. -- We send a UDP discovery packet to endpoint:port and receive our IP and our port in LE. -- Then we send our IP and port via vWS with opcode 1. -- When that's all done, we receive opcode 4 from the vWS. -- Finally we can transmit data to endpoint:port. -""" - -from __future__ import annotations - -import select -import socket -import asyncio -import logging -import threading - -try: - from asyncio import timeout as atimeout # type: ignore -except ImportError: - from async_timeout import timeout as atimeout # type: ignore - -from typing import TYPE_CHECKING, Optional, Dict, List, Callable, Coroutine, Any, Tuple - -from .enums import Enum -from .utils import MISSING, sane_wait_for -from .errors import ConnectionClosed -from .backoff import ExponentialBackoff -from .gateway import DiscordVoiceWebSocket - -if TYPE_CHECKING: - from . import abc - from .guild import Guild - from .user import ClientUser - from .member import VoiceState - from .voice_client import VoiceClient - - from .types.voice import ( - GuildVoiceState as GuildVoiceStatePayload, - VoiceServerUpdate as VoiceServerUpdatePayload, - SupportedModes, - ) - - WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]] - SocketReaderCallback = Callable[[bytes], Any] - - -__all__ = ('VoiceConnectionState',) - -_log = logging.getLogger(__name__) - - -class SocketReader(threading.Thread): - def __init__(self, state: VoiceConnectionState, *, start_paused: bool = True) -> None: - super().__init__(daemon=True, name=f'voice-socket-reader:{id(self):#x}') - self.state: VoiceConnectionState = state - self.start_paused = start_paused - self._callbacks: List[SocketReaderCallback] = [] - self._running = threading.Event() - self._end = threading.Event() - # If we have paused reading due to having no callbacks - self._idle_paused: bool = True - - def register(self, callback: SocketReaderCallback) -> None: - self._callbacks.append(callback) - if self._idle_paused: - self._idle_paused = False - self._running.set() - - def unregister(self, callback: SocketReaderCallback) -> None: - try: - self._callbacks.remove(callback) - except ValueError: - pass - else: - if not self._callbacks and self._running.is_set(): - # If running is not set, we are either explicitly paused and - # should be explicitly resumed, or we are already idle paused - self._idle_paused = True - self._running.clear() - - def pause(self) -> None: - self._idle_paused = False - self._running.clear() - - def resume(self, *, force: bool = False) -> None: - if self._running.is_set(): - return - # Don't resume if there are no callbacks registered - if not force and not self._callbacks: - # We tried to resume but there was nothing to do, so resume when ready - self._idle_paused = True - return - self._idle_paused = False - self._running.set() - - def stop(self) -> None: - self._end.set() - self._running.set() - - def run(self) -> None: - self._end.clear() - self._running.set() - if self.start_paused: - self.pause() - try: - self._do_run() - except Exception: - _log.exception('Error in %s', self) - finally: - self.stop() - self._running.clear() - self._callbacks.clear() - - def _do_run(self) -> None: - while not self._end.is_set(): - if not self._running.is_set(): - self._running.wait() - continue - - # Since this socket is a non blocking socket, select has to be used to wait on it for reading. - try: - readable, _, _ = select.select([self.state.socket], [], [], 30) - except (ValueError, TypeError, OSError) as e: - _log.debug( - "Select error handling socket in reader, this should be safe to ignore: %s: %s", e.__class__.__name__, e - ) - # The socket is either closed or doesn't exist at the moment - continue - - if not readable: - continue - - try: - data = self.state.socket.recv(2048) - except OSError: - _log.debug('Error reading from socket in %s, this should be safe to ignore', self, exc_info=True) - else: - for cb in self._callbacks: - try: - cb(data) - except Exception: - _log.exception('Error calling %s in %s', cb, self) - - -class ConnectionFlowState(Enum): - """Enum representing voice connection flow state.""" - - # fmt: off - disconnected = 0 - set_guild_voice_state = 1 - got_voice_state_update = 2 - got_voice_server_update = 3 - got_both_voice_updates = 4 - websocket_connected = 5 - got_websocket_ready = 6 - got_ip_discovery = 7 - connected = 8 - # fmt: on - - -class VoiceConnectionState: - """Represents the internal state of a voice connection.""" - - def __init__(self, voice_client: VoiceClient, *, hook: Optional[WebsocketHook] = None) -> None: - self.voice_client = voice_client - self.hook = hook - - self.timeout: float = 30.0 - self.reconnect: bool = True - self.self_deaf: bool = False - self.self_mute: bool = False - self.token: Optional[str] = None - self.session_id: Optional[str] = None - self.endpoint: Optional[str] = None - self.endpoint_ip: Optional[str] = None - self.server_id: Optional[int] = None - self.ip: Optional[str] = None - self.port: Optional[int] = None - self.voice_port: Optional[int] = None - self.secret_key: List[int] = MISSING - self.ssrc: int = MISSING - self.mode: SupportedModes = MISSING - self.socket: socket.socket = MISSING - self.ws: DiscordVoiceWebSocket = MISSING - - self._state: ConnectionFlowState = ConnectionFlowState.disconnected - self._expecting_disconnect: bool = False - self._connected = threading.Event() - self._state_event = asyncio.Event() - self._disconnected = asyncio.Event() - self._runner: Optional[asyncio.Task] = None - self._connector: Optional[asyncio.Task] = None - self._socket_reader = SocketReader(self) - self._socket_reader.start() - - @property - def state(self) -> ConnectionFlowState: - return self._state - - @state.setter - def state(self, state: ConnectionFlowState) -> None: - if state is not self._state: - _log.debug('Connection state changed to %s', state.name) - self._state = state - self._state_event.set() - self._state_event.clear() - - if state is ConnectionFlowState.connected: - self._connected.set() - else: - self._connected.clear() - - @property - def guild(self) -> Guild: - return self.voice_client.guild - - @property - def user(self) -> ClientUser: - return self.voice_client.user - - @property - def supported_modes(self) -> Tuple[SupportedModes, ...]: - return self.voice_client.supported_modes - - @property - def self_voice_state(self) -> Optional[VoiceState]: - return self.guild.me.voice - - async def voice_state_update(self, data: GuildVoiceStatePayload) -> None: - channel_id = data['channel_id'] - - if channel_id is None: - self._disconnected.set() - - # If we know we're going to get a voice_state_update where we have no channel due to - # being in the reconnect or disconnect flow, we ignore it. Otherwise, it probably wasn't from us. - if self._expecting_disconnect: - self._expecting_disconnect = False - else: - _log.debug('We were externally disconnected from voice.') - await self.disconnect() - - return - - channel_id = int(channel_id) - self.session_id = data['session_id'] - - # we got the event while connecting - if self.state in (ConnectionFlowState.set_guild_voice_state, ConnectionFlowState.got_voice_server_update): - if self.state is ConnectionFlowState.set_guild_voice_state: - self.state = ConnectionFlowState.got_voice_state_update - - # we moved ourselves - if channel_id != self.voice_client.channel.id: - self._update_voice_channel(channel_id) - - else: - self.state = ConnectionFlowState.got_both_voice_updates - return - - if self.state is ConnectionFlowState.connected: - self._update_voice_channel(channel_id) - - elif self.state is not ConnectionFlowState.disconnected: - if channel_id != self.voice_client.channel.id: - # For some unfortunate reason we were moved during the connection flow - _log.info('Handling channel move while connecting...') - - self._update_voice_channel(channel_id) - await self.soft_disconnect(with_state=ConnectionFlowState.got_voice_state_update) - await self.connect( - reconnect=self.reconnect, - timeout=self.timeout, - self_deaf=(self.self_voice_state or self).self_deaf, - self_mute=(self.self_voice_state or self).self_mute, - resume=False, - wait=False, - ) - else: - _log.debug('Ignoring unexpected voice_state_update event') - - async def voice_server_update(self, data: VoiceServerUpdatePayload) -> None: - previous_token = self.token - previous_server_id = self.server_id - previous_endpoint = self.endpoint - - self.token = data['token'] - self.server_id = int(data['guild_id']) - endpoint = data.get('endpoint') - - if self.token is None or endpoint is None: - _log.warning( - 'Awaiting endpoint... This requires waiting. ' - 'If timeout occurred considering raising the timeout and reconnecting.' - ) - return - - self.endpoint, _, _ = endpoint.rpartition(':') - if self.endpoint.startswith('wss://'): - # Just in case, strip it off since we're going to add it later - self.endpoint = self.endpoint[6:] - - # we got the event while connecting - if self.state in (ConnectionFlowState.set_guild_voice_state, ConnectionFlowState.got_voice_state_update): - # This gets set after READY is received - self.endpoint_ip = MISSING - self._create_socket() - - if self.state is ConnectionFlowState.set_guild_voice_state: - self.state = ConnectionFlowState.got_voice_server_update - else: - self.state = ConnectionFlowState.got_both_voice_updates - - elif self.state is ConnectionFlowState.connected: - _log.debug('Voice server update, closing old voice websocket') - await self.ws.close(4014) - self.state = ConnectionFlowState.got_voice_server_update - - elif self.state is not ConnectionFlowState.disconnected: - # eventual consistency - if previous_token == self.token and previous_server_id == self.server_id and previous_token == self.token: - return - - _log.debug('Unexpected server update event, attempting to handle') - - await self.soft_disconnect(with_state=ConnectionFlowState.got_voice_server_update) - await self.connect( - reconnect=self.reconnect, - timeout=self.timeout, - self_deaf=(self.self_voice_state or self).self_deaf, - self_mute=(self.self_voice_state or self).self_mute, - resume=False, - wait=False, - ) - self._create_socket() - - async def connect( - self, *, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool, wait: bool = True - ) -> None: - if self._connector: - self._connector.cancel() - self._connector = None - - if self._runner: - self._runner.cancel() - self._runner = None - - self.timeout = timeout - self.reconnect = reconnect - self._connector = self.voice_client.loop.create_task( - self._wrap_connect(reconnect, timeout, self_deaf, self_mute, resume), name='Voice connector' - ) - if wait: - await self._connector - - async def _wrap_connect(self, *args: Any) -> None: - try: - await self._connect(*args) - except asyncio.CancelledError: - _log.debug('Cancelling voice connection') - await self.soft_disconnect() - raise - except asyncio.TimeoutError: - _log.info('Timed out connecting to voice') - await self.disconnect() - raise - except Exception: - _log.exception('Error connecting to voice... disconnecting') - await self.disconnect() - raise - - async def _connect(self, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool) -> None: - _log.info('Connecting to voice...') - - async with atimeout(timeout): - for i in range(5): - _log.info('Starting voice handshake... (connection attempt %d)', i + 1) - - await self._voice_connect(self_deaf=self_deaf, self_mute=self_mute) - # Setting this unnecessarily will break reconnecting - if self.state is ConnectionFlowState.disconnected: - self.state = ConnectionFlowState.set_guild_voice_state - - await self._wait_for_state(ConnectionFlowState.got_both_voice_updates) - - _log.info('Voice handshake complete. Endpoint found: %s', self.endpoint) - - try: - self.ws = await self._connect_websocket(resume) - await self._handshake_websocket() - break - except ConnectionClosed: - if reconnect: - wait = 1 + i * 2.0 - _log.exception('Failed to connect to voice... Retrying in %ss...', wait) - await self.disconnect(cleanup=False) - await asyncio.sleep(wait) - continue - else: - await self.disconnect() - raise - - _log.info('Voice connection complete.') - - if not self._runner: - self._runner = self.voice_client.loop.create_task(self._poll_voice_ws(reconnect), name='Voice websocket poller') - - async def disconnect(self, *, force: bool = True, cleanup: bool = True, wait: bool = False) -> None: - if not force and not self.is_connected(): - return - - try: - await self._voice_disconnect() - if self.ws: - await self.ws.close() - except Exception: - _log.debug('Ignoring exception disconnecting from voice', exc_info=True) - finally: - self.state = ConnectionFlowState.disconnected - self._socket_reader.pause() - - # Stop threads before we unlock waiters so they end properly - if cleanup: - self._socket_reader.stop() - self.voice_client.stop() - - # Flip the connected event to unlock any waiters - self._connected.set() - self._connected.clear() - - if self.socket: - self.socket.close() - - self.ip = MISSING - self.port = MISSING - - # Skip this part if disconnect was called from the poll loop task - if wait and not self._inside_runner(): - # Wait for the voice_state_update event confirming the bot left the voice channel. - # This prevents a race condition caused by disconnecting and immediately connecting again. - # The new VoiceConnectionState object receives the voice_state_update event containing channel=None while still - # connecting leaving it in a bad state. Since there's no nice way to transfer state to the new one, we have to do this. - try: - async with atimeout(self.timeout): - await self._disconnected.wait() - except TimeoutError: - _log.debug('Timed out waiting for voice disconnection confirmation') - except asyncio.CancelledError: - pass - - if cleanup: - self.voice_client.cleanup() - - async def soft_disconnect(self, *, with_state: ConnectionFlowState = ConnectionFlowState.got_both_voice_updates) -> None: - _log.debug('Soft disconnecting from voice') - # Stop the websocket reader because closing the websocket will trigger an unwanted reconnect - if self._runner: - self._runner.cancel() - self._runner = None - - try: - if self.ws: - await self.ws.close() - except Exception: - _log.debug('Ignoring exception soft disconnecting from voice', exc_info=True) - finally: - self.state = with_state - self._socket_reader.pause() - - if self.socket: - self.socket.close() - - self.ip = MISSING - self.port = MISSING - - async def move_to(self, channel: Optional[abc.Snowflake], timeout: Optional[float]) -> None: - if channel is None: - # This function should only be called externally so its ok to wait for the disconnect. - await self.disconnect(wait=True) - return - - if self.voice_client.channel and channel.id == self.voice_client.channel.id: - return - - previous_state = self.state - - # this is only an outgoing ws request - # if it fails, nothing happens and nothing changes (besides self.state) - await self._move_to(channel) - - last_state = self.state - try: - await self.wait_async(timeout) - except asyncio.TimeoutError: - _log.warning('Timed out trying to move to channel %s in guild %s', channel.id, self.guild.id) - if self.state is last_state: - _log.debug('Reverting to previous state %s', previous_state.name) - self.state = previous_state - - def wait(self, timeout: Optional[float] = None) -> bool: - return self._connected.wait(timeout) - - async def wait_async(self, timeout: Optional[float] = None) -> None: - await self._wait_for_state(ConnectionFlowState.connected, timeout=timeout) - - def is_connected(self) -> bool: - return self.state is ConnectionFlowState.connected - - def send_packet(self, packet: bytes) -> None: - self.socket.sendall(packet) - - def add_socket_listener(self, callback: SocketReaderCallback) -> None: - _log.debug('Registering socket listener callback %s', callback) - self._socket_reader.register(callback) - - def remove_socket_listener(self, callback: SocketReaderCallback) -> None: - _log.debug('Unregistering socket listener callback %s', callback) - self._socket_reader.unregister(callback) - - def _inside_runner(self) -> bool: - return self._runner is not None and asyncio.current_task() == self._runner - - async def _wait_for_state( - self, state: ConnectionFlowState, *other_states: ConnectionFlowState, timeout: Optional[float] = None - ) -> None: - states = (state, *other_states) - while True: - if self.state in states: - return - await sane_wait_for([self._state_event.wait()], timeout=timeout) - - async def _voice_connect(self, *, self_deaf: bool = False, self_mute: bool = False) -> None: - channel = self.voice_client.channel - await channel.guild.change_voice_state(channel=channel, self_deaf=self_deaf, self_mute=self_mute) - - async def _voice_disconnect(self) -> None: - _log.info( - 'The voice handshake is being terminated for Channel ID %s (Guild ID %s)', - self.voice_client.channel.id, - self.voice_client.guild.id, - ) - self.state = ConnectionFlowState.disconnected - await self.voice_client.channel.guild.change_voice_state(channel=None) - self._expecting_disconnect = True - self._disconnected.clear() - - async def _connect_websocket(self, resume: bool) -> DiscordVoiceWebSocket: - ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook) - self.state = ConnectionFlowState.websocket_connected - return ws - - async def _handshake_websocket(self) -> None: - while not self.ip: - await self.ws.poll_event() - self.state = ConnectionFlowState.got_ip_discovery - while self.ws.secret_key is None: - await self.ws.poll_event() - self.state = ConnectionFlowState.connected - - def _create_socket(self) -> None: - self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - self.socket.setblocking(False) - self._socket_reader.resume() - - async def _poll_voice_ws(self, reconnect: bool) -> None: - backoff = ExponentialBackoff() - while True: - try: - await self.ws.poll_event() - except asyncio.CancelledError: - return - except (ConnectionClosed, asyncio.TimeoutError) as exc: - if isinstance(exc, ConnectionClosed): - # The following close codes are undocumented so I will document them here. - # 1000 - normal closure (obviously) - # 4014 - we were externally disconnected (voice channel deleted, we were moved, etc) - # 4015 - voice server has crashed - if exc.code in (1000, 4015): - # Don't call disconnect a second time if the websocket closed from a disconnect call - if not self._expecting_disconnect: - _log.info('Disconnecting from voice normally, close code %d.', exc.code) - await self.disconnect() - break - - if exc.code == 4014: - # We were disconnected by discord - # This condition is a race between the main ws event and the voice ws closing - if self._disconnected.is_set(): - _log.info('Disconnected from voice by discord, close code %d.', exc.code) - await self.disconnect() - break - - # We may have been moved to a different channel - _log.info('Disconnected from voice by force... potentially reconnecting.') - successful = await self._potential_reconnect() - if not successful: - _log.info('Reconnect was unsuccessful, disconnecting from voice normally...') - # Don't bother to disconnect if already disconnected - if self.state is not ConnectionFlowState.disconnected: - await self.disconnect() - break - else: - continue - - _log.debug('Not handling close code %s (%s)', exc.code, exc.reason or 'no reason') - - if not reconnect: - await self.disconnect() - raise - - retry = backoff.delay() - _log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry) - await asyncio.sleep(retry) - await self.disconnect(cleanup=False) - - try: - await self._connect( - reconnect=reconnect, - timeout=self.timeout, - self_deaf=(self.self_voice_state or self).self_deaf, - self_mute=(self.self_voice_state or self).self_mute, - resume=False, - ) - except asyncio.TimeoutError: - # at this point we've retried 5 times... let's continue the loop. - _log.warning('Could not connect to voice... Retrying...') - continue - - async def _potential_reconnect(self) -> bool: - try: - await self._wait_for_state( - ConnectionFlowState.got_voice_server_update, - ConnectionFlowState.got_both_voice_updates, - ConnectionFlowState.disconnected, - timeout=self.timeout, - ) - except asyncio.TimeoutError: - return False - else: - if self.state is ConnectionFlowState.disconnected: - return False - - previous_ws = self.ws - try: - self.ws = await self._connect_websocket(False) - await self._handshake_websocket() - except (ConnectionClosed, asyncio.TimeoutError): - return False - else: - return True - finally: - await previous_ws.close() - - async def _move_to(self, channel: abc.Snowflake) -> None: - await self.voice_client.channel.guild.change_voice_state(channel=channel) - self.state = ConnectionFlowState.set_guild_voice_state - - def _update_voice_channel(self, channel_id: Optional[int]) -> None: - self.voice_client.channel = channel_id and self.guild.get_channel(channel_id) # type: ignore +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + + +Some documentation to refer to: + +- Our main web socket (mWS) sends opcode 4 with a guild ID and channel ID. +- The mWS receives VOICE_STATE_UPDATE and VOICE_SERVER_UPDATE. +- We pull the session_id from VOICE_STATE_UPDATE. +- We pull the token, endpoint and server_id from VOICE_SERVER_UPDATE. +- Then we initiate the voice web socket (vWS) pointing to the endpoint. +- We send opcode 0 with the user_id, server_id, session_id and token using the vWS. +- The vWS sends back opcode 2 with an ssrc, port, modes(array) and heartbeat_interval. +- We send a UDP discovery packet to endpoint:port and receive our IP and our port in LE. +- Then we send our IP and port via vWS with opcode 1. +- When that's all done, we receive opcode 4 from the vWS. +- Finally we can transmit data to endpoint:port. +""" + +from __future__ import annotations + +import select +import socket +import asyncio +import logging +import threading + +from typing import TYPE_CHECKING, Optional, Dict, List, Callable, Coroutine, Any, Tuple + +from .enums import Enum +from .utils import MISSING, sane_wait_for +from .errors import ConnectionClosed +from .backoff import ExponentialBackoff +from .gateway import DiscordVoiceWebSocket + +if TYPE_CHECKING: + from . import abc + from .guild import Guild + from .user import ClientUser + from .member import VoiceState + from .voice_client import VoiceClient + + from .types.voice import ( + GuildVoiceState as GuildVoiceStatePayload, + VoiceServerUpdate as VoiceServerUpdatePayload, + SupportedModes, + ) + + WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]] + SocketReaderCallback = Callable[[bytes], Any] + + +__all__ = ('VoiceConnectionState',) + +_log = logging.getLogger(__name__) + + +class SocketReader(threading.Thread): + def __init__(self, state: VoiceConnectionState, *, start_paused: bool = True) -> None: + super().__init__(daemon=True, name=f'voice-socket-reader:{id(self):#x}') + self.state: VoiceConnectionState = state + self.start_paused = start_paused + self._callbacks: List[SocketReaderCallback] = [] + self._running = threading.Event() + self._end = threading.Event() + # If we have paused reading due to having no callbacks + self._idle_paused: bool = True + + def register(self, callback: SocketReaderCallback) -> None: + self._callbacks.append(callback) + if self._idle_paused: + self._idle_paused = False + self._running.set() + + def unregister(self, callback: SocketReaderCallback) -> None: + try: + self._callbacks.remove(callback) + except ValueError: + pass + else: + if not self._callbacks and self._running.is_set(): + # If running is not set, we are either explicitly paused and + # should be explicitly resumed, or we are already idle paused + self._idle_paused = True + self._running.clear() + + def pause(self) -> None: + self._idle_paused = False + self._running.clear() + + def resume(self, *, force: bool = False) -> None: + if self._running.is_set(): + return + # Don't resume if there are no callbacks registered + if not force and not self._callbacks: + # We tried to resume but there was nothing to do, so resume when ready + self._idle_paused = True + return + self._idle_paused = False + self._running.set() + + def stop(self) -> None: + self._end.set() + self._running.set() + + def run(self) -> None: + self._end.clear() + self._running.set() + if self.start_paused: + self.pause() + try: + self._do_run() + except Exception: + _log.exception('Error in %s', self) + finally: + self.stop() + self._running.clear() + self._callbacks.clear() + + def _do_run(self) -> None: + while not self._end.is_set(): + if not self._running.is_set(): + self._running.wait() + continue + + # Since this socket is a non blocking socket, select has to be used to wait on it for reading. + try: + readable, _, _ = select.select([self.state.socket], [], [], 30) + except (ValueError, TypeError, OSError) as e: + _log.debug( + "Select error handling socket in reader, this should be safe to ignore: %s: %s", e.__class__.__name__, e + ) + # The socket is either closed or doesn't exist at the moment + continue + + if not readable: + continue + + try: + data = self.state.socket.recv(2048) + except OSError: + _log.debug('Error reading from socket in %s, this should be safe to ignore', self, exc_info=True) + else: + for cb in self._callbacks: + try: + cb(data) + except Exception: + _log.exception('Error calling %s in %s', cb, self) + + +class ConnectionFlowState(Enum): + """Enum representing voice connection flow state.""" + + # fmt: off + disconnected = 0 + set_guild_voice_state = 1 + got_voice_state_update = 2 + got_voice_server_update = 3 + got_both_voice_updates = 4 + websocket_connected = 5 + got_websocket_ready = 6 + got_ip_discovery = 7 + connected = 8 + # fmt: on + + +class VoiceConnectionState: + """Represents the internal state of a voice connection.""" + + def __init__(self, voice_client: VoiceClient, *, hook: Optional[WebsocketHook] = None) -> None: + self.voice_client = voice_client + self.hook = hook + + self.timeout: float = 30.0 + self.reconnect: bool = True + self.self_deaf: bool = False + self.self_mute: bool = False + self.token: Optional[str] = None + self.session_id: Optional[str] = None + self.endpoint: Optional[str] = None + self.endpoint_ip: Optional[str] = None + self.server_id: Optional[int] = None + self.ip: Optional[str] = None + self.port: Optional[int] = None + self.voice_port: Optional[int] = None + self.secret_key: List[int] = MISSING + self.ssrc: int = MISSING + self.mode: SupportedModes = MISSING + self.socket: socket.socket = MISSING + self.ws: DiscordVoiceWebSocket = MISSING + + self._state: ConnectionFlowState = ConnectionFlowState.disconnected + self._expecting_disconnect: bool = False + self._connected = threading.Event() + self._state_event = asyncio.Event() + self._disconnected = asyncio.Event() + self._runner: Optional[asyncio.Task] = None + self._connector: Optional[asyncio.Task] = None + self._socket_reader = SocketReader(self) + self._socket_reader.start() + + @property + def state(self) -> ConnectionFlowState: + return self._state + + @state.setter + def state(self, state: ConnectionFlowState) -> None: + if state is not self._state: + _log.debug('Connection state changed to %s', state.name) + self._state = state + self._state_event.set() + self._state_event.clear() + + if state is ConnectionFlowState.connected: + self._connected.set() + else: + self._connected.clear() + + @property + def guild(self) -> Guild: + return self.voice_client.guild + + @property + def user(self) -> ClientUser: + return self.voice_client.user + + @property + def supported_modes(self) -> Tuple[SupportedModes, ...]: + return self.voice_client.supported_modes + + @property + def self_voice_state(self) -> Optional[VoiceState]: + return self.guild.me.voice + + async def voice_state_update(self, data: GuildVoiceStatePayload) -> None: + channel_id = data['channel_id'] + + if channel_id is None: + self._disconnected.set() + + # If we know we're going to get a voice_state_update where we have no channel due to + # being in the reconnect or disconnect flow, we ignore it. Otherwise, it probably wasn't from us. + if self._expecting_disconnect: + self._expecting_disconnect = False + else: + _log.debug('We were externally disconnected from voice.') + await self.disconnect() + + return + + channel_id = int(channel_id) + self.session_id = data['session_id'] + + # we got the event while connecting + if self.state in (ConnectionFlowState.set_guild_voice_state, ConnectionFlowState.got_voice_server_update): + if self.state is ConnectionFlowState.set_guild_voice_state: + self.state = ConnectionFlowState.got_voice_state_update + + # we moved ourselves + if channel_id != self.voice_client.channel.id: + self._update_voice_channel(channel_id) + + else: + self.state = ConnectionFlowState.got_both_voice_updates + return + + if self.state is ConnectionFlowState.connected: + self._update_voice_channel(channel_id) + + elif self.state is not ConnectionFlowState.disconnected: + if channel_id != self.voice_client.channel.id: + # For some unfortunate reason we were moved during the connection flow + _log.info('Handling channel move while connecting...') + + self._update_voice_channel(channel_id) + await self.soft_disconnect(with_state=ConnectionFlowState.got_voice_state_update) + await self.connect( + reconnect=self.reconnect, + timeout=self.timeout, + self_deaf=(self.self_voice_state or self).self_deaf, + self_mute=(self.self_voice_state or self).self_mute, + resume=False, + wait=False, + ) + else: + _log.debug('Ignoring unexpected voice_state_update event') + + async def voice_server_update(self, data: VoiceServerUpdatePayload) -> None: + previous_token = self.token + previous_server_id = self.server_id + previous_endpoint = self.endpoint + + self.token = data['token'] + self.server_id = int(data['guild_id']) + endpoint = data.get('endpoint') + + if self.token is None or endpoint is None: + _log.warning( + 'Awaiting endpoint... This requires waiting. ' + 'If timeout occurred considering raising the timeout and reconnecting.' + ) + return + + self.endpoint, _, _ = endpoint.rpartition(':') + if self.endpoint.startswith('wss://'): + # Just in case, strip it off since we're going to add it later + self.endpoint = self.endpoint[6:] + + # we got the event while connecting + if self.state in (ConnectionFlowState.set_guild_voice_state, ConnectionFlowState.got_voice_state_update): + # This gets set after READY is received + self.endpoint_ip = MISSING + self._create_socket() + + if self.state is ConnectionFlowState.set_guild_voice_state: + self.state = ConnectionFlowState.got_voice_server_update + else: + self.state = ConnectionFlowState.got_both_voice_updates + + elif self.state is ConnectionFlowState.connected: + _log.debug('Voice server update, closing old voice websocket') + await self.ws.close(4014) + self.state = ConnectionFlowState.got_voice_server_update + + elif self.state is not ConnectionFlowState.disconnected: + # eventual consistency + if previous_token == self.token and previous_server_id == self.server_id and previous_token == self.token: + return + + _log.debug('Unexpected server update event, attempting to handle') + + await self.soft_disconnect(with_state=ConnectionFlowState.got_voice_server_update) + await self.connect( + reconnect=self.reconnect, + timeout=self.timeout, + self_deaf=(self.self_voice_state or self).self_deaf, + self_mute=(self.self_voice_state or self).self_mute, + resume=False, + wait=False, + ) + self._create_socket() + + async def connect( + self, *, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool, wait: bool = True + ) -> None: + if self._connector: + self._connector.cancel() + self._connector = None + + if self._runner: + self._runner.cancel() + self._runner = None + + self.timeout = timeout + self.reconnect = reconnect + self._connector = self.voice_client.loop.create_task( + self._wrap_connect(reconnect, timeout, self_deaf, self_mute, resume), name='Voice connector' + ) + if wait: + await self._connector + + async def _wrap_connect(self, *args: Any) -> None: + try: + await self._connect(*args) + except asyncio.CancelledError: + _log.debug('Cancelling voice connection') + await self.soft_disconnect() + raise + except asyncio.TimeoutError: + _log.info('Timed out connecting to voice') + await self.disconnect() + raise + except Exception: + _log.exception('Error connecting to voice... disconnecting') + await self.disconnect() + raise + + async def _inner_connect(self, reconnect: bool, self_deaf: bool, self_mute: bool, resume: bool) -> None: + for i in range(5): + _log.info('Starting voice handshake... (connection attempt %d)', i + 1) + + await self._voice_connect(self_deaf=self_deaf, self_mute=self_mute) + # Setting this unnecessarily will break reconnecting + if self.state is ConnectionFlowState.disconnected: + self.state = ConnectionFlowState.set_guild_voice_state + + await self._wait_for_state(ConnectionFlowState.got_both_voice_updates) + + _log.info('Voice handshake complete. Endpoint found: %s', self.endpoint) + + try: + self.ws = await self._connect_websocket(resume) + await self._handshake_websocket() + break + except ConnectionClosed: + if reconnect: + wait = 1 + i * 2.0 + _log.exception('Failed to connect to voice... Retrying in %ss...', wait) + await self.disconnect(cleanup=False) + await asyncio.sleep(wait) + continue + else: + await self.disconnect() + raise + + async def _connect(self, reconnect: bool, timeout: float, self_deaf: bool, self_mute: bool, resume: bool) -> None: + _log.info('Connecting to voice...') + + await asyncio.wait_for( + self._inner_connect(reconnect=reconnect, self_deaf=self_deaf, self_mute=self_mute, resume=resume), + timeout=timeout, + ) + _log.info('Voice connection complete.') + + if not self._runner: + self._runner = self.voice_client.loop.create_task(self._poll_voice_ws(reconnect), name='Voice websocket poller') + + async def disconnect(self, *, force: bool = True, cleanup: bool = True, wait: bool = False) -> None: + if not force and not self.is_connected(): + return + + try: + await self._voice_disconnect() + if self.ws: + await self.ws.close() + except Exception: + _log.debug('Ignoring exception disconnecting from voice', exc_info=True) + finally: + self.state = ConnectionFlowState.disconnected + self._socket_reader.pause() + + # Stop threads before we unlock waiters so they end properly + if cleanup: + self._socket_reader.stop() + self.voice_client.stop() + + # Flip the connected event to unlock any waiters + self._connected.set() + self._connected.clear() + + if self.socket: + self.socket.close() + + self.ip = MISSING + self.port = MISSING + + # Skip this part if disconnect was called from the poll loop task + if wait and not self._inside_runner(): + # Wait for the voice_state_update event confirming the bot left the voice channel. + # This prevents a race condition caused by disconnecting and immediately connecting again. + # The new VoiceConnectionState object receives the voice_state_update event containing channel=None while still + # connecting leaving it in a bad state. Since there's no nice way to transfer state to the new one, we have to do this. + try: + await asyncio.wait_for(self._disconnected.wait(), timeout=self.timeout) + except TimeoutError: + _log.debug('Timed out waiting for voice disconnection confirmation') + except asyncio.CancelledError: + pass + + if cleanup: + self.voice_client.cleanup() + + async def soft_disconnect(self, *, with_state: ConnectionFlowState = ConnectionFlowState.got_both_voice_updates) -> None: + _log.debug('Soft disconnecting from voice') + # Stop the websocket reader because closing the websocket will trigger an unwanted reconnect + if self._runner: + self._runner.cancel() + self._runner = None + + try: + if self.ws: + await self.ws.close() + except Exception: + _log.debug('Ignoring exception soft disconnecting from voice', exc_info=True) + finally: + self.state = with_state + self._socket_reader.pause() + + if self.socket: + self.socket.close() + + self.ip = MISSING + self.port = MISSING + + async def move_to(self, channel: Optional[abc.Snowflake], timeout: Optional[float]) -> None: + if channel is None: + # This function should only be called externally so its ok to wait for the disconnect. + await self.disconnect(wait=True) + return + + if self.voice_client.channel and channel.id == self.voice_client.channel.id: + return + + previous_state = self.state + + # this is only an outgoing ws request + # if it fails, nothing happens and nothing changes (besides self.state) + await self._move_to(channel) + + last_state = self.state + try: + await self.wait_async(timeout) + except asyncio.TimeoutError: + _log.warning('Timed out trying to move to channel %s in guild %s', channel.id, self.guild.id) + if self.state is last_state: + _log.debug('Reverting to previous state %s', previous_state.name) + self.state = previous_state + + def wait(self, timeout: Optional[float] = None) -> bool: + return self._connected.wait(timeout) + + async def wait_async(self, timeout: Optional[float] = None) -> None: + await self._wait_for_state(ConnectionFlowState.connected, timeout=timeout) + + def is_connected(self) -> bool: + return self.state is ConnectionFlowState.connected + + def send_packet(self, packet: bytes) -> None: + self.socket.sendall(packet) + + def add_socket_listener(self, callback: SocketReaderCallback) -> None: + _log.debug('Registering socket listener callback %s', callback) + self._socket_reader.register(callback) + + def remove_socket_listener(self, callback: SocketReaderCallback) -> None: + _log.debug('Unregistering socket listener callback %s', callback) + self._socket_reader.unregister(callback) + + def _inside_runner(self) -> bool: + return self._runner is not None and asyncio.current_task() == self._runner + + async def _wait_for_state( + self, state: ConnectionFlowState, *other_states: ConnectionFlowState, timeout: Optional[float] = None + ) -> None: + states = (state, *other_states) + while True: + if self.state in states: + return + await sane_wait_for([self._state_event.wait()], timeout=timeout) + + async def _voice_connect(self, *, self_deaf: bool = False, self_mute: bool = False) -> None: + channel = self.voice_client.channel + await channel.guild.change_voice_state(channel=channel, self_deaf=self_deaf, self_mute=self_mute) + + async def _voice_disconnect(self) -> None: + _log.info( + 'The voice handshake is being terminated for Channel ID %s (Guild ID %s)', + self.voice_client.channel.id, + self.voice_client.guild.id, + ) + self.state = ConnectionFlowState.disconnected + await self.voice_client.channel.guild.change_voice_state(channel=None) + self._expecting_disconnect = True + self._disconnected.clear() + + async def _connect_websocket(self, resume: bool) -> DiscordVoiceWebSocket: + ws = await DiscordVoiceWebSocket.from_connection_state(self, resume=resume, hook=self.hook) + self.state = ConnectionFlowState.websocket_connected + return ws + + async def _handshake_websocket(self) -> None: + while not self.ip: + await self.ws.poll_event() + self.state = ConnectionFlowState.got_ip_discovery + while self.ws.secret_key is None: + await self.ws.poll_event() + self.state = ConnectionFlowState.connected + + def _create_socket(self) -> None: + self.socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.socket.setblocking(False) + self._socket_reader.resume() + + async def _poll_voice_ws(self, reconnect: bool) -> None: + backoff = ExponentialBackoff() + while True: + try: + await self.ws.poll_event() + except asyncio.CancelledError: + return + except (ConnectionClosed, asyncio.TimeoutError) as exc: + if isinstance(exc, ConnectionClosed): + # The following close codes are undocumented so I will document them here. + # 1000 - normal closure (obviously) + # 4014 - we were externally disconnected (voice channel deleted, we were moved, etc) + # 4015 - voice server has crashed + if exc.code in (1000, 4015): + # Don't call disconnect a second time if the websocket closed from a disconnect call + if not self._expecting_disconnect: + _log.info('Disconnecting from voice normally, close code %d.', exc.code) + await self.disconnect() + break + + if exc.code == 4014: + # We were disconnected by discord + # This condition is a race between the main ws event and the voice ws closing + if self._disconnected.is_set(): + _log.info('Disconnected from voice by discord, close code %d.', exc.code) + await self.disconnect() + break + + # We may have been moved to a different channel + _log.info('Disconnected from voice by force... potentially reconnecting.') + successful = await self._potential_reconnect() + if not successful: + _log.info('Reconnect was unsuccessful, disconnecting from voice normally...') + # Don't bother to disconnect if already disconnected + if self.state is not ConnectionFlowState.disconnected: + await self.disconnect() + break + else: + continue + + _log.debug('Not handling close code %s (%s)', exc.code, exc.reason or 'no reason') + + if not reconnect: + await self.disconnect() + raise + + retry = backoff.delay() + _log.exception('Disconnected from voice... Reconnecting in %.2fs.', retry) + await asyncio.sleep(retry) + await self.disconnect(cleanup=False) + + try: + await self._connect( + reconnect=reconnect, + timeout=self.timeout, + self_deaf=(self.self_voice_state or self).self_deaf, + self_mute=(self.self_voice_state or self).self_mute, + resume=False, + ) + except asyncio.TimeoutError: + # at this point we've retried 5 times... let's continue the loop. + _log.warning('Could not connect to voice... Retrying...') + continue + + async def _potential_reconnect(self) -> bool: + try: + await self._wait_for_state( + ConnectionFlowState.got_voice_server_update, + ConnectionFlowState.got_both_voice_updates, + ConnectionFlowState.disconnected, + timeout=self.timeout, + ) + except asyncio.TimeoutError: + return False + else: + if self.state is ConnectionFlowState.disconnected: + return False + + previous_ws = self.ws + try: + self.ws = await self._connect_websocket(False) + await self._handshake_websocket() + except (ConnectionClosed, asyncio.TimeoutError): + return False + else: + return True + finally: + await previous_ws.close() + + async def _move_to(self, channel: abc.Snowflake) -> None: + await self.voice_client.channel.guild.change_voice_state(channel=channel) + self.state = ConnectionFlowState.set_guild_voice_state + + def _update_voice_channel(self, channel_id: Optional[int]) -> None: + self.voice_client.channel = channel_id and self.guild.get_channel(channel_id) # type: ignore diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index d04e21b57d55..3b0416f9a72c 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -721,6 +721,11 @@ def _get_guild(self, guild_id: Optional[int]) -> Optional[Guild]: return self._parent._get_guild(guild_id) return None + def _get_poll(self, msg_id: Optional[int]) -> Optional[Poll]: + if self._parent is not None: + return self._parent._get_poll(msg_id) + return None + def store_user(self, data: Union[UserPayload, PartialUserPayload], *, cache: bool = True) -> BaseUser: if self._parent is not None: return self._parent.store_user(data, cache=cache) diff --git a/docs/api.rst b/docs/api.rst index 13b49df5b4e4..41cf6549d169 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1053,8 +1053,8 @@ Polls .. function:: on_poll_vote_add(user, answer) on_poll_vote_remove(user, answer) - Called when a :class:`Poll` gains or loses a vote. If the ``user`` or ``message`` - are not cached then this event will not be called. + Called when a :class:`Poll` gains or loses a vote. If the ``user`` or ``answer``'s poll + parent message are not cached then this event will not be called. This requires :attr:`Intents.message_content` and :attr:`Intents.polls` to be enabled. @@ -1078,6 +1078,11 @@ Polls This requires :attr:`Intents.message_content` and :attr:`Intents.polls` to be enabled. + .. note:: + + If the poll allows multiple answers and the user removes or adds multiple votes, this + event will be called as many times as votes that are added or removed. + .. versionadded:: 2.4 :param payload: The raw event payload data. @@ -3615,7 +3620,7 @@ of :class:`enum.Enum`. .. class:: PollLayoutType - Represents how a poll answers are shown + Represents how a poll answers are shown. .. versionadded:: 2.4 @@ -3623,6 +3628,41 @@ of :class:`enum.Enum`. The default layout. + +.. class:: InviteType + + Represents the type of an invite. + + .. versionadded:: 2.4 + + .. attribute:: guild + + The invite is a guild invite. + + .. attribute:: group_dm + + The invite is a group DM invite. + + .. attribute:: friend + + The invite is a friend invite. + + +.. class:: ReactionType + + Represents the type of a reaction. + + .. versionadded:: 2.4 + + .. attribute:: normal + + A normal reaction. + + .. attribute:: burst + + A burst reaction, also known as a "super reaction". + + .. _discord-api-audit-logs: Audit Log Data @@ -5077,6 +5117,14 @@ PartialWebhookChannel .. autoclass:: PartialWebhookChannel() :members: +PollAnswer +~~~~~~~~~~ + +.. attributetable:: PollAnswer + +.. autoclass:: PollAnswer() + :members: + .. _discord_api_data: Data Classes @@ -5347,18 +5395,15 @@ Poll .. attributetable:: Poll -.. autoclass:: Poll() +.. autoclass:: Poll :members: -.. attributetable:: PollAnswer - -.. autoclass:: PollAnswer() - :members: - :inherited-members: +PollMedia +~~~~~~~~~ .. attributetable:: PollMedia -.. autoclass:: PollMedia() +.. autoclass:: PollMedia :members: diff --git a/docs/intents.rst b/docs/intents.rst index e805c5ff71ec..ca85ab8ddc04 100644 --- a/docs/intents.rst +++ b/docs/intents.rst @@ -114,6 +114,7 @@ Message Content - Whether you use :attr:`Message.attachments` to check message attachments. - Whether you use :attr:`Message.embeds` to check message embeds. - Whether you use :attr:`Message.components` to check message components. +- Whether you use :attr:`Message.poll` to check the message polls. - Whether you use the commands extension with a non-mentioning prefix. .. _intents_member_cache: diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 53ad210b3e6c..211cd790f6b5 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -334,7 +334,12 @@ Enumerations .. attribute:: link Represents a link button. + .. attribute:: premium + Represents a button denoting that buying a SKU is + required to perform this action. + + .. versionadded:: 2.4 .. attribute:: blurple An alias for :attr:`primary`. diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 4f09e0a04c8b..d51de610b90a 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,192 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p4p0: + +v2.4.0 +------- + +New Features +~~~~~~~~~~~~~ + +- Add support for allowed contexts in app commands (:issue:`9760`). + - An "allowed context" is the location where an app command can be used. + - This is an internal change to decorators such as :func:`app_commands.guild_only` and :func:`app_commands.dm_only`. + - Add :func:`app_commands.private_channel_only`. + - Add :func:`app_commands.allowed_contexts`. + - Add :class:`app_commands.AppCommandContext`. + - Add :attr:`app_commands.Command.allowed_contexts`. + - Add :attr:`app_commands.AppCommand.allowed_contexts`. + - Add :attr:`app_commands.ContextMenu.allowed_contexts`. + +- Add support for user-installable apps (:issue:`9760`). + - Add :attr:`app_commands.Command.allowed_installs`. + - Add :attr:`app_commands.AppCommand.allowed_installs`. + - Add :attr:`app_commands.ContextMenu.allowed_installs`. + - Add :func:`app_commands.allowed_installs`. + - Add :func:`app_commands.guild_install`. + - Add :func:`app_commands.user_install`. + - Add :class:`app_commands.AppInstallationType`. + - Add :attr:`Interaction.context`. + - Add :meth:`Interaction.is_guild_integration`. + - Add :meth:`Interaction.is_user_integration`. + +- Add support for Polls (:issue:`9759`). + - Polls can be created using :class:`Poll` and the ``poll`` keyword-only parameter in various message sending methods. + - Add :class:`PollAnswer` and :class:`PollMedia`. + - Add :attr:`Intents.polls`, :attr:`Intents.guild_polls` and :attr:`Intents.dm_polls` intents. + - Add :meth:`Message.end_poll` method to end polls. + - Add new events, :func:`on_poll_vote_add`, :func:`on_poll_vote_remove`, :func:`on_raw_poll_vote_add`, and :func:`on_raw_poll_vote_remove`. + +- Voice handling has been completely rewritten to hopefully fix many bugs (:issue:`9525`, :issue:`9528`, :issue:`9536`, :issue:`9572`, :issue:`9576`, :issue:`9596`, :issue:`9683`, :issue:`9699`, :issue:`9772`, etc.) +- Add :attr:`DMChannel.recipients` to get all recipients of a DM channel (:issue:`9760`). +- Add support for :attr:`RawReactionActionEvent.message_author_id`. +- Add support for :attr:`AuditLogAction.creator_monetization_request_created` and :attr:`AuditLogAction.creator_monetization_terms_accepted`. +- Add support for :class:`AttachmentFlags`, accessed via :attr:`Attachment.flags` (:issue:`9486`). +- Add support for :class:`RoleFlags`, accessed via :attr:`Role.flags` (:issue:`9485`). +- Add support for :attr:`ChannelType.media`, accessed via :meth:`ForumChannel.is_media`. +- Add various new permissions (:issue:`9501`, :issue:`9762`, :issue:`9759`, :issue:`9857`) + - Add :meth:`Permissions.events`. + - Add :attr:`Permissions.create_events`. + - Add :attr:`Permissions.view_creator_monetization_analytics`. + - Add :attr:`Permissions.send_polls` + - Add :attr:`Permissions.create_polls`. + - Add :attr:`Permissions.use_external_apps`. + +- Add shortcut for :attr:`CategoryChannel.forums`. +- Add encoder options to :meth:`VoiceClient.play` (:issue:`9527`). +- Add support for team member roles. + - Add :class:`TeamMemberRole`. + - Add :attr:`TeamMember.role`. + - Updated :attr:`Bot.owner_ids <.ext.commands.Bot.owner_ids>` to account for team roles. Team owners or developers are considered Bot owners. + +- Add optional attribute ``integration_type`` in :attr:`AuditLogEntry.extra` for ``kick`` or ``member_role_update`` actions. +- Add support for "dynamic" :class:`ui.Item` that let you parse state out of a ``custom_id`` using regex. + - In order to use this, you must subclass :class:`ui.DynamicItem`. + - This is an alternative to persistent views. + - Add :meth:`Client.add_dynamic_items`. + - Add :meth:`Client.remove_dynamic_items`. + - Add :meth:`ui.Item.interaction_check`. + - Check the :resource:`dynamic_counter example ` for more information. + +- Add support for reading burst reactions. The API does not support sending them as of currently. + - Add :attr:`Reaction.normal_count`. + - Add :attr:`Reaction.burst_count`. + - Add :attr:`Reaction.me_burst`. + +- Add support for default values on select menus (:issue:`9577`). + - Add :class:`SelectDefaultValue`. + - Add :class:`SelectDefaultValueType`. + - Add a ``default_values`` attribute to each specialised select menu. + +- Add ``scheduled_event`` parameter for :meth:`StageChannel.create_instance` (:issue:`9595`). +- Add support for auto mod members (:issue:`9328`). + - Add ``type`` keyword argument to :class:`AutoModRuleAction`. + - Add :attr:`AutoModTrigger.mention_raid_protection`. + - Add :attr:`AutoModRuleTriggerType.member_profile`. + - Add :attr:`AutoModRuleEventType.member_update`. + - Add :attr:`AutoModRuleActionType.block_member_interactions`. + +- Add support for premium app integrations (:issue:`9453`). + - Add multiple SKU and entitlement related classes, e.g. :class:`SKU`, :class:`Entitlement`, :class:`SKUFlags`. + - Add multiple enums, e.g. :class:`SKUType`, :class:`EntitlementType`, :class:`EntitlementOwnerType`. + - Add :meth:`Client.fetch_skus` and :meth:`Client.fetch_entitlement` to fetch from the API. + - Add :meth:`Client.create_entitlement` to create entitlements. + - Add :attr:`Client.entitlements`. + - Add :attr:`Interaction.entitlement_sku_ids`. + - Add :attr:`Interaction.entitlements`. + - Add :attr:`ButtonStyle.premium` and :attr:`ui.Button.sku_id` to send a button asking the user to buy an SKU (:issue:`9845`). + - Add support for one time purchase (:issue:`9803`). + +- Add support for editing application info (:issue:`9610`). + - Add :attr:`AppInfo.interactions_endpoint_url`. + - Add :attr:`AppInfo.redirect_uris`. + - Add :meth:`AppInfo.edit`. + +- Add support for getting/fetching threads from :class:`Message` (:issue:`9665`). + - Add :attr:`PartialMessage.thread`. + - Add :attr:`Message.thread`. + - Add :meth:`Message.fetch_thread`. + +- Add support for platform and assets to activities (:issue:`9677`). + - Add :attr:`Activity.platform`. + - Add :attr:`Game.platform`. + - Add :attr:`Game.assets`. + +- Add support for suppressing embeds in an interaction response (:issue:`9678`). +- Add support for adding forum thread tags via webhook (:issue:`9680`) and (:issue:`9783`). +- Add support for guild incident message types (:issue:`9686`). +- Add :attr:`Locale.latin_american_spanish` (:issue:`9689`). +- Add support for setting voice channel status (:issue:`9603`). +- Add a shard connect timeout parameter to :class:`AutoShardedClient`. +- Add support for guild incidents (:issue:`9590`). + - Updated :meth:`Guild.edit` with ``invites_disabled_until`` and ``dms_disabled_until`` parameters. + - Add :attr:`Guild.invites_paused_until`. + - Add :attr:`Guild.dms_paused_until`. + - Add :meth:`Guild.invites_paused`. + - Add :meth:`Guild.dms_paused`. + +- Add support for :attr:`abc.User.avatar_decoration` (:issue:`9343`). +- Add support for GIF stickers (:issue:`9737`). +- Add support for updating :class:`ClientUser` banners (:issue:`9752`). +- Add support for bulk banning members via :meth:`Guild.bulk_ban`. +- Add ``reason`` keyword argument to :meth:`Thread.delete` (:issue:`9804`). +- Add :attr:`AppInfo.approximate_guild_count` (:issue:`9811`). +- Add support for :attr:`Message.interaction_metadata` (:issue:`9817`). +- Add support for differing :class:`Invite` types (:issue:`9682`). +- Add support for reaction types to raw and non-raw models (:issue:`9836`). +- |tasks| Add ``name`` parameter to :meth:`~ext.tasks.loop` to name the internal :class:`asyncio.Task`. +- |commands| Add fallback behaviour to :class:`~ext.commands.CurrentGuild`. +- |commands| Add logging for errors that occur during :meth:`~ext.commands.Cog.cog_unload`. +- |commands| Add support for :class:`typing.NewType` and ``type`` keyword type aliases (:issue:`9815`). + - Also supports application commands. + +- |commands| Add support for positional-only flag parameters (:issue:`9805`). +- |commands| Add support for channel URLs in ChannelConverter related classes (:issue:`9799`). + + +Bug Fixes +~~~~~~~~~~ + +- Fix emoji and sticker cache being populated despite turning the intent off. +- Fix outstanding chunk requests when receiving a gateway READY event not being cleared (:issue:`9571`). +- Fix escape behaviour for lists and headers in :meth:`~utils.escape_markdown`. +- Fix alias value for :attr:`Intents.auto_moderation` (:issue:`9524`). +- Fixes and improvements for :class:`FFmpegAudio` and all related subclasses (:issue:`9528`). +- Fix :meth:`Template.source_guild` attempting to resolve from cache (:issue:`9535`). +- Fix :exc:`IndexError` being raised instead of :exc:`ValueError` when calling :meth:`Colour.from_str` with an empty string (:issue:`9540`). +- Fix :meth:`View.from_message` not correctly creating the varying :class:`ui.Select` types (:issue:`9559`). +- Fix logging with autocomplete exceptions, which were previously suppressed. +- Fix possible error in voice cleanup logic (:issue:`9572`). +- Fix possible :exc:`AttributeError` during :meth:`app_commands.CommandTree.sync` when a command is regarded as 'too large'. +- Fix possible :exc:`TypeError` if a :class:`app_commands.Group` did not have a name set (:issue:`9581`). +- Fix possible bad voice state where you move to a voice channel with missing permissions (:issue:`9596`). +- Fix websocket reaching an error state due to received error payload (:issue:`9561`). +- Fix handling of :class:`AuditLogDiff` when relating to auto mod triggers (:issue:`9622`). +- Fix race condition in voice logic relating to disconnect and connect (:issue:`9683`). +- Use the :attr:`Interaction.user` guild as a fallback for :attr:`Interaction.guild` if not available. +- Fix restriction on auto moderation audit log ID range. +- Fix check for maximum number of children per :class:`ui.View`. +- Fix comparison between :class:`Object` classes with a ``type`` set. +- Fix handling of an enum in :meth:`AutoModRule.edit` (:issue:`9798`). +- Fix handling of :meth:`Client.close` within :meth:`Client.__aexit__` (:issue:`9769`). +- Fix channel deletion not evicting related threads from cache (:issue:`9796`). +- Fix bug with cache superfluously incrementing role positions (:issue:`9853`). +- Fix ``exempt_channels`` not being passed along in :meth:`Guild.create_automod_rule` (:issue:`9861`). +- Fix :meth:`abc.GuildChannel.purge` failing on single-message delete mode if the message was deleted (:issue:`9830`, :issue:`9863`). +- |commands| Fix localization support for :class:`~ext.commands.HybridGroup` fallback. +- |commands| Fix nested :class:`~ext.commands.HybridGroup`'s inserting manual app commands. +- |commands| Fix an issue where :class:`~ext.commands.HybridGroup` wrapped instances would be out of sync. +- |commands| Fix :class:`~ext.commands.HelpCommand` defined checks not carrying over during copy (:issue:`9843`). + +Miscellaneous +~~~~~~~~~~~~~~ + +- Additional documentation added for logging capabilities. +- Performance increases of constructing :class:`Permissions` using keyword arguments. +- Improve ``__repr__`` of :class:`SyncWebhook` and :class:`Webhook` (:issue:`9764`). +- Change internal thread names to be consistent (:issue:`9538`). + .. _vp2p3p2: v2.3.2 diff --git a/requirements.txt b/requirements.txt index 74dedab377f8..046084ebb6d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1 @@ aiohttp>=3.7.4,<4 -async-timeout>=4.0,<5.0; python_version<"3.11"