From a2a228105b5c98e0f3f9da2735639aa00cb56694 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 28 Sep 2025 18:03:50 +0200 Subject: [PATCH 01/88] Fix various TypeDicts for Unpack --- discord/client.py | 20 ++++++++++---------- discord/ext/commands/bot.py | 4 ++-- discord/ext/commands/cog.py | 17 +++-------------- discord/ext/commands/core.py | 10 +++++----- discord/ext/commands/help.py | 10 +++++----- discord/ext/commands/hybrid.py | 4 +++- 6 files changed, 28 insertions(+), 37 deletions(-) diff --git a/discord/client.py b/discord/client.py index cfd8fb122..63c86f352 100644 --- a/discord/client.py +++ b/discord/client.py @@ -124,25 +124,25 @@ if TYPE_CHECKING: from .flags import MemberCacheFlags class _ClientOptions(TypedDict, total=False): - max_messages: int - proxy: str - proxy_auth: aiohttp.BasicAuth - shard_id: int - shard_count: int + max_messages: Optional[int] + proxy: Optional[str] + proxy_auth: Optional[aiohttp.BasicAuth] + shard_id: Optional[int] + shard_count: Optional[int] application_id: int member_cache_flags: MemberCacheFlags chunk_guilds_at_startup: bool - status: Status - activity: BaseActivity - allowed_mentions: AllowedMentions + status: Optional[Status] + activity: Optional[BaseActivity] + allowed_mentions: Optional[AllowedMentions] heartbeat_timeout: float guild_ready_timeout: float assume_unsync_clock: bool enable_debug_events: bool enable_raw_presences: bool http_trace: aiohttp.TraceConfig - max_ratelimit_timeout: float - connector: aiohttp.BaseConnector + max_ratelimit_timeout: Optional[float] + connector: Optional[aiohttp.BaseConnector] # fmt: off diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 3a916d69e..0bb4cf95f 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -89,8 +89,8 @@ if TYPE_CHECKING: PrefixType = Union[_Prefix, _PrefixCallable[BotT]] class _BotOptions(_ClientOptions, total=False): - owner_id: int - owner_ids: Collection[int] + owner_id: Optional[int] + owner_ids: Optional[Collection[int]] strip_after_prefix: bool case_insensitive: bool diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 371a9f8c1..b6d2ab0c1 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -45,29 +45,18 @@ from typing import ( Tuple, TypeVar, Union, - TypedDict, ) from ._types import _BaseCommand, BotT if TYPE_CHECKING: - from typing_extensions import Self, Unpack + from typing_extensions import Self from discord.abc import Snowflake from discord._types import ClientT from .bot import BotBase from .context import Context - from .core import Command, _CommandDecoratorKwargs - - class _CogKwargs(TypedDict, total=False): - name: str - group_name: Union[str, app_commands.locale_str] - description: str - group_description: Union[str, app_commands.locale_str] - group_nsfw: bool - group_auto_locale_strings: bool - group_extras: Dict[Any, Any] - command_attrs: _CommandDecoratorKwargs + from .core import Command __all__ = ( @@ -182,7 +171,7 @@ class CogMeta(type): __cog_app_commands__: List[Union[app_commands.Group, app_commands.Command[Any, ..., Any]]] __cog_listeners__: List[Tuple[str, str]] - def __new__(cls, *args: Any, **kwargs: Unpack[_CogKwargs]) -> CogMeta: + def __new__(cls, *args: Any, **kwargs: Any) -> CogMeta: name, bases, attrs = args if any(issubclass(base, app_commands.Group) for base in bases): raise TypeError( diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 9ec0dd484..949539b61 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -68,11 +68,11 @@ if TYPE_CHECKING: class _CommandDecoratorKwargs(TypedDict, total=False): enabled: bool - help: str - brief: str - usage: str + help: Optional[str] + brief: Optional[str] + usage: Optional[str] rest_is_raw: bool - aliases: List[str] + aliases: Union[List[str], Tuple[str, ...]] description: str hidden: bool checks: List[UserCheck[Context[Any]]] @@ -449,7 +449,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): self.brief: Optional[str] = kwargs.get('brief') self.usage: Optional[str] = kwargs.get('usage') self.rest_is_raw: bool = kwargs.get('rest_is_raw', False) - self.aliases: Union[List[str], Tuple[str]] = kwargs.get('aliases', []) + self.aliases: Union[List[str], Tuple[str, ...]] = kwargs.get('aliases', []) self.extras: Dict[Any, Any] = kwargs.get('extras', {}) if not isinstance(self.aliases, (list, tuple)): diff --git a/discord/ext/commands/help.py b/discord/ext/commands/help.py index dabbd9ef9..10648b4cc 100644 --- a/discord/ext/commands/help.py +++ b/discord/ext/commands/help.py @@ -69,12 +69,12 @@ if TYPE_CHECKING: class _HelpCommandOptions(TypedDict, total=False): show_hidden: bool - verify_checks: bool + verify_checks: Optional[bool] command_attrs: _CommandKwargs class _BaseHelpCommandOptions(_HelpCommandOptions, total=False): sort_commands: bool - dm_help: bool + dm_help: Optional[bool] dm_help_threshold: int no_category: str paginator: Paginator @@ -394,7 +394,7 @@ class HelpCommand: def __init__(self, **options: Unpack[_HelpCommandOptions]) -> None: self.show_hidden: bool = options.pop('show_hidden', False) - self.verify_checks: bool = options.pop('verify_checks', True) + self.verify_checks: Optional[bool] = options.pop('verify_checks', True) self.command_attrs = attrs = options.pop('command_attrs', {}) attrs.setdefault('name', 'help') attrs.setdefault('help', 'Shows this message') @@ -1070,7 +1070,7 @@ class DefaultHelpCommand(HelpCommand): self.width: int = options.pop('width', 80) self.indent: int = options.pop('indent', 2) self.sort_commands: bool = options.pop('sort_commands', True) - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.arguments_heading: str = options.pop('arguments_heading', 'Arguments:') self.commands_heading: str = options.pop('commands_heading', 'Commands:') @@ -1364,7 +1364,7 @@ class MinimalHelpCommand(HelpCommand): def __init__(self, **options: Unpack[_MinimalHelpCommandOptions]) -> None: self.sort_commands: bool = options.pop('sort_commands', True) self.commands_heading: str = options.pop('commands_heading', 'Commands') - self.dm_help: bool = options.pop('dm_help', False) + self.dm_help: Optional[bool] = options.pop('dm_help', False) self.dm_help_threshold: int = options.pop('dm_help_threshold', 1000) self.aliases_heading: str = options.pop('aliases_heading', 'Aliases:') self.no_category: str = options.pop('no_category', 'No Category') diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 70d18f5d1..6687104cb 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -67,10 +67,12 @@ if TYPE_CHECKING: default_permissions: bool nsfw: bool description: str + case_insensitive: bool class _HybridGroupDecoratorKwargs(_HybridGroupKwargs, total=False): description: Union[str, app_commands.locale_str] - fallback: Union[str, app_commands.locale_str] + fallback: Optional[str] + fallback_locale: Optional[app_commands.locale_str] __all__ = ( From b837d94d1a4abf5288fc86aed36fd1c1cb051e6d Mon Sep 17 00:00:00 2001 From: Martin Date: Sun, 28 Sep 2025 18:15:11 +0200 Subject: [PATCH 02/88] Add silent parameter to ForumChannel.create_thread --- discord/channel.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/discord/channel.py b/discord/channel.py index 3bfaeba0f..17a1c0fb2 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -2879,6 +2879,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = ..., view: LayoutView, suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2901,6 +2902,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = ..., view: View = ..., suppress_embeds: bool = ..., + silent: bool = ..., reason: Optional[str] = ..., ) -> ThreadWithMessage: ... @@ -2922,6 +2924,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): applied_tags: Sequence[ForumTag] = MISSING, view: BaseView = MISSING, suppress_embeds: bool = False, + silent: bool = False, reason: Optional[str] = None, ) -> ThreadWithMessage: """|coro| @@ -2976,6 +2979,11 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): A list of stickers to upload. Must be a maximum of 3. suppress_embeds: :class:`bool` Whether to suppress embeds for the message. This sends the message without any embeds if set to ``True``. + silent: :class:`bool` + Whether to suppress push and desktop notifications for the message. This will increment the mention counter + in the UI, but will not actually send a notification. + + .. versionadded:: 2.7 reason: :class:`str` The reason for creating a new thread. Shows up on the audit log. @@ -3008,8 +3016,10 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): if view and not hasattr(view, '__discord_ui_view__'): raise TypeError(f'view parameter must be View not {view.__class__.__name__}') - if suppress_embeds: - flags = MessageFlags._from_value(4) + if suppress_embeds or silent: + flags = MessageFlags._from_value(0) + flags.suppress_embeds = suppress_embeds + flags.suppress_notifications = silent else: flags = MISSING From a2a00ae8afb8aaa17f642cfa8d8afcb534254053 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Sun, 28 Sep 2025 17:32:36 +0100 Subject: [PATCH 03/88] Fix unordered list rendering for several AuditLogAction entries --- docs/api.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/api.rst b/docs/api.rst index ab8f4f5ca..1a8365d81 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -2824,6 +2824,7 @@ of :class:`enum.Enum`. which was created. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2843,6 +2844,7 @@ of :class:`enum.Enum`. which was updated. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` @@ -2862,6 +2864,7 @@ of :class:`enum.Enum`. which was deleted. Possible attributes for :class:`AuditLogDiff`: + - :attr:`~AuditLogDiff.name` - :attr:`~AuditLogDiff.channel` - :attr:`~AuditLogDiff.description` From 6ad55415736bfd0b8bb7562c0aaa0fdac1a56e6c Mon Sep 17 00:00:00 2001 From: Levi Pesin <35454228+LeviPesin@users.noreply.github.com> Date: Mon, 29 Sep 2025 02:02:52 +0930 Subject: [PATCH 04/88] Add missing InviteType and ReactionType exports --- discord/enums.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/discord/enums.py b/discord/enums.py index 172f736a9..0f1d02695 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -75,6 +75,8 @@ __all__ = ( 'EntitlementType', 'EntitlementOwnerType', 'PollLayoutType', + 'InviteType', + 'ReactionType', 'VoiceChannelEffectAnimationType', 'SubscriptionStatus', 'MessageReferenceType', From 46300dfc62f9e6d06654780e07f4e46411006244 Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 29 Sep 2025 00:36:24 +0800 Subject: [PATCH 05/88] Add missing reason kwarg in delete_invite --- discord/client.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/discord/client.py b/discord/client.py index 63c86f352..0b6e4fa4b 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2511,7 +2511,7 @@ class Client: ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /) -> Invite: + async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str]) -> Invite: """|coro| Revokes an :class:`.Invite`, URL, or ID to an invite. @@ -2527,6 +2527,8 @@ class Client: ---------- invite: Union[:class:`.Invite`, :class:`str`] The invite to revoke. + reason: Optional[:class:`str`] + The reason for deleting the invite. Shows up on the audit log. Raises ------- @@ -2539,7 +2541,7 @@ class Client: """ resolved = utils.resolve_invite(invite) - data = await self.http.delete_invite(resolved.code) + data = await self.http.delete_invite(resolved.code, reason=reason) return Invite.from_incomplete(state=self._connection, data=data) # Miscellaneous stuff From c050ed02c3431895f537486bdfe2d0d55f103cd5 Mon Sep 17 00:00:00 2001 From: Sacul Date: Mon, 29 Sep 2025 00:54:54 +0800 Subject: [PATCH 06/88] Support new fields in Modify Current Member --- discord/http.py | 13 +++++------ discord/member.py | 55 +++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 58 insertions(+), 10 deletions(-) diff --git a/discord/http.py b/discord/http.py index 7b82fddb6..acf3835f6 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1136,18 +1136,15 @@ class HTTPClient: def edit_profile(self, payload: Dict[str, Any]) -> Response[user.User]: return self.request(Route('PATCH', '/users/@me'), json=payload) - def change_my_nickname( + def edit_my_member( self, guild_id: Snowflake, - nickname: str, *, reason: Optional[str] = None, - ) -> Response[member.Nickname]: - r = Route('PATCH', '/guilds/{guild_id}/members/@me/nick', guild_id=guild_id) - payload = { - 'nick': nickname, - } - return self.request(r, json=payload, reason=reason) + **fields: Any, + ) -> Response[member.MemberWithUser]: + r = Route('PATCH', '/guilds/{guild_id}/members/@me', guild_id=guild_id) + return self.request(r, json=fields, reason=reason) def change_nickname( self, diff --git a/discord/member.py b/discord/member.py index fd2cf7edb..69ebada01 100644 --- a/discord/member.py +++ b/discord/member.py @@ -815,12 +815,22 @@ class Member(discord.abc.Messageable, _UserTag): voice_channel: Optional[VocalGuildChannel] = MISSING, timed_out_until: Optional[datetime.datetime] = MISSING, bypass_verification: bool = MISSING, + avatar: Optional[bytes] = MISSING, + banner: Optional[bytes] = MISSING, + bio: Optional[str] = MISSING, reason: Optional[str] = None, ) -> Optional[Member]: """|coro| Edits the member's data. + .. note:: + + To upload an avatar or banner, a :term:`py:bytes-like object` must be passed in that + represents the image being uploaded. If this is done through a file + then the file must be opened via ``open('some_filename', 'rb')`` and + the :term:`py:bytes-like object` is given through the use of ``fp.read()``. + Depending on the parameter passed, this requires different permissions listed below: +---------------------+---------------------------------------+ @@ -876,6 +886,20 @@ class Member(discord.abc.Messageable, _UserTag): Indicates if the member should be allowed to bypass the guild verification requirements. .. versionadded:: 2.2 + avatar: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. + Only image formats supported for uploading are JPEG, PNG, GIF, and WEBP. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 + banner: Optional[:class:`bytes`] + A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no banner. + Only image formats supported for uploading are JPEG, PNG, GIF and WEBP.. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 + bio: Optional[:class:`str`] + The new bio for the member. Use ``None`` to remove the bio. + This can only be set when editing the bot's own member. + .. versionadded:: 2.7 reason: Optional[:class:`str`] The reason for editing this member. Shows up on the audit log. @@ -888,6 +912,9 @@ class Member(discord.abc.Messageable, _UserTag): The operation failed. TypeError The datetime object passed to ``timed_out_until`` was not timezone-aware. + ValueError + You tried to edit the bio, avatar or banner of a member that is not the bot's own member. + Or the wrong image format passed for ``avatar`` or ``banner``. Returns -------- @@ -899,14 +926,33 @@ class Member(discord.abc.Messageable, _UserTag): guild_id = self.guild.id me = self._state.self_id == self.id payload: Dict[str, Any] = {} + self_payload: Dict[str, Any] = {} if nick is not MISSING: nick = nick or '' if me: - await http.change_my_nickname(guild_id, nick, reason=reason) + self_payload['nick'] = nick else: payload['nick'] = nick + if avatar is not MISSING: + if avatar is None: + self_payload['avatar'] = None + else: + self_payload['avatar'] = utils._bytes_to_base64_data(avatar) + + if banner is not MISSING: + if banner is None: + self_payload['banner'] = None + else: + self_payload['banner'] = utils._bytes_to_base64_data(banner) + + if bio is not MISSING: + self_payload['bio'] = bio or '' + + if not me and self_payload: + raise ValueError("Editing the bio, avatar or banner is only for the bot's own member.") + if deafen is not MISSING: payload['deaf'] = deafen @@ -954,7 +1000,12 @@ class Member(discord.abc.Messageable, _UserTag): if payload: data = await http.edit_member(guild_id, self.id, reason=reason, **payload) - return Member(data=data, guild=self.guild, state=self._state) + elif self_payload: + data = await http.edit_my_member(guild_id, reason=reason, **self_payload) + else: + return None + + return Member(data=data, guild=self.guild, state=self._state) async def request_to_speak(self) -> None: """|coro| From 78ff16621a5552fe30c562c5c662c446777e668b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 28 Sep 2025 13:05:23 -0400 Subject: [PATCH 07/88] Fix certain component IDs not being able to be settable afterwards Fix #10305 --- discord/ui/file.py | 10 +++++++++- discord/ui/separator.py | 9 +++++++++ discord/ui/text_input.py | 10 +++++++++- 3 files changed, 27 insertions(+), 2 deletions(-) diff --git a/discord/ui/file.py b/discord/ui/file.py index 92b927ac0..acebc5ace 100644 --- a/discord/ui/file.py +++ b/discord/ui/file.py @@ -100,7 +100,15 @@ class File(Item[V]): spoiler=bool(spoiler), id=id, ) - self.id = id + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this file component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value def _is_v2(self): return True diff --git a/discord/ui/separator.py b/discord/ui/separator.py index e6dc61f00..9f34341da 100644 --- a/discord/ui/separator.py +++ b/discord/ui/separator.py @@ -83,6 +83,15 @@ class Separator(Item[V]): def _is_v2(self): return True + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this separator.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def visible(self) -> bool: """:class:`bool`: Whether this separator is visible. diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index de0c8e079..e93a710ca 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -144,11 +144,19 @@ class TextInput(Item[V]): id=id, ) self.row = row - self.id = id def __str__(self) -> str: return self.value + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this text input.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + @property def custom_id(self) -> str: """:class:`str`: The ID of the text input that gets received during an interaction.""" From 830858cde8c67fcb4e2a5884be389a4107369e5b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 28 Sep 2025 13:17:54 -0400 Subject: [PATCH 08/88] Add MessageType.is_deletable to fix Messageable.purge failing Fix #10319 --- discord/abc.py | 3 +++ discord/enums.py | 10 ++++++++++ docs/api.rst | 9 +++++++++ 3 files changed, 22 insertions(+) diff --git a/discord/abc.py b/discord/abc.py index 535c70aba..5f06fb1ce 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -195,6 +195,9 @@ async def _purge_helper( count = 0 await asyncio.sleep(1) + if not message.type.is_deletable(): + continue + if not check(message): continue diff --git a/discord/enums.py b/discord/enums.py index 0f1d02695..d691e6b48 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -279,6 +279,16 @@ class MessageType(Enum): poll_result = 46 emoji_added = 63 + def is_deletable(self) -> bool: + return self not in { + MessageType.recipient_add, + MessageType.recipient_remove, + MessageType.call, + MessageType.channel_name_change, + MessageType.channel_icon_change, + MessageType.thread_starter_message, + } + class SpeakingState(Enum): none = 0 diff --git a/docs/api.rst b/docs/api.rst index 1a8365d81..495636b12 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1732,6 +1732,15 @@ of :class:`enum.Enum`. Checks if two messages are not equal. + .. method:: is_deletable() + + Checks if the message type is deletable, as some system messages cannot be deleted. + + .. versionadded:: 2.7 + + :return: A boolean denoting if the message type is deletable. + :rtype: :class:`bool` + .. attribute:: default The default message type. This is the same as regular messages. From 1d3642d1fed01b18acc96a84c70d6198652f42aa Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Sun, 21 Sep 2025 15:58:00 +0200 Subject: [PATCH 09/88] Fix KeyError on custom_id for modal components that don't support it --- discord/ui/modal.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 86c09da30..b1f86165a 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -175,9 +175,13 @@ class Modal(BaseView): elif component['type'] == 18: self._refresh(interaction, [component['component']]) else: - item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore + custom_id = component.get('custom_id') + if custom_id is None: + continue + + item = find(lambda i: getattr(i, 'custom_id', None) == custom_id, self.walk_children()) if item is None: - _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', component['custom_id']) + _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', custom_id) continue item._refresh_state(interaction, component) # type: ignore From 944ffe93c77250d18d70875675d46a3262856e43 Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:06:31 +0200 Subject: [PATCH 10/88] Handle resolved data for modal components and types --- discord/state.py | 3 ++- discord/types/interactions.py | 21 +++++++++++----- discord/ui/item.py | 4 +++ discord/ui/modal.py | 47 +++++++++++++++++++++++++++-------- discord/ui/select.py | 22 ++++++++++++++-- discord/ui/view.py | 8 ++++-- 6 files changed, 84 insertions(+), 21 deletions(-) diff --git a/discord/state.py b/discord/state.py index 74922907d..7ef3bbd15 100644 --- a/discord/state.py +++ b/discord/state.py @@ -828,7 +828,8 @@ class ConnectionState(Generic[ClientT]): inner_data = data['data'] custom_id = inner_data['custom_id'] components = inner_data['components'] - self._view_store.dispatch_modal(custom_id, interaction, components) + resolved = inner_data.get('resolved', {}) + self._view_store.dispatch_modal(custom_id, interaction, components, resolved) self.dispatch('interaction', interaction) def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: diff --git a/discord/types/interactions.py b/discord/types/interactions.py index f34166959..dfdb9a0dd 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -36,6 +36,7 @@ from .role import Role from .snowflake import Snowflake from .user import User from .guild import GuildFeature +from .components import ComponentBase if TYPE_CHECKING: from .message import Message @@ -204,19 +205,19 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData] -class ModalSubmitTextInputInteractionData(TypedDict): +class ModalSubmitTextInputInteractionData(ComponentBase): type: Literal[4] custom_id: str value: str -class ModalSubmitStringSelectInteractionData(TypedDict): - type: Literal[3] +class ModalSubmitSelectInteractionData(ComponentBase): + type: Literal[3, 5, 6, 7, 8] custom_id: str values: List[str] -ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData] +ModalSubmitComponentItemInteractionData = Union[ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData] class ModalSubmitActionRowInteractionData(TypedDict): @@ -224,19 +225,27 @@ class ModalSubmitActionRowInteractionData(TypedDict): components: List[ModalSubmitComponentItemInteractionData] -class ModalSubmitLabelInteractionData(TypedDict): +class ModalSubmitTextDisplayInteractionData(ComponentBase): + type: Literal[10] + content: str + + +class ModalSubmitLabelInteractionData(ComponentBase): type: Literal[18] component: ModalSubmitComponentItemInteractionData ModalSubmitComponentInteractionData = Union[ - ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData + ModalSubmitActionRowInteractionData, + ModalSubmitTextDisplayInteractionData, + ModalSubmitLabelInteractionData, ] class ModalSubmitInteractionData(TypedDict): custom_id: str components: List[ModalSubmitComponentInteractionData] + resolved: NotRequired[ResolvedData] InteractionData = Union[ diff --git a/discord/ui/item.py b/discord/ui/item.py index 5498dc20f..8f716559c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -45,6 +45,7 @@ if TYPE_CHECKING: from .action_row import ActionRow from .container import Container from .dynamic import DynamicItem + from ..app_commands.namespace import ResolveKey I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) @@ -97,6 +98,9 @@ class Item(Generic[V]): def _refresh_component(self, component: Component) -> None: return None + def _handle_submit(self, interaction: Interaction, data: Dict[str, Any], resolved: Dict[ResolveKey, Any]) -> None: + return self._refresh_state(interaction, data) + def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None diff --git a/discord/ui/modal.py b/discord/ui/modal.py index b1f86165a..db8bf5241 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -36,12 +36,17 @@ from .item import Item from .view import BaseView from .select import BaseSelect from .text_input import TextInput +from ..interactions import Namespace if TYPE_CHECKING: from typing_extensions import Self from ..interactions import Interaction - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) + from ..app_commands.namespace import ResolveKey # fmt: off @@ -168,27 +173,41 @@ class Modal(BaseView): """ _log.error('Ignoring exception in modal %r:', self, exc_info=error) - def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitComponentInteractionDataPayload]) -> None: + def _refresh( + self, + interaction: Interaction, + components: Sequence[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ) -> None: for component in components: if component['type'] == 1: - self._refresh(interaction, component['components']) + self._refresh(interaction, component['components'], resolved) # type: ignore elif component['type'] == 18: - self._refresh(interaction, [component['component']]) + self._refresh(interaction, [component['component']], resolved) # type: ignore else: custom_id = component.get('custom_id') if custom_id is None: continue - item = find(lambda i: getattr(i, 'custom_id', None) == custom_id, self.walk_children()) + item = find( + lambda i: getattr(i, 'custom_id', None) == custom_id, + self.walk_children(), + ) if item is None: _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', custom_id) continue - item._refresh_state(interaction, component) # type: ignore - async def _scheduled_task(self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]): + item._handle_submit(interaction, component, resolved) # type: ignore + + async def _scheduled_task( + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ): try: self._refresh_timeout() - self._refresh(interaction, components) + self._refresh(interaction, components, resolved) allow = await self.interaction_check(interaction) if not allow: @@ -225,10 +244,18 @@ class Modal(BaseView): return components def _dispatch_submit( - self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> asyncio.Task[None]: + try: + namespace = Namespace._get_resolved_items(interaction, resolved) + except KeyError: + namespace = {} + return asyncio.create_task( - self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}' + self._scheduled_task(interaction, components, namespace), name=f'discord-ui-modal-dispatch-{self.id}' ) def to_dict(self) -> Dict[str, Any]: diff --git a/discord/ui/select.py b/discord/ui/select.py index a181357b7..202288feb 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -78,6 +78,7 @@ if TYPE_CHECKING: from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread from ..interactions import Interaction + from ..app_commands.namespace import ResolveKey ValidSelectType: TypeAlias = Literal[ ComponentType.string_select, @@ -356,7 +357,24 @@ class BaseSelect(Item[V]): def _refresh_component(self, component: SelectMenu) -> None: self._underlying = component - def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentInteractionData) -> None: + def _handle_submit( + self, interaction: Interaction, data: SelectMessageComponentInteractionData, resolved: Dict[ResolveKey, Any] + ) -> None: + payload: List[PossibleValue] + values = selected_values.get({}) + string_values = data.get('values', []) + payload = [v for k, v in resolved.items() if k.id in string_values] + if not payload: + payload = list(string_values) + + self._values = values[self.custom_id] = payload + selected_values.set(values) + + def _refresh_state( + self, + interaction: Interaction, + data: SelectMessageComponentInteractionData, + ) -> None: values = selected_values.get({}) payload: List[PossibleValue] try: @@ -366,7 +384,7 @@ class BaseSelect(Item[V]): ) payload = list(resolved.values()) except KeyError: - payload = data.get('values', []) # type: ignore + payload = list(data.get('values', [])) self._values = values[self.custom_id] = payload selected_values.set(values) diff --git a/discord/ui/view.py b/discord/ui/view.py index 9c7547e60..252a21dbb 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -85,7 +85,10 @@ if TYPE_CHECKING: from ..interactions import Interaction from ..message import Message from ..types.components import ComponentBase as ComponentBasePayload - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) from ..state import ConnectionState from .modal import Modal @@ -1041,13 +1044,14 @@ class ViewStore: custom_id: str, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> None: modal = self._modals.get(custom_id) if modal is None: _log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id) return - self.add_task(modal._dispatch_submit(interaction, components)) + self.add_task(modal._dispatch_submit(interaction, components, resolved)) def remove_interaction_mapping(self, interaction_id: int) -> None: # This is called before re-adding the view From 2d7e0614ad91e0544d0bf3f20f6ec615ca2b6d25 Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:19:04 +0200 Subject: [PATCH 11/88] Add support for File Upload component --- discord/components.py | 70 ++++++++++++ discord/enums.py | 1 + discord/types/components.py | 14 ++- discord/types/interactions.py | 10 +- discord/ui/__init__.py | 1 + discord/ui/file_upload.py | 199 ++++++++++++++++++++++++++++++++++ docs/interactions/api.rst | 27 +++++ examples/modals/report.py | 143 ++++++++++++++++++++++++ 8 files changed, 461 insertions(+), 4 deletions(-) create mode 100644 discord/ui/file_upload.py create mode 100644 examples/modals/report.py diff --git a/discord/components.py b/discord/components.py index 08ae4f277..c6b5974de 100644 --- a/discord/components.py +++ b/discord/components.py @@ -72,6 +72,7 @@ if TYPE_CHECKING: ContainerComponent as ContainerComponentPayload, UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, + FileUploadComponent as FileUploadComponentPayload, ) from .emoji import Emoji @@ -112,6 +113,7 @@ __all__ = ( 'TextDisplay', 'SeparatorComponent', 'LabelComponent', + 'FileUploadComponent', ) @@ -131,6 +133,7 @@ class Component: - :class:`FileComponent` - :class:`SeparatorComponent` - :class:`Container` + - :class:`FileUploadComponent` This class is abstract and cannot be instantiated. @@ -1384,6 +1387,71 @@ class LabelComponent(Component): return payload +class FileUploadComponent(Component): + """Represents a file upload component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a file upload is + :class:`discord.ui.FileUpload` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + min_values: :class:`int` + The minimum number of files that must be uploaded for this component. + Defaults to 1 and must be between 0 and 10. + max_values: :class:`int` + The maximum number of files that must be uploaded for this component. + Defaults to 1 and must be between 1 and 10. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + """ + + __slots__: Tuple[str, ...] = ( + 'custom_id', + 'min_values', + 'max_values', + 'required', + 'id', + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: FileUploadComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.min_values: int = data.get('min_values', 1) + self.max_values: int = data.get('max_values', 1) + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.file_upload]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.file_upload + + def to_dict(self) -> FileUploadComponentPayload: + payload: FileUploadComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1409,3 +1477,5 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return Container(data, state) elif data['type'] == 18: return LabelComponent(data, state) + elif data['type'] == 19: + return FileUploadComponent(data) diff --git a/discord/enums.py b/discord/enums.py index d691e6b48..260222894 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -693,6 +693,7 @@ class ComponentType(Enum): separator = 14 container = 17 label = 18 + file_upload = 19 def __int__(self) -> int: return self.value diff --git a/discord/types/components.py b/discord/types/components.py index bb75a918f..5522da38a 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -192,7 +192,15 @@ class LabelComponent(ComponentBase): type: Literal[18] label: str description: NotRequired[str] - component: Union[StringSelectComponent, TextInput] + component: Union[SelectMenu, TextInput, FileUploadComponent] + + +class FileUploadComponent(ComponentBase): + type: Literal[19] + custom_id: str + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] @@ -207,4 +215,4 @@ ContainerChildComponent = Union[ SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent] +Component = Union[ActionRowChildComponent, LabelComponent, FileUploadComponent, ContainerChildComponent] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index dfdb9a0dd..6e6d9ef39 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -217,7 +217,15 @@ class ModalSubmitSelectInteractionData(ComponentBase): values: List[str] -ModalSubmitComponentItemInteractionData = Union[ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData] +class ModalSubmitFileUploadInteractionData(ComponentBase): + type: Literal[19] + custom_id: str + values: List[str] + + +ModalSubmitComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData, ModalSubmitFileUploadInteractionData +] class ModalSubmitActionRowInteractionData(TypedDict): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 2ce3655ed..061c1ef60 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -25,3 +25,4 @@ from .text_display import * from .thumbnail import * from .action_row import * from .label import * +from .file_upload import * diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py new file mode 100644 index 000000000..a2b889a44 --- /dev/null +++ b/discord/ui/file_upload.py @@ -0,0 +1,199 @@ +""" +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. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import FileUploadComponent +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..message import Attachment + from ..interactions import Interaction + from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitFileUploadInteractionDataPayload + from ..types.components import FileUploadComponent as FileUploadComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'FileUpload', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class FileUpload(Item[V]): + """Represents a file upload component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the file upload component. + max_values: Optional[:class:`int`] + The maximum number of files that can be uploaded in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of files that must be uploaded in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'max_values', + 'min_values', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: FileUploadComponent = FileUploadComponent._raw_construct( + id=id, + custom_id=custom_id, + max_values=max_values, + min_values=min_values, + required=required, + ) + self.id = id + self._values: List[Attachment] = [] + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def values(self) -> List[Attachment]: + """List[:class:`discord.Attachment`]: The list of attachments uploaded by the user. + + You can call :meth:`~discord.Attachment.to_file` on each attachment + to get a :class:`~discord.File` for sending. + """ + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of files that must be user upload before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of files that the user must upload before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> FileUploadComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: FileUploadComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitFileUploadInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = [v for k, v in resolved.items() if k.id in data.get('values', [])] + + @classmethod + def from_component(cls, component: FileUploadComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + max_values=component.max_values, + min_values=component.min_values, + required=component.required, + ) + return self + + @property + def type(self) -> Literal[ComponentType.file_upload]: + return self._underlying.type + + def is_dispatchable(self) -> bool: + return False diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index b2098128b..107e4e2e4 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -193,6 +193,16 @@ Container :inherited-members: +FileUploadComponent +~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: FileUploadComponent + +.. autoclass:: FileUploadComponent() + :members: + :inherited-members: + + AppCommand ~~~~~~~~~~~ @@ -479,6 +489,12 @@ Enumerations .. versionadded:: 2.6 + .. attribute:: file_upload + + Represents a file upload component, usually in a modal. + + .. versionadded:: 2.7 + .. class:: ButtonStyle Represents the style of the button component. @@ -855,6 +871,17 @@ ActionRow :inherited-members: :exclude-members: callback + +FileUpload +~~~~~~~~~~~ + +.. attributetable:: discord.ui.FileUpload + +.. autoclass:: discord.ui.FileUpload + :members: + :inherited-members: + :exclude-members: callback, interaction_check + .. _discord_app_commands: Application Commands diff --git a/examples/modals/report.py b/examples/modals/report.py new file mode 100644 index 000000000..9e027a8c1 --- /dev/null +++ b/examples/modals/report.py @@ -0,0 +1,143 @@ +import discord +from discord import app_commands + +import traceback + +# The guild in which this slash command will be registered. +# It is recommended to have a test guild to separate from your "production" bot +TEST_GUILD = discord.Object(0) +# The ID of the channel where reports will be sent to +REPORTS_CHANNEL_ID = 0 + + +class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + + def __init__(self) -> None: + # Just default intents and a `discord.Client` instance + # We don't need a `commands.Bot` instance because we are not + # creating text-based commands. + intents = discord.Intents.default() + super().__init__(intents=intents) + + # We need an `discord.app_commands.CommandTree` instance + # to register application commands (slash commands in this case) + self.tree = app_commands.CommandTree(self) + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + async def setup_hook(self) -> None: + await self.tree.sync(guild=TEST_GUILD) + + +# Define a modal dialog for reporting issues or feedback +class ReportModal(discord.ui.Modal, title='Your Report'): + topic = discord.ui.Label( + text='Topic', + description='Select the topic of the report.', + component=discord.ui.Select( + placeholder='Choose a topic...', + options=[ + discord.SelectOption(label='Bug', description='Report a bug in the bot'), + discord.SelectOption(label='Feedback', description='Provide feedback or suggestions'), + discord.SelectOption(label='Feature Request', description='Request a new feature'), + discord.SelectOption(label='Performance', description='Report performance issues'), + discord.SelectOption(label='UI/UX', description='Report user interface or experience issues'), + discord.SelectOption(label='Security', description='Report security vulnerabilities'), + discord.SelectOption(label='Other', description='Other types of reports'), + ], + ), + ) + report_title = discord.ui.Label( + text='Title', + description='A short title for the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.short, + placeholder='The bot does not respond to commands', + max_length=120, + ), + ) + description = discord.ui.Label( + text='Description', + description='A detailed description of the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.paragraph, + placeholder='When I use /ping, the bot does not respond at all. There are no error messages.', + max_length=2000, + ), + ) + images = discord.ui.Label( + text='Images', + description='Upload any relevant images for your report (optional).', + component=discord.ui.FileUpload( + max_values=10, + custom_id='report_images', + required=False, + ), + ) + footer = discord.ui.TextDisplay( + 'Please ensure your report follows the server rules. Any kind of abuse will result in a ban.' + ) + + def to_view(self, interaction: discord.Interaction) -> discord.ui.LayoutView: + # Tell the type checker what our components are... + assert isinstance(self.topic.component, discord.ui.Select) + assert isinstance(self.description.component, discord.ui.TextInput) + assert isinstance(self.report_title.component, discord.ui.TextInput) + assert isinstance(self.images.component, discord.ui.FileUpload) + + topic = self.topic.component.values[0] + title = self.report_title.component.value + description = self.description.component.value + files = self.images.component.values + + view = discord.ui.LayoutView() + container = discord.ui.Container() + view.add_item(container) + + container.add_item(discord.ui.TextDisplay(f'-# User Report\n## {topic}')) + + timestamp = discord.utils.format_dt(interaction.created_at, 'F') + footer = discord.ui.TextDisplay(f'-# Reported by {interaction.user} (ID: {interaction.user.id}) | {timestamp}') + + container.add_item(discord.ui.TextDisplay(f'### {title}')) + container.add_item(discord.ui.TextDisplay(f'>>> {description}')) + + if files: + gallery = discord.ui.MediaGallery() + gallery.items = [discord.MediaGalleryItem(media=attachment.url) for attachment in files] + container.add_item(gallery) + + container.add_item(footer) + return view + + async def on_submit(self, interaction: discord.Interaction[MyClient]): + view = self.to_view(interaction) + + # Send the report to the designated channel + reports_channel = interaction.client.get_partial_messageable(REPORTS_CHANNEL_ID) + await reports_channel.send(view=view) + await interaction.response.send_message('Thank you for your report! We will look into it shortly.', ephemeral=True) + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True) + + # Make sure we know what the error actually is + traceback.print_exception(type(error), error, error.__traceback__) + + +client = MyClient() + + +@client.tree.command(guild=TEST_GUILD, description='Report an issue or provide feedback.') +async def report(interaction: discord.Interaction): + # Send the modal with an instance of our `ReportModal` class + # Since modals require an interaction, they cannot be done as a response to a text command. + # They can only be done as a response to either an application command or a button press. + await interaction.response.send_modal(ReportModal()) + + +client.run('token') From 178ea664b2fb2e391a1dad7e764db375112b82d2 Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:24:44 +0200 Subject: [PATCH 12/88] Add missing required kwarg to all select classes --- discord/ui/select.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/discord/ui/select.py b/discord/ui/select.py index 202288feb..7668619c6 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -598,6 +598,10 @@ class UserSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -629,6 +633,7 @@ class UserSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -640,6 +645,7 @@ class UserSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -700,6 +706,10 @@ class RoleSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the roles that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -731,6 +741,7 @@ class RoleSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -742,6 +753,7 @@ class RoleSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -797,6 +809,10 @@ class MentionableSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users/roles that should be selected by default. if :class:`.Object` is passed, then the type must be specified in the constructor. @@ -829,6 +845,7 @@ class MentionableSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -840,6 +857,7 @@ class MentionableSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -902,6 +920,10 @@ class ChannelSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the channels that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -937,6 +959,7 @@ class ChannelSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -948,6 +971,7 @@ class ChannelSelect(BaseSelect[V]): min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), From d78636283b7e240803fb050080f867109851320e Mon Sep 17 00:00:00 2001 From: Soheab_ <33902984+Soheab@users.noreply.github.com> Date: Sun, 21 Sep 2025 16:28:32 +0200 Subject: [PATCH 13/88] Correct supported types in docs for modal components --- discord/components.py | 1 + discord/ui/label.py | 7 ++++--- discord/ui/text_display.py | 3 ++- discord/ui/text_input.py | 2 ++ 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/discord/components.py b/discord/components.py index c6b5974de..06caf24f2 100644 --- a/discord/components.py +++ b/discord/components.py @@ -133,6 +133,7 @@ class Component: - :class:`FileComponent` - :class:`SeparatorComponent` - :class:`Container` + - :class:`LabelComponent` - :class:`FileUploadComponent` This class is abstract and cannot be instantiated. diff --git a/discord/ui/label.py b/discord/ui/label.py index 7a2d496a6..cb93cd0d1 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -50,6 +50,8 @@ V = TypeVar('V', bound='BaseView', covariant=True) class Label(Item[V]): """Represents a UI label within a modal. + This is a top-level layout component that can only be used on :class:`Modal`. + .. versionadded:: 2.6 Parameters @@ -60,7 +62,7 @@ class Label(Item[V]): description: Optional[:class:`str`] The description text to display right below the label text. Can only be up to 100 characters. - component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`] + component: :class:`Item` The component to display below the label. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -74,8 +76,7 @@ class Label(Item[V]): The description text to display right below the label text. Can only be up to 100 characters. component: :class:`Item` - The component to display below the label. Currently only - supports :class:`TextInput` and :class:`Select`. + The component to display below the label. """ __item_repr_attributes__: Tuple[str, ...] = ( diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index b6f908748..4abff1a18 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -43,7 +43,8 @@ __all__ = ('TextDisplay',) class TextDisplay(Item[V]): """Represents a UI text display. - This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`. + This is a top-level layout component that can only be used on :class:`LayoutView`, + :class:`Section`, :class:`Container`, or :class:`Modal`. .. versionadded:: 2.6 diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index e93a710ca..09342dfc5 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -53,6 +53,8 @@ V = TypeVar('V', bound='BaseView', covariant=True) class TextInput(Item[V]): """Represents a UI text input. + This a top-level layout component that can only be used in :class:`Label`. + .. container:: operations .. describe:: str(x) From 8f90b7d534ec39a366fb9ef6d569c799ec840d80 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 28 Sep 2025 16:07:30 -0400 Subject: [PATCH 14/88] Add shoutout to Button and Thumbnail accessories for Section --- discord/ui/section.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/section.py b/discord/ui/section.py index 67d35e001..209c030e9 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -55,7 +55,7 @@ class Section(Item[V]): \*children: Union[:class:`str`, :class:`TextDisplay`] The text displays of this section. Up to 3. accessory: :class:`Item` - The section accessory. + The section accessory. This is usually either a :class:`Button` or :class:`Thumbnail`. id: Optional[:class:`int`] The ID of this component. This must be unique across the view. """ From c7575d9f21050e0cae99bdc8c7dc240edf036bd1 Mon Sep 17 00:00:00 2001 From: Sacul Date: Thu, 2 Oct 2025 11:43:44 +0800 Subject: [PATCH 15/88] Fix docstrings for version added in Member.edit --- discord/member.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/discord/member.py b/discord/member.py index 69ebada01..ad3c300af 100644 --- a/discord/member.py +++ b/discord/member.py @@ -890,15 +890,18 @@ class Member(discord.abc.Messageable, _UserTag): A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no avatar. Only image formats supported for uploading are JPEG, PNG, GIF, and WEBP. This can only be set when editing the bot's own member. + .. versionadded:: 2.7 banner: Optional[:class:`bytes`] A :term:`py:bytes-like object` representing the image to upload. Could be ``None`` to denote no banner. Only image formats supported for uploading are JPEG, PNG, GIF and WEBP.. This can only be set when editing the bot's own member. + .. versionadded:: 2.7 bio: Optional[:class:`str`] The new bio for the member. Use ``None`` to remove the bio. This can only be set when editing the bot's own member. + .. versionadded:: 2.7 reason: Optional[:class:`str`] From 651699fcd2b3cf7b00faa7cc1bbd5a689e2dd41b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 5 Oct 2025 02:45:20 -0400 Subject: [PATCH 16/88] Remove buggy and unintentional Container.children setter --- discord/ui/container.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/discord/ui/container.py b/discord/ui/container.py index 43f3ec1ee..3025e17f8 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -208,10 +208,6 @@ class Container(Item[V]): """List[:class:`Item`]: The children of this container.""" return self._children.copy() - @children.setter - def children(self, value: List[Item[V]]) -> None: - self._children = value - @property def accent_colour(self) -> Optional[Union[Colour, int]]: """Optional[Union[:class:`discord.Colour`, :class:`int`]]: The colour of the container, or ``None``.""" From 88294fe352ac3ae62880fc2210b6dc906faed3b4 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 8 Oct 2025 08:52:11 -0400 Subject: [PATCH 17/88] Fix Section.accessory setter not updating view bindings --- discord/ui/section.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/ui/section.py b/discord/ui/section.py index 209c030e9..ff687e0be 100644 --- a/discord/ui/section.py +++ b/discord/ui/section.py @@ -117,6 +117,7 @@ class Section(Item[V]): if not isinstance(value, Item): raise TypeError(f'Expected an Item, got {value.__class__.__name__!r} instead') + value._update_view(self.view) value._parent = self self._accessory = value From e2b6fa8370e38dd13b6b7073e24c12cc8eec7525 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 8 Oct 2025 17:31:24 -0400 Subject: [PATCH 18/88] Use obj reference target instead of data for typing objects --- discord/ext/commands/errors.py | 2 +- docs/ext/commands/commands.rst | 26 +++++++++++++------------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index a962a4e73..97841ec6a 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -870,7 +870,7 @@ class BotMissingPermissions(CheckFailure): class BadUnionArgument(UserInputError): - """Exception raised when a :data:`typing.Union` converter fails for all + """Exception raised when a :obj:`typing.Union` converter fails for all its associated types. This inherits from :exc:`UserInputError` diff --git a/docs/ext/commands/commands.rst b/docs/ext/commands/commands.rst index 52e57ff4d..c6ef75b30 100644 --- a/docs/ext/commands/commands.rst +++ b/docs/ext/commands/commands.rst @@ -485,7 +485,7 @@ commands in an easy to use manner. typing.Union ^^^^^^^^^^^^^^ -A :data:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of +A :obj:`typing.Union` is a special type hint that allows for the command to take in any of the specific types instead of a singular type. For example, given the following: .. code-block:: python3 @@ -502,12 +502,12 @@ The way this works is through a left-to-right order. It first attempts to conver :class:`discord.TextChannel`, and if it fails it tries to convert it to a :class:`discord.Member`. If all converters fail, then a special error is raised, :exc:`~ext.commands.BadUnionArgument`. -Note that any valid converter discussed above can be passed in to the argument list of a :data:`typing.Union`. +Note that any valid converter discussed above can be passed in to the argument list of a :obj:`typing.Union`. typing.Optional ^^^^^^^^^^^^^^^^^ -A :data:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to +A :obj:`typing.Optional` is a special type hint that allows for "back-referencing" behaviour. If the converter fails to parse into the specified type, the parser will skip the parameter and then either ``None`` or the specified default will be passed into the parameter instead. The parser will then continue on to the next parameters and converters, if any. @@ -536,7 +536,7 @@ typing.Literal .. versionadded:: 2.0 -A :data:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values +A :obj:`typing.Literal` is a special type hint that requires the passed parameter to be equal to one of the listed values after being converted to the same type. For example, given the following: .. code-block:: python3 @@ -550,7 +550,7 @@ after being converted to the same type. For example, given the following: The ``buy_sell`` parameter must be either the literal string ``"buy"`` or ``"sell"`` and ``amount`` must convert to the ``int`` ``1`` or ``2``. If ``buy_sell`` or ``amount`` don't match any value, then a special error is raised, -:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :data:`typing.Literal` converter. +:exc:`~.ext.commands.BadLiteralArgument`. Any literal values can be mixed and matched within the same :obj:`typing.Literal` converter. Note that ``typing.Literal[True]`` and ``typing.Literal[False]`` still follow the :class:`bool` converter rules. @@ -559,7 +559,7 @@ typing.Annotated .. versionadded:: 2.0 -A :data:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use. +A :obj:`typing.Annotated` is a special type introduced in Python 3.9 that allows the type checker to see one type, but allows the library to see another type. This is useful for appeasing the type checker for complicated converters. The second parameter of ``Annotated`` must be the converter that the library should use. For example, given the following: @@ -581,7 +581,7 @@ The type checker will see ``arg`` as a regular :class:`str` but the library will Greedy ^^^^^^^^ -The :class:`~ext.commands.Greedy` converter is a generalisation of the :data:`typing.Optional` converter, except applied +The :class:`~ext.commands.Greedy` converter is a generalisation of the :obj:`typing.Optional` converter, except applied to a list of arguments. In simple terms, this means that it tries to convert as much as it can until it can't convert any further. @@ -606,7 +606,7 @@ The type passed when using this converter depends on the parameter type that it :class:`~ext.commands.Greedy` parameters can also be made optional by specifying an optional value. -When mixed with the :data:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes: +When mixed with the :obj:`typing.Optional` converter you can provide simple and expressive command invocation syntaxes: .. code-block:: python3 @@ -632,16 +632,16 @@ This command can be invoked any of the following ways: .. warning:: - The usage of :class:`~ext.commands.Greedy` and :data:`typing.Optional` are powerful and useful, however as a + The usage of :class:`~ext.commands.Greedy` and :obj:`typing.Optional` are powerful and useful, however as a price, they open you up to some parsing ambiguities that might surprise some people. - For example, a signature expecting a :data:`typing.Optional` of a :class:`discord.Member` followed by a + For example, a signature expecting a :obj:`typing.Optional` of a :class:`discord.Member` followed by a :class:`int` could catch a member named after a number due to the different ways a :class:`~ext.commands.MemberConverter` decides to fetch members. You should take care to not introduce unintended parsing ambiguities in your code. One technique would be to clamp down the expected syntaxes allowed through custom converters or reordering the parameters to minimise clashes. - To help aid with some parsing ambiguities, :class:`str`, ``None``, :data:`typing.Optional` and + To help aid with some parsing ambiguities, :class:`str`, ``None``, :obj:`typing.Optional` and :class:`~ext.commands.Greedy` are forbidden as parameters for the :class:`~ext.commands.Greedy` converter. @@ -663,7 +663,7 @@ Consider the following example: await ctx.send(f'You have uploaded <{attachment.url}>') -When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :data:`typing.Optional` converter, the user does not have to provide an attachment. +When this command is invoked, the user must directly upload a file for the command body to be executed. When combined with the :obj:`typing.Optional` converter, the user does not have to provide an attachment. .. code-block:: python3 @@ -809,7 +809,7 @@ In order to customise the flag syntax we also have a few options that can be pas topic: Optional[str] nsfw: Optional[bool] slowmode: Optional[int] - + # Hello there --bold True class Greeting(commands.FlagConverter): text: str = commands.flag(positional=True) From 463becb7ed62ea3924a9af1da0e78be0f886ad8c Mon Sep 17 00:00:00 2001 From: beerpsi <92439990+beer-psi@users.noreply.github.com> Date: Thu, 9 Oct 2025 04:33:29 +0700 Subject: [PATCH 19/88] Use compression.zstd for gateway compression on Python 3.14 --- discord/utils.py | 21 ++++++++++++++++++++- pyproject.toml | 2 +- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/discord/utils.py b/discord/utils.py index dc90c8b95..a4aa2835a 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -71,10 +71,14 @@ import types import typing import warnings import logging -import zlib import yarl +if sys.version_info >= (3, 14): + import compression.zstd +else: + import zlib + try: import orjson # type: ignore except ModuleNotFoundError: @@ -1437,6 +1441,21 @@ if _HAS_ZSTD: # Each WS message is a complete gateway message return self.context.decompress(data).decode('utf-8') + _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext +elif sys.version_info >= (3, 14): + + class _ZstdDecompressionContext: + __slots__ = ('context',) + + COMPRESSION_TYPE: str = 'zstd-stream' + + def __init__(self) -> None: + self.context = compression.zstd.ZstdDecompressor() + + def decompress(self, data: bytes, /) -> str | None: + # Each WS message is a complete gateway message + return self.context.decompress(data).decode('utf-8') + _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext else: diff --git a/pyproject.toml b/pyproject.toml index 20d117b01..d32ed9a29 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ speed = [ "aiodns>=1.1; sys_platform != 'win32'", "Brotli", "cchardet==2.1.7; python_version < '3.10'", - "zstandard>=0.23.0" + "zstandard>=0.23.0; python_version <= '3.13'" ] test = [ "coverage[toml]", From be3e3322514ca43c6e93a1f3128cef31275501bd Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 8 Oct 2025 17:41:50 -0400 Subject: [PATCH 20/88] Add v2.6.4 changelog --- docs/whats_new.rst | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index c78c44435..33b6ec090 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,26 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p6p4: + +v2.6.4 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix :class:`InviteType` and :class:`ReactionType` not being exported (:issue:`10310`) +- Fix :class:`ui.Modal` submits not working for components without a ``custom_id`` (:issue:`10307`) +- Fix ``required`` keyword argument missing in most :class:`ui.Select` classes (:issue:`10307`) +- Fix incorrect handling of :class:`ui.Modal` submit data when using selects (:issue:`10307`) +- Fix potential exception when assigning :attr:`ui.Container.children` +- Fix :attr:`ui.Section.accessory` setter not updating internal state leading to an exception + +Miscellaneous +~~~~~~~~~~~~~~ + +- Use ``compression.zstd`` from the standard library if available on Python 3.14 (:issue:`10323`) + .. _vp2p6p3: v2.6.3 From 6d19bc763c540a36b83429f8e9c6bb3dd8e16c7d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 14 Oct 2025 17:16:25 -0400 Subject: [PATCH 21/88] [commands] Fix flag annotations not working under 3.14 --- discord/ext/commands/flags.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index 0b03b81d4..09d925232 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -178,6 +178,14 @@ def validate_flag_name(name: str, forbidden: Set[str]) -> None: def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]: annotations = namespace.get('__annotations__', {}) + if '__annotate__' in namespace: + # In Python 3.14, classes no longer get `__annotations__` and instead a function + # under __annotate__ is used instead that that takes a format argument on how to + # receive those annotations. + # Format 1 is full value, Format 3 is value and ForwardRef for undefined ones + # So format 3 is the one we're typically used to + annotations = namespace['__annotate__'](3) + case_insensitive = namespace['__commands_flag_case_insensitive__'] flags: Dict[str, Flag] = {} cache: Dict[str, Any] = {} From 9c327df45a75ee09cd5680aaa6c41406a16eba97 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 14 Oct 2025 17:20:32 -0400 Subject: [PATCH 22/88] Use webp as the default emoji URL format Fix #10331 --- discord/emoji.py | 2 +- discord/partial_emoji.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/emoji.py b/discord/emoji.py index 74f344acc..de6b47b69 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -165,7 +165,7 @@ class Emoji(_EmojiTag, AssetMixin): @property def url(self) -> str: """:class:`str`: Returns the URL of the emoji.""" - fmt = 'gif' if self.animated else 'png' + fmt = 'webp' if self.animated else 'png' return f'{Asset.BASE}/emojis/{self.id}.{fmt}' @property diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index 502202330..bbfbc099d 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -245,7 +245,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): if self.is_unicode_emoji(): return '' - fmt = 'gif' if self.animated else 'png' + fmt = 'webp' if self.animated else 'png' return f'{Asset.BASE}/emojis/{self.id}.{fmt}' async def read(self) -> bytes: From c58b973c7ea84105a95af999b1f0666ba68d3895 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 14 Oct 2025 17:33:49 -0400 Subject: [PATCH 23/88] Add animated=true parameter to animated emoji --- discord/emoji.py | 4 ++-- discord/partial_emoji.py | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/discord/emoji.py b/discord/emoji.py index de6b47b69..efea38c75 100644 --- a/discord/emoji.py +++ b/discord/emoji.py @@ -165,8 +165,8 @@ class Emoji(_EmojiTag, AssetMixin): @property def url(self) -> str: """:class:`str`: Returns the URL of the emoji.""" - fmt = 'webp' if self.animated else 'png' - return f'{Asset.BASE}/emojis/{self.id}.{fmt}' + end = 'webp?animated=true' if self.animated else 'png' + return f'{Asset.BASE}/emojis/{self.id}.{end}' @property def roles(self) -> List[Role]: diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index bbfbc099d..d028244c0 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -245,8 +245,8 @@ class PartialEmoji(_EmojiTag, AssetMixin): if self.is_unicode_emoji(): return '' - fmt = 'webp' if self.animated else 'png' - return f'{Asset.BASE}/emojis/{self.id}.{fmt}' + end = 'webp?animated=true' if self.animated else 'png' + return f'{Asset.BASE}/emojis/{self.id}.{end}' async def read(self) -> bytes: """|coro| From ab8195bbd3ed44a47bb392d65a69718a5089c58e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 16 Oct 2025 02:23:49 -0400 Subject: [PATCH 24/88] Add support for role member counts --- discord/guild.py | 33 +++++++++++++++++++++++++++++++++ discord/http.py | 3 +++ 2 files changed, 36 insertions(+) diff --git a/discord/guild.py b/discord/guild.py index 47a8b57c0..450eccc4c 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -3872,6 +3872,39 @@ class Guild(Hashable): return roles + async def role_member_counts(self) -> Dict[Union[Object, Role], int]: + """|coro| + + Retrieves a mapping of roles to the number of members that have it. + + You must have :attr:`~Permissions.manage_roles` to do this. + + .. versionadded:: 2.7 + + Raises + ------- + Forbidden + You do not have permissions to view the role member counts. + HTTPException + Retrieving the role member counts failed. + + Returns + -------- + Dict[Union[:class:`Object`, :class:`Role`], :class:`int`] + A mapping of roles to the number of members that have it. + If a role is not found in the cache, it will be represented as an :class:`Object` + instead of a :class:`Role`. + """ + data = await self._state.http.get_role_member_counts(self.id) + result: Dict[Union[Object, Role], int] = {} + for role_id, member_count in data.items(): + role_id = int(role_id) + role = self.get_role(role_id) + if role is None: + role = Object(id=role_id, type=Role) + result[role] = member_count + return result + async def welcome_screen(self) -> WelcomeScreen: """|coro| diff --git a/discord/http.py b/discord/http.py index acf3835f6..1e09607e8 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1906,6 +1906,9 @@ class HTTPClient: def get_role(self, guild_id: Snowflake, role_id: Snowflake) -> Response[role.Role]: return self.request(Route('GET', '/guilds/{guild_id}/roles/{role_id}', guild_id=guild_id, role_id=role_id)) + def get_role_member_counts(self, guild_id: Snowflake) -> Response[Dict[str, int]]: + return self.request(Route('GET', '/guilds/{guild_id}/roles/member-counts', guild_id=guild_id)) + def edit_role( self, guild_id: Snowflake, role_id: Snowflake, *, reason: Optional[str] = None, **fields: Any ) -> Response[role.Role]: From 055885100dba865679f5fbf6580b6820d095b5fb Mon Sep 17 00:00:00 2001 From: Sacul <183588943+Sacul0457@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:49:25 +0800 Subject: [PATCH 25/88] Fix default value for reason parameter in delete_invite --- discord/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 0b6e4fa4b..88c390be0 100644 --- a/discord/client.py +++ b/discord/client.py @@ -2511,7 +2511,7 @@ class Client: ) return Invite.from_incomplete(state=self._connection, data=data) - async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str]) -> Invite: + async def delete_invite(self, invite: Union[Invite, str], /, *, reason: Optional[str] = None) -> Invite: """|coro| Revokes an :class:`.Invite`, URL, or ID to an invite. From 62cb74b7d8fe546fecddf3292750b42d59accdf6 Mon Sep 17 00:00:00 2001 From: beerpsi <92439990+beer-psi@users.noreply.github.com> Date: Tue, 28 Oct 2025 18:50:33 +0700 Subject: [PATCH 26/88] Do not assume Python 3.14 has compression.zstd --- discord/utils.py | 42 ++++++++++++++---------------------------- 1 file changed, 14 insertions(+), 28 deletions(-) diff --git a/discord/utils.py b/discord/utils.py index a4aa2835a..986d166d3 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -74,11 +74,6 @@ import logging import yarl -if sys.version_info >= (3, 14): - import compression.zstd -else: - import zlib - try: import orjson # type: ignore except ModuleNotFoundError: @@ -87,11 +82,18 @@ else: HAS_ORJSON = True try: - import zstandard # type: ignore -except ImportError: - _HAS_ZSTD = False -else: + from zstandard import ZstdDecompressor # type: ignore + _HAS_ZSTD = True +except ImportError: + try: + from compression.zstd import ZstdDecompressor # type: ignore + except ImportError: + import zlib + + _HAS_ZSTD = False + else: + _HAS_ZSTD = True __all__ = ( 'oauth_url', @@ -1429,32 +1431,16 @@ def _human_join(seq: Sequence[str], /, *, delimiter: str = ', ', final: str = 'o if _HAS_ZSTD: class _ZstdDecompressionContext: - __slots__ = ('context',) - - COMPRESSION_TYPE: str = 'zstd-stream' - - def __init__(self) -> None: - decompressor = zstandard.ZstdDecompressor() - self.context = decompressor.decompressobj() - - def decompress(self, data: bytes, /) -> str | None: - # Each WS message is a complete gateway message - return self.context.decompress(data).decode('utf-8') - - _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext -elif sys.version_info >= (3, 14): - - class _ZstdDecompressionContext: - __slots__ = ('context',) + __slots__ = ('decompressor',) COMPRESSION_TYPE: str = 'zstd-stream' def __init__(self) -> None: - self.context = compression.zstd.ZstdDecompressor() + self.decompressor = ZstdDecompressor() def decompress(self, data: bytes, /) -> str | None: # Each WS message is a complete gateway message - return self.context.decompress(data).decode('utf-8') + return self.decompressor.decompress(data).decode('utf-8') _ActiveDecompressionContext: Type[_DecompressionContext] = _ZstdDecompressionContext else: From 09f748d48f7aea533436e8a63dfba39c6fc22a2c Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Tue, 28 Oct 2025 12:50:55 +0100 Subject: [PATCH 27/88] Add uv.lock and pylock.toml to gitignore --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 0f7db0f40..62782dbcf 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,5 @@ docs/crowdin.py *.mo /.coverage build/* +uv.lock* +pylock*.toml \ No newline at end of file From 8f2cb6070026bd2be2fd1fe35f978b408490879b Mon Sep 17 00:00:00 2001 From: Sacul <183588943+Sacul0457@users.noreply.github.com> Date: Tue, 28 Oct 2025 19:51:15 +0800 Subject: [PATCH 28/88] Fix inaccurate total_children_count property in modals --- discord/ui/label.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/discord/ui/label.py b/discord/ui/label.py index cb93cd0d1..f9313f53f 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -139,3 +139,8 @@ class Label(Item[V]): def is_dispatchable(self) -> bool: return False + + @property + def _total_count(self) -> int: + # Count the component and ourselves + return 2 From e2cf721e9ce0857c698fed17e7d890abe39f8c54 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sat, 8 Nov 2025 00:53:04 +0100 Subject: [PATCH 29/88] Fix container items having out of date internal state --- discord/ui/action_row.py | 41 ++++++++++++++++++++++------------------ discord/ui/button.py | 5 ++++- discord/ui/container.py | 27 +++++++++++--------------- discord/ui/item.py | 15 +++++++++++++++ discord/ui/select.py | 13 +++++++++++-- discord/ui/view.py | 21 ++++---------------- 6 files changed, 68 insertions(+), 54 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index c7f7a2b7b..38fc3daaa 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -24,12 +24,12 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import copy from typing import ( TYPE_CHECKING, Any, Callable, ClassVar, - Coroutine, Dict, Generator, List, @@ -42,7 +42,7 @@ from typing import ( overload, ) -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from .button import Button, button as _button from .select import select as _select, Select, UserSelect, RoleSelect, ChannelSelect, MentionableSelect from ..components import ActionRow as ActionRowComponent @@ -65,7 +65,6 @@ if TYPE_CHECKING: ) from ..emoji import Emoji from ..components import SelectOption - from ..interactions import Interaction from .container import Container from .dynamic import DynamicItem @@ -77,18 +76,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('ActionRow',) -class _ActionRowCallback: - __slots__ = ('row', 'callback', 'item') - - def __init__(self, callback: ItemCallbackType[S, Any], row: ActionRow, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback - self.row: ActionRow = row - self.item: Item[Any] = item - - def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: - return self.callback(self.row, interaction, self.item) - - class ActionRow(Item[V]): r"""Represents a UI action row. @@ -143,8 +130,9 @@ class ActionRow(Item[V]): ) -> None: super().__init__() self._children: List[Item[V]] = self._init_children() - self._children.extend(children) self._weight: int = sum(i.width for i in self._children) + for child in children: + self.add_item(child) if self._weight > 5: raise ValueError('maximum number of children exceeded') @@ -173,8 +161,8 @@ class ActionRow(Item[V]): for func in self.__action_row_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = _ActionRowCallback(func, self, item) # type: ignore - item._parent = getattr(func, '__discord_ui_parent__', self) + item.callback = _ItemCallback(func, self, item) # type: ignore + item._parent = self setattr(self, func.__name__, item) children.append(item) return children @@ -184,6 +172,23 @@ class ActionRow(Item[V]): for child in self._children: child._view = view + def copy(self) -> ActionRow[V]: + new = copy.copy(self) + children = [] + for child in new._children: + newch = child.copy() + newch._parent = new + if isinstance(newch.callback, _ItemCallback): + newch.callback.parent = new + children.append(newch) + new._children = children + new._parent = self._parent + new._update_view(self.view) + return new + + def __deepcopy__(self, memo) -> ActionRow[V]: + return self.copy() + def _has_children(self): return True diff --git a/discord/ui/button.py b/discord/ui/button.py index f80065963..4c1e4cc89 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -30,7 +30,7 @@ import inspect import os -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent @@ -304,6 +304,9 @@ class Button(Item[V]): sku_id=self.sku_id, id=self.id, ) + if isinstance(new.callback, _ItemCallback): + new.callback.item = new + new._update_view(self.view) return new def __deepcopy__(self, memo) -> Self: diff --git a/discord/ui/container.py b/discord/ui/container.py index 3025e17f8..600b0687b 100644 --- a/discord/ui/container.py +++ b/discord/ui/container.py @@ -29,7 +29,6 @@ from typing import ( TYPE_CHECKING, Any, ClassVar, - Coroutine, Dict, Generator, List, @@ -39,7 +38,7 @@ from typing import ( Union, ) -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from .view import _component_to_item, LayoutView from ..enums import ComponentType from ..utils import get as _utils_get @@ -49,7 +48,6 @@ if TYPE_CHECKING: from typing_extensions import Self from ..components import Container as ContainerComponent - from ..interactions import Interaction from .dynamic import DynamicItem S = TypeVar('S', bound='Container', covariant=True) @@ -58,18 +56,6 @@ V = TypeVar('V', bound='LayoutView', covariant=True) __all__ = ('Container',) -class _ContainerCallback: - __slots__ = ('container', 'callback', 'item') - - def __init__(self, callback: ItemCallbackType[S, Any], container: Container, item: Item[Any]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback - self.container: Container = container - self.item: Item[Any] = item - - def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: - return self.callback(self.container, interaction, self.item) - - class Container(Item[V]): r"""Represents a UI container. @@ -163,7 +149,7 @@ class Container(Item[V]): # action rows can be created inside containers, and then callbacks can exist here # so we create items based off them item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) - item.callback = _ContainerCallback(raw, self, item) # type: ignore + item.callback = _ItemCallback(raw, self, item) # type: ignore setattr(self, raw.__name__, item) # this should not fail because in order for a function to be here it should be from # an action row and must have passed the check in __init_subclass__, but still @@ -196,6 +182,15 @@ class Container(Item[V]): child._update_view(view) return True + def copy(self) -> Container[V]: + new = copy.deepcopy(self) + for child in new._children: + newch = child.copy() + newch._parent = new + new._parent = self._parent + new._update_view(self.view) + return new + def _has_children(self): return True diff --git a/discord/ui/item.py b/discord/ui/item.py index 8f716559c..4c0dd6110 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -55,6 +55,21 @@ ItemCallbackType = Callable[[V, Interaction[Any], I], Coroutine[Any, Any, Any]] ContainedItemCallbackType = Callable[[C, Interaction[Any], I], Coroutine[Any, Any, Any]] +class _ItemCallback: + __slots__ = ('parent', 'callback', 'item') + + def __init__(self, callback: ContainedItemCallbackType[Any, Any], parent: Any, item: Item[Any]) -> None: + self.callback: ItemCallbackType[Any, Any] = callback + self.parent: Any = parent + self.item: Item[Any] = item + + def __repr__(self) -> str: + return f'' + + def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: + return self.callback(self.parent, interaction, self.item) + + class Item(Generic[V]): """Represents the base UI item that all UI components inherit from. diff --git a/discord/ui/select.py b/discord/ui/select.py index 7668619c6..b003f8fcb 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -39,10 +39,11 @@ from typing import ( Sequence, ) from contextvars import ContextVar +import copy import inspect import os -from .item import Item, ContainedItemCallbackType as ItemCallbackType +from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji @@ -70,7 +71,7 @@ __all__ = ( ) if TYPE_CHECKING: - from typing_extensions import TypeAlias, TypeGuard + from typing_extensions import TypeAlias, TypeGuard, Self from .view import BaseView from .action_row import ActionRow @@ -269,6 +270,14 @@ class BaseSelect(Item[V]): self.row = row self._values: List[PossibleValue] = [] + def copy(self) -> Self: + new = copy.copy(self) + if isinstance(new.callback, _ItemCallback): + new.callback.item = new + new._parent = self._parent + new._update_view(self.view) + return new + @property def id(self) -> Optional[int]: """Optional[:class:`int`]: The ID of this select.""" diff --git a/discord/ui/view.py b/discord/ui/view.py index 252a21dbb..36d95d8a3 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -28,7 +28,6 @@ from typing import ( Any, Callable, ClassVar, - Coroutine, Dict, Generator, Iterator, @@ -50,7 +49,7 @@ import sys import time import os -from .item import Item, ItemCallbackType +from .item import Item, ItemCallbackType, _ItemCallback from .select import Select from .dynamic import DynamicItem from ..components import ( @@ -207,18 +206,6 @@ class _ViewWeights: self.weights = [0, 0, 0, 0, 0] -class _ViewCallback: - __slots__ = ('view', 'callback', 'item') - - def __init__(self, callback: ItemCallbackType[Any, Any], view: BaseView, item: Item[BaseView]) -> None: - self.callback: ItemCallbackType[Any, Any] = callback - self.view: BaseView = view - self.item: Item[BaseView] = item - - def __call__(self, interaction: Interaction) -> Coroutine[Any, Any, Any]: - return self.callback(self.view, interaction, self.item) - - class BaseView: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False @@ -252,13 +239,13 @@ class BaseView: item._update_view(self) parent = getattr(item, '__discord_ui_parent__', None) if parent and parent._view is None: - parent._view = self + parent._update_view(self) children.append(item) parents[raw] = item else: item: Item = raw.__discord_ui_model_type__(**raw.__discord_ui_model_kwargs__) - item.callback = _ViewCallback(raw, self, item) # type: ignore - item._view = self + item.callback = _ItemCallback(raw, self, item) # type: ignore + item._update_view(self) if isinstance(item, Select): item.options = [option.copy() for option in item.options] setattr(self, raw.__name__, item) From 0ace5f8b51f8906d8bd99eaf17095ff4f11be4dd Mon Sep 17 00:00:00 2001 From: Sacul <183588943+Sacul0457@users.noreply.github.com> Date: Sat, 8 Nov 2025 07:56:24 +0800 Subject: [PATCH 30/88] Document new timestamp format style --- discord/utils.py | 40 ++++++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/discord/utils.py b/discord/utils.py index 986d166d3..e2bc96bb1 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -1223,7 +1223,7 @@ def is_inside_class(func: Callable[..., Any]) -> bool: return not remaining.endswith('') -TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 'R'] +TimestampStyle = Literal['f', 'F', 'd', 'D', 't', 'T', 's', 'S', 'R'] def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) -> str: @@ -1231,23 +1231,27 @@ def format_dt(dt: datetime.datetime, /, style: Optional[TimestampStyle] = None) This allows for a locale-independent way of presenting data using Discord specific Markdown. - +-------------+----------------------------+-----------------+ - | Style | Example Output | Description | - +=============+============================+=================+ - | t | 22:57 | Short Time | - +-------------+----------------------------+-----------------+ - | T | 22:57:58 | Long Time | - +-------------+----------------------------+-----------------+ - | d | 17/05/2016 | Short Date | - +-------------+----------------------------+-----------------+ - | D | 17 May 2016 | Long Date | - +-------------+----------------------------+-----------------+ - | f (default) | 17 May 2016 22:57 | Short Date Time | - +-------------+----------------------------+-----------------+ - | F | Tuesday, 17 May 2016 22:57 | Long Date Time | - +-------------+----------------------------+-----------------+ - | R | 5 years ago | Relative Time | - +-------------+----------------------------+-----------------+ + +-------------+--------------------------------+-------------------------+ + | Style | Example Output | Description | + +=============+================================+=========================+ + | t | 22:57 | Short Time | + +-------------+--------------------------------+-------------------------+ + | T | 22:57:58 | Medium Time | + +-------------+--------------------------------+-------------------------+ + | d | 17/05/2016 | Short Date | + +-------------+--------------------------------+-------------------------+ + | D | May 17, 2016 | Long Date | + +-------------+--------------------------------+-------------------------+ + | f (default) | May 17, 2016 at 22:57 | Long Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | F | Tuesday, May 17, 2016 at 22:57 | Full Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | s | 17/05/2016, 22:57 | Short Date, Short Time | + +-------------+--------------------------------+-------------------------+ + | S | 17/05/2016, 22:57:58 | Short Date, Medium Time | + +-------------+--------------------------------+-------------------------+ + | R | 5 years ago | Relative Time | + +-------------+--------------------------------+-------------------------+ Note that the exact output depends on the user's locale setting in the client. The example output presented is using the ``en-GB`` locale. From 8b154754960eda6f96b59dc8ae21e3a7a8b5c357 Mon Sep 17 00:00:00 2001 From: Michael H Date: Fri, 7 Nov 2025 18:57:01 -0500 Subject: [PATCH 31/88] [Zstandard] Decompress even when discord doesn't encode size information --- discord/utils.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/discord/utils.py b/discord/utils.py index e2bc96bb1..e015cddb0 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -81,19 +81,20 @@ except ModuleNotFoundError: else: HAS_ORJSON = True +_ZSTD_SOURCE: Literal['zstandard', 'compression.zstd'] | None = None + try: from zstandard import ZstdDecompressor # type: ignore - _HAS_ZSTD = True + _ZSTD_SOURCE = 'zstandard' except ImportError: try: from compression.zstd import ZstdDecompressor # type: ignore + + _ZSTD_SOURCE = 'compression.zstd' except ImportError: import zlib - _HAS_ZSTD = False - else: - _HAS_ZSTD = True __all__ = ( 'oauth_url', @@ -1432,7 +1433,7 @@ def _human_join(seq: Sequence[str], /, *, delimiter: str = ', ', final: str = 'o return delimiter.join(seq[:-1]) + f' {final} {seq[-1]}' -if _HAS_ZSTD: +if _ZSTD_SOURCE is not None: class _ZstdDecompressionContext: __slots__ = ('decompressor',) @@ -1441,6 +1442,12 @@ if _HAS_ZSTD: def __init__(self) -> None: self.decompressor = ZstdDecompressor() + if _ZSTD_SOURCE == 'zstandard': + # The default API for zstandard requires a size hint when + # the size is not included in the zstandard frame. + # This constructs an instance of zstandard.ZstdDecompressionObj + # which dynamically allocates a buffer, matching stdlib module's behavior. + self.decompressor = self.decompressor.decompressobj() def decompress(self, data: bytes, /) -> str | None: # Each WS message is a complete gateway message From b77459a4dfb1eade9686cefeb969b936d0ba46b5 Mon Sep 17 00:00:00 2001 From: n6ck <90283633+n6ck@users.noreply.github.com> Date: Wed, 12 Nov 2025 03:52:54 +0000 Subject: [PATCH 32/88] Change description to be optional when creating emoji --- discord/guild.py | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index 450eccc4c..f93cf175d 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -26,7 +26,6 @@ from __future__ import annotations import copy import datetime -import unicodedata from typing import ( Any, AsyncIterator, @@ -3087,7 +3086,7 @@ class Guild(Hashable): self, *, name: str, - description: str, + description: str = MISSING, emoji: str, file: File, reason: Optional[str] = None, @@ -3103,11 +3102,16 @@ class Guild(Hashable): Parameters ----------- name: :class:`str` - The sticker name. Must be at least 2 characters. + The sticker name. Must be between 2 and 30 characters. description: :class:`str` - The sticker's description. + The sticker's description. Can be an empty string or a string between 2 and 100 characters. + Defaults to an empty string if not provided. emoji: :class:`str` - The name of a unicode emoji that represents the sticker's expression. + The emoji tag associated with the sticker. This corresponds to the + ``tags`` field in Discord's API, which is used for emoji autocomplete + and suggestion purposes. For correct rendering in Discord's UI, this + should ideally be a raw Unicode emoji or the string ID + of a custom emoji. Any string up to 200 characters is accepted. file: :class:`File` The file of the sticker to upload. reason: :class:`str` @@ -3127,19 +3131,10 @@ class Guild(Hashable): """ payload = { 'name': name, + 'description': description or '', + 'tags': emoji, } - payload['description'] = description - - try: - emoji = unicodedata.name(emoji) - except TypeError: - pass - else: - emoji = emoji.replace(' ', '_') - - payload['tags'] = emoji - data = await self._state.http.create_guild_sticker(self.id, payload, file, reason) if self._state.cache_guild_expressions: return self._state.store_sticker(self, data) From 9580898c9725663d223eed65c8901c581a9043f2 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Thu, 13 Nov 2025 02:02:19 +0100 Subject: [PATCH 33/88] Detach view from item when removed --- discord/ui/view.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/discord/ui/view.py b/discord/ui/view.py index 36d95d8a3..826b6a952 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -439,6 +439,7 @@ class BaseView: pass else: self._add_count(-item._total_count) + item._update_view(None) return self @@ -448,6 +449,9 @@ class BaseView: This function returns the class instance to allow for fluent-style chaining. """ + for child in self._children: + child._update_view(None) + self._children.clear() self._total_children = 0 return self @@ -744,6 +748,8 @@ class View(BaseView): pass else: self.__weights.remove_item(item) + item._update_view(None) + return self def clear_items(self) -> Self: From c342db853486ef6e26d72b10bf901e951c4b4d8e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 12 Nov 2025 20:17:43 -0500 Subject: [PATCH 34/88] [commands] Fix flag annotations in 3.14 using annotationlib Fix #10349 --- discord/ext/commands/flags.py | 29 ++++++++++++++++++++--------- 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/discord/ext/commands/flags.py b/discord/ext/commands/flags.py index 09d925232..10d036f8a 100644 --- a/discord/ext/commands/flags.py +++ b/discord/ext/commands/flags.py @@ -50,6 +50,25 @@ if TYPE_CHECKING: from .context import Context from .parameters import Parameter +try: + from annotationlib import call_annotate_function, get_annotate_from_class_namespace # type: ignore + + def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]: + # In Python 3.14, classes no longer get `__annotations__` and instead a function + # under __annotate__ is used instead that that takes a format argument on how to + # receive those annotations. + # Format 1 is full value, Format 3 is value and ForwardRef for undefined ones + # So format 3 is the one we're typically used to + annotate = get_annotate_from_class_namespace(namespace) + if annotate is not None: + return call_annotate_function(annotate, 3) # type: ignore + return namespace.get('__annotations__', {}) + +except ImportError: + + def get_annotations_from_namespace(namespace: Dict[str, Any]) -> Dict[str, Any]: + return namespace.get('__annotations__', {}) + @dataclass class Flag: @@ -177,15 +196,7 @@ def validate_flag_name(name: str, forbidden: Set[str]) -> None: def get_flags(namespace: Dict[str, Any], globals: Dict[str, Any], locals: Dict[str, Any]) -> Dict[str, Flag]: - annotations = namespace.get('__annotations__', {}) - if '__annotate__' in namespace: - # In Python 3.14, classes no longer get `__annotations__` and instead a function - # under __annotate__ is used instead that that takes a format argument on how to - # receive those annotations. - # Format 1 is full value, Format 3 is value and ForwardRef for undefined ones - # So format 3 is the one we're typically used to - annotations = namespace['__annotate__'](3) - + annotations = get_annotations_from_namespace(namespace) case_insensitive = namespace['__commands_flag_case_insensitive__'] flags: Dict[str, Flag] = {} cache: Dict[str, Any] = {} From 9be91cb093402f54a44726c7dc4c04ff3b2c5a63 Mon Sep 17 00:00:00 2001 From: Sacul <183588943+Sacul0457@users.noreply.github.com> Date: Wed, 19 Nov 2025 08:51:27 +0800 Subject: [PATCH 35/88] Optimise utils.find and specialise utils.as_chunks --- discord/abc.py | 2 +- discord/utils.py | 27 +++++++++++++++------------ 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/discord/abc.py b/discord/abc.py index 5f06fb1ce..95ccfd67b 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -821,7 +821,7 @@ class GuildChannel: if obj.is_default(): return base - overwrite = utils.get(self._overwrites, type=_Overwrites.ROLE, id=obj.id) + overwrite = utils.find(lambda ow: ow.type == _Overwrites.ROLE and ow.id == obj.id, self._overwrites) if overwrite is not None: base.handle_overwrite(overwrite.allow, overwrite.deny) diff --git a/discord/utils.py b/discord/utils.py index e015cddb0..ce4b9e396 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -56,6 +56,8 @@ from typing import ( TYPE_CHECKING, ) import unicodedata +import collections.abc +from itertools import islice from base64 import b64encode, b64decode from bisect import bisect_left import datetime @@ -434,7 +436,7 @@ def time_snowflake(dt: datetime.datetime, /, *, high: bool = False) -> int: def _find(predicate: Callable[[T], Any], iterable: Iterable[T], /) -> Optional[T]: - return next((element for element in iterable if predicate(element)), None) + return next(filter(predicate, iterable), None) async def _afind(predicate: Callable[[T], Any], iterable: AsyncIterable[T], /) -> Optional[T]: @@ -1037,17 +1039,18 @@ def escape_mentions(text: str) -> str: def _chunk(iterator: Iterable[T], max_size: int) -> Iterator[List[T]]: - ret = [] - n = 0 - for item in iterator: - ret.append(item) - n += 1 - if n == max_size: - yield ret - ret = [] - n = 0 - if ret: - yield ret + # Specialise iterators that can be sliced as it is much faster + if isinstance(iterator, collections.abc.Sequence): + for i in range(0, len(iterator), max_size): + yield list(iterator[i : i + max_size]) + else: + # Fallback to slower path + iterator = iter(iterator) + while True: + batch = list(islice(iterator, max_size)) + if not batch: + break + yield batch async def _achunk(iterator: AsyncIterable[T], max_size: int) -> AsyncIterator[List[T]]: From 005287898393bd13d21e077e8607d5226757820a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 21 Dec 2025 12:18:05 -0500 Subject: [PATCH 36/88] Guard against Item.view being None when dispatching --- discord/ui/view.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 826b6a952..4da8d6468 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1027,8 +1027,11 @@ class ViewStore: if item is None: return - # Note, at this point the View is *not* None - task = item.view._dispatch_item(item, interaction) # type: ignore + if item.view is None: + _log.warning('View interaction referencing unknown view for item %s. Discarding', item) + return + + task = item.view._dispatch_item(item, interaction) if task is not None: self.add_task(task) From bd37844be7f83abde030b4302cf5da801fccd1a9 Mon Sep 17 00:00:00 2001 From: Snazzah Date: Wed, 7 Jan 2026 16:05:46 -0500 Subject: [PATCH 37/88] Add DAVE protocol support --- discord/gateway.py | 130 ++++++++++++++++++++++++++++++++++++---- discord/voice_client.py | 20 ++++++- discord/voice_state.py | 70 ++++++++++++++++++++++ pyproject.toml | 5 +- 4 files changed, 210 insertions(+), 15 deletions(-) diff --git a/discord/gateway.py b/discord/gateway.py index 4e1f78c68..75acf7e98 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -44,6 +44,11 @@ from .activity import BaseActivity from .enums import SpeakingState from .errors import ConnectionClosed +try: + import davey # type: ignore +except ImportError: + pass + _log = logging.getLogger(__name__) __all__ = ( @@ -812,18 +817,30 @@ class DiscordVoiceWebSocket: _max_heartbeat_timeout: float # fmt: off - IDENTIFY = 0 - SELECT_PROTOCOL = 1 - READY = 2 - HEARTBEAT = 3 - SESSION_DESCRIPTION = 4 - SPEAKING = 5 - HEARTBEAT_ACK = 6 - RESUME = 7 - HELLO = 8 - RESUMED = 9 - CLIENT_CONNECT = 12 - CLIENT_DISCONNECT = 13 + IDENTIFY = 0 + SELECT_PROTOCOL = 1 + READY = 2 + HEARTBEAT = 3 + SESSION_DESCRIPTION = 4 + SPEAKING = 5 + HEARTBEAT_ACK = 6 + RESUME = 7 + HELLO = 8 + RESUMED = 9 + CLIENTS_CONNECT = 11 + CLIENT_CONNECT = 12 + CLIENT_DISCONNECT = 13 + DAVE_PREPARE_TRANSITION = 21 + DAVE_EXECUTE_TRANSITION = 22 + DAVE_TRANSITION_READY = 23 + DAVE_PREPARE_EPOCH = 24 + MLS_EXTERNAL_SENDER = 25 + MLS_KEY_PACKAGE = 26 + MLS_PROPOSALS = 27 + MLS_COMMIT_WELCOME = 28 + MLS_ANNOUNCE_COMMIT_TRANSITION = 29 + MLS_WELCOME = 30 + MLS_INVALID_COMMIT_WELCOME = 31 # fmt: on def __init__( @@ -850,6 +867,10 @@ class DiscordVoiceWebSocket: _log.debug('Sending voice websocket frame: %s.', data) await self.ws.send_str(utils._to_json(data)) + async def send_binary(self, opcode: int, data: bytes) -> None: + _log.debug('Sending voice websocket binary frame: opcode=%s size=%d', opcode, len(data)) + await self.ws.send_bytes(bytes([opcode]) + data) + send_heartbeat = send_as_json async def resume(self) -> None: @@ -874,6 +895,7 @@ class DiscordVoiceWebSocket: 'user_id': str(state.user.id), 'session_id': state.session_id, 'token': state.token, + 'max_dave_protocol_version': state.max_dave_protocol_version, }, } await self.send_as_json(payload) @@ -943,6 +965,16 @@ class DiscordVoiceWebSocket: await self.send_as_json(payload) + async def send_transition_ready(self, transition_id: int): + payload = { + 'op': DiscordVoiceWebSocket.DAVE_TRANSITION_READY, + 'd': { + 'transition_id': transition_id, + }, + } + + await self.send_as_json(payload) + async def received_message(self, msg: Dict[str, Any]) -> None: _log.debug('Voice websocket frame received: %s', msg) op = msg['op'] @@ -959,13 +991,85 @@ class DiscordVoiceWebSocket: elif op == self.SESSION_DESCRIPTION: self._connection.mode = data['mode'] await self.load_secret_key(data) + self._connection.dave_protocol_version = data['dave_protocol_version'] + if data['dave_protocol_version'] > 0: + await self._connection.reinit_dave_session() elif op == self.HELLO: interval = data['heartbeat_interval'] / 1000.0 self._keep_alive = VoiceKeepAliveHandler(ws=self, interval=min(interval, 5.0)) self._keep_alive.start() + elif self._connection.dave_session: + state = self._connection + if op == self.DAVE_PREPARE_TRANSITION: + _log.debug( + 'Preparing for DAVE transition id %d for protocol version %d', + data['transition_id'], + data['protocol_version'], + ) + state.dave_pending_transitions[data['transition_id']] = data['protocol_version'] + if data['transition_id'] == 0: + await state._execute_transition(data['transition_id']) + else: + if data['protocol_version'] == 0 and state.dave_session: + state.dave_session.set_passthrough_mode(True, 120) + + await self.send_transition_ready(data['transition_id']) + elif op == self.DAVE_EXECUTE_TRANSITION: + _log.debug('Executing DAVE transition id %d', data['transition_id']) + await state._execute_transition(data['transition_id']) + elif op == self.DAVE_PREPARE_EPOCH: + _log.debug('Preparing for DAVE epoch %d', data['epoch']) + # When the epoch ID is equal to 1, this message indicates that a new MLS group is to be created for the given protocol version. + if data['epoch'] == 1: + state.dave_protocol_version = data['protocol_version'] + await state.reinit_dave_session() await self._hook(self, msg) + async def received_binary_message(self, msg: bytes) -> None: + self.seq_ack = struct.unpack_from('>H', msg, 0)[0] + op = msg[2] + _log.debug('Voice websocket binary frame received: %d bytes; seq=%s op=%s', len(msg), self.seq_ack, op) + state = self._connection + + if state.dave_session is None: + return + + if op == self.MLS_EXTERNAL_SENDER: + state.dave_session.set_external_sender(msg[3:]) + _log.debug('Set MLS external sender') + elif op == self.MLS_PROPOSALS: + optype = msg[3] + result = state.dave_session.process_proposals( + davey.ProposalsOperationType.append if optype == 0 else davey.ProposalsOperationType.revoke, msg[4:] + ) + if isinstance(result, davey.CommitWelcome): + await self.send_binary( + DiscordVoiceWebSocket.MLS_COMMIT_WELCOME, + result.commit + result.welcome if result.welcome else result.commit, + ) + _log.debug('MLS proposals processed') + elif op == self.MLS_ANNOUNCE_COMMIT_TRANSITION: + transition_id = struct.unpack_from('>H', msg, 3)[0] + try: + state.dave_session.process_commit(msg[5:]) + if transition_id != 0: + state.dave_pending_transitions[transition_id] = state.dave_protocol_version + await self.send_transition_ready(transition_id) + _log.debug('MLS commit processed for transition id %d', transition_id) + except Exception: + await state._recover_from_invalid_commit(transition_id) + elif op == self.MLS_WELCOME: + transition_id = struct.unpack_from('>H', msg, 3)[0] + try: + state.dave_session.process_welcome(msg[5:]) + if transition_id != 0: + state.dave_pending_transitions[transition_id] = state.dave_protocol_version + await self.send_transition_ready(transition_id) + _log.debug('MLS welcome processed for transition id %d', transition_id) + except Exception: + await state._recover_from_invalid_commit(transition_id) + async def initial_connection(self, data: Dict[str, Any]) -> None: state = self._connection state.ssrc = data['ssrc'] @@ -1045,6 +1149,8 @@ class DiscordVoiceWebSocket: msg = await asyncio.wait_for(self.ws.receive(), timeout=30.0) if msg.type is aiohttp.WSMsgType.TEXT: await self.received_message(utils._from_json(msg.data)) + elif msg.type is aiohttp.WSMsgType.BINARY: + await self.received_binary_message(msg.data) elif msg.type is aiohttp.WSMsgType.ERROR: _log.debug('Received voice %s', msg) raise ConnectionClosed(self.ws, shard_id=None) from msg.data diff --git a/discord/voice_client.py b/discord/voice_client.py index b0f3e951b..3b489b7d0 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -284,6 +284,17 @@ class VoiceClient(VoiceProtocol): def timeout(self) -> float: return self._connection.timeout + @property + def voice_privacy_code(self) -> Optional[str]: + """:class:`str`: Get the voice privacy code of this E2EE session's group. + + A new privacy code is created and cached each time a new transition is executed. + This can be None if there is no active DAVE session happening. + + .. versionadded:: 2.7 + """ + return self._connection.dave_session.voice_privacy_code if self._connection.dave_session else None + def checked_add(self, attr: str, value: int, limit: int) -> None: val = getattr(self, attr) if val + value > limit: @@ -368,7 +379,12 @@ class VoiceClient(VoiceProtocol): # audio related - def _get_voice_packet(self, data): + def _get_voice_packet(self, data: bytes): + packet = ( + self._connection.dave_session.encrypt_opus(data) + if self._connection.dave_session and self._connection.can_encrypt + else data + ) header = bytearray(12) # Formulate rtp header @@ -379,7 +395,7 @@ class VoiceClient(VoiceProtocol): struct.pack_into('>I', header, 8, self.ssrc) encrypt_packet = getattr(self, '_encrypt_' + self.mode) - return encrypt_packet(header, data) + return encrypt_packet(header, packet) def _encrypt_aead_xchacha20_poly1305_rtpsize(self, header: bytes, data) -> bytes: # Esentially the same as _lite diff --git a/discord/voice_state.py b/discord/voice_state.py index 5e78c7851..04cc11b61 100644 --- a/discord/voice_state.py +++ b/discord/voice_state.py @@ -69,6 +69,14 @@ if TYPE_CHECKING: WebsocketHook = Optional[Callable[[DiscordVoiceWebSocket, Dict[str, Any]], Coroutine[Any, Any, Any]]] SocketReaderCallback = Callable[[bytes], Any] +has_dave: bool + +try: + import davey # type: ignore + + has_dave = True +except ImportError: + has_dave = False __all__ = ('VoiceConnectionState',) @@ -208,6 +216,10 @@ class VoiceConnectionState: self.mode: SupportedModes = MISSING self.socket: socket.socket = MISSING self.ws: DiscordVoiceWebSocket = MISSING + self.dave_session: Optional[davey.DaveSession] = None + self.dave_protocol_version: int = 0 + self.dave_pending_transitions: Dict[int, int] = {} + self.dave_downgraded: bool = False self._state: ConnectionFlowState = ConnectionFlowState.disconnected self._expecting_disconnect: bool = False @@ -252,6 +264,64 @@ class VoiceConnectionState: def self_voice_state(self) -> Optional[VoiceState]: return self.guild.me.voice + @property + def max_dave_protocol_version(self) -> int: + return davey.DAVE_PROTOCOL_VERSION if has_dave else 0 + + @property + def can_encrypt(self) -> bool: + return self.dave_protocol_version != 0 and self.dave_session != None and self.dave_session.ready + + async def reinit_dave_session(self) -> None: + if self.dave_protocol_version > 0: + if not has_dave: + raise RuntimeError('davey library needed in order to use E2EE voice') + if self.dave_session is not None: + self.dave_session.reinit(self.dave_protocol_version, self.user.id, self.voice_client.channel.id) + else: + self.dave_session = davey.DaveSession(self.dave_protocol_version, self.user.id, self.voice_client.channel.id) + + if self.dave_session is not None: + await self.voice_client.ws.send_binary( + DiscordVoiceWebSocket.MLS_KEY_PACKAGE, self.dave_session.get_serialized_key_package() + ) + elif self.dave_session: + self.dave_session.reset() + self.dave_session.set_passthrough_mode(True, 10) + pass + + async def _recover_from_invalid_commit(self, transition_id: int) -> None: + payload = { + 'op': DiscordVoiceWebSocket.MLS_INVALID_COMMIT_WELCOME, + 'd': { + 'transition_id': transition_id, + }, + } + + await self.voice_client.ws.send_as_json(payload) + await self.reinit_dave_session() + + async def _execute_transition(self, transition_id: int) -> None: + _log.debug('Executing transition id %d', transition_id) + if transition_id not in self.dave_pending_transitions: + _log.warning("Received execute transition, but we don't have a pending transition for id %d", transition_id) + return + + old_version = self.dave_protocol_version + self.dave_protocol_version = self.dave_pending_transitions.pop(transition_id) + + if old_version != self.dave_protocol_version and self.dave_protocol_version == 0: + self.dave_downgraded = True + _log.debug('DAVE Session downgraded') + elif transition_id > 0 and self.dave_downgraded: + self.dave_downgraded = False + if self.dave_session: + self.dave_session.set_passthrough_mode(True, 10) + _log.debug('DAVE Session upgraded') + + # In the future, the session should be signaled too, but for now theres just v1 + _log.debug('Transition id %d executed', transition_id) + async def voice_state_update(self, data: GuildVoiceStatePayload) -> None: channel_id = data['channel_id'] diff --git a/pyproject.toml b/pyproject.toml index d32ed9a29..1c45d5f7a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,10 @@ Documentation = "https://discordpy.readthedocs.io/en/latest/" dependencies = { file = "requirements.txt" } [project.optional-dependencies] -voice = ["PyNaCl>=1.5.0,<1.6"] +voice = [ + "PyNaCl>=1.5.0,<1.6", + "davey==0.1.0" +] docs = [ "sphinx==4.4.0", "sphinxcontrib_trio==1.1.2", From 1df81fea52f508482a0bb1fbf01f48a489d1892d Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 8 Jan 2026 08:17:43 -0500 Subject: [PATCH 38/88] [commands] Mark Cog check special methods as MaybeCoro return Fix #10354 --- discord/ext/commands/cog.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index b6d2ab0c1..4b2f2c2fa 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -47,7 +47,7 @@ from typing import ( Union, ) -from ._types import _BaseCommand, BotT +from ._types import _BaseCommand, BotT, MaybeCoro if TYPE_CHECKING: from typing_extensions import Self @@ -583,7 +583,7 @@ class Cog(metaclass=CogMeta): pass @_cog_special_method - def bot_check_once(self, ctx: Context[BotT]) -> bool: + def bot_check_once(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :meth:`.Bot.check_once` check. @@ -593,7 +593,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def bot_check(self, ctx: Context[BotT]) -> bool: + def bot_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :meth:`.Bot.check` check. @@ -603,7 +603,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def cog_check(self, ctx: Context[BotT]) -> bool: + def cog_check(self, ctx: Context[BotT]) -> MaybeCoro[bool]: """A special method that registers as a :func:`~discord.ext.commands.check` for every command and subcommand in this cog. @@ -613,7 +613,7 @@ class Cog(metaclass=CogMeta): return True @_cog_special_method - def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> bool: + def interaction_check(self, interaction: discord.Interaction[ClientT], /) -> MaybeCoro[bool]: """A special method that registers as a :func:`discord.app_commands.check` for every app command and subcommand in this cog. From b9b21ca270e139dd344d7961b1ab9330609bca05 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 11 Jan 2026 08:02:50 -0500 Subject: [PATCH 39/88] [commands] Fix Context.from_interaction derived Message.type The previous Message.type when accessed from Context would be an unknown enum type because the enum was double nested when the proper type expected by the synthetic payload was an int not an enum. Fix #10382 --- discord/ext/commands/context.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/discord/ext/commands/context.py b/discord/ext/commands/context.py index 968fec419..54b3dd973 100644 --- a/discord/ext/commands/context.py +++ b/discord/ext/commands/context.py @@ -259,6 +259,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): bot: BotT = interaction.client data: ApplicationCommandInteractionData = interaction.data # type: ignore + type_ = data.get('type', 1) if interaction.message is None: synthetic_payload = { 'id': interaction.id, @@ -268,7 +269,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): 'tts': False, 'pinned': False, 'edited_timestamp': None, - 'type': MessageType.chat_input_command if data.get('type', 1) == 1 else MessageType.context_menu_command, + 'type': MessageType.chat_input_command.value if type_ == 1 else MessageType.context_menu_command.value, 'flags': 64, 'content': '', 'mentions': [], @@ -288,7 +289,7 @@ class Context(discord.abc.Messageable, Generic[BotT]): else: message = interaction.message - prefix = '/' if data.get('type', 1) == 1 else '\u200b' # Mock the prefix + prefix = '/' if type_ == 1 else '\u200b' # Mock the prefix ctx = cls( message=message, bot=bot, From bcea13e993c6dfe6ba00d7a517415551fd8893e6 Mon Sep 17 00:00:00 2001 From: Michael Date: Wed, 14 Jan 2026 01:08:53 +0200 Subject: [PATCH 40/88] Allow ui.View initialization without a running event loop --- discord/ui/view.py | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 4da8d6468..a57a61a72 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -219,7 +219,14 @@ class BaseView: self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None - self.__stopped: asyncio.Future[bool] = asyncio.get_running_loop().create_future() + + try: + loop = asyncio.get_running_loop() + except RuntimeError: + self.__stopped: Optional[asyncio.Future[bool]] = None + else: + self.__stopped: Optional[asyncio.Future[bool]] = loop.create_future() + self._total_children: int = len(tuple(self.walk_children())) def _is_layout(self) -> bool: @@ -557,7 +564,7 @@ class BaseView: self.__timeout_task = asyncio.create_task(self.__timeout_task_impl()) def _dispatch_timeout(self): - if self.__stopped.done(): + if self.__stopped is None or self.__stopped.done(): return if self.__cancel_callback: @@ -568,8 +575,8 @@ class BaseView: asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') def _dispatch_item(self, item: Item, interaction: Interaction) -> Optional[asyncio.Task[None]]: - if self.__stopped.done(): - return + if self.__stopped is None or self.__stopped.done(): + return None return asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') @@ -600,7 +607,7 @@ class BaseView: This operation cannot be undone. """ - if not self.__stopped.done(): + if self.__stopped is not None and not self.__stopped.done(): self.__stopped.set_result(False) self.__timeout_expiry = None @@ -614,6 +621,9 @@ class BaseView: def is_finished(self) -> bool: """:class:`bool`: Whether the view has finished interacting.""" + if self.__stopped is None: + return True + return self.__stopped.done() def is_dispatching(self) -> bool: @@ -642,6 +652,9 @@ class BaseView: If ``True``, then the view timed out. If ``False`` then the view finished normally. """ + if self.__stopped is None: + self.__stopped = asyncio.get_running_loop().create_future() + return await self.__stopped def walk_children(self) -> Generator[Item[Any], None, None]: From c8b95774cb2707f7153ace09b131a5ed2e281f43 Mon Sep 17 00:00:00 2001 From: Quintenvw Date: Wed, 14 Jan 2026 12:10:36 +0100 Subject: [PATCH 41/88] Change join thread endpoint from POST to PUT --- discord/http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/http.py b/discord/http.py index 1e09607e8..0219c1196 100644 --- a/discord/http.py +++ b/discord/http.py @@ -1345,7 +1345,7 @@ class HTTPClient: return self.request(r, json=params.payload, params=query, reason=reason) def join_thread(self, channel_id: Snowflake) -> Response[None]: - return self.request(Route('POST', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) + return self.request(Route('PUT', '/channels/{channel_id}/thread-members/@me', channel_id=channel_id)) def add_user_to_thread(self, channel_id: Snowflake, user_id: Snowflake) -> Response[None]: return self.request( From 3ab09be13c0bb87fbd34a0dc8c8a3e2720e4005e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 28 Jan 2026 16:58:55 -0500 Subject: [PATCH 42/88] Invert View.is_finished condition when there is no associated Future --- discord/ui/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index a57a61a72..6b4a3ca90 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -622,7 +622,7 @@ class BaseView: def is_finished(self) -> bool: """:class:`bool`: Whether the view has finished interacting.""" if self.__stopped is None: - return True + return False return self.__stopped.done() From a7d42b990645fff59c27d59873ad95815210ba4b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 4 Feb 2026 18:57:04 -0500 Subject: [PATCH 43/88] Fallback to Item.row when converting to Modal component list Fix #10397 --- discord/ui/modal.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index db8bf5241..1da93478b 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -223,7 +223,7 @@ class Modal(BaseView): def to_components(self) -> List[Dict[str, Any]]: def key(item: Item) -> int: - return item._rendered_row or 0 + return item._rendered_row or item.row or 0 children = sorted(self._children, key=key) components: List[Dict[str, Any]] = [] From 103fe90d8bc36f7d9b119fffe6c2be162f98c131 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 5 Feb 2026 08:56:58 -0500 Subject: [PATCH 44/88] [commands] Fix decorator order mattering for hybrid commands --- discord/app_commands/commands.py | 102 ++++++++++++++++++------------- discord/ext/commands/hybrid.py | 8 +++ 2 files changed, 66 insertions(+), 44 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index 36d07d41c..cc0aaef9b 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -2180,8 +2180,9 @@ def describe(**parameters: Union[str, locale_str]) -> Callable[[T], T]: ''' def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_descriptions(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_descriptions(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_description__.update(parameters) # type: ignore # Runtime attribute access @@ -2223,8 +2224,9 @@ def rename(**parameters: Union[str, locale_str]) -> Callable[[T], T]: """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_renames(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_renames(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_rename__.update(parameters) # type: ignore # Runtime attribute access @@ -2292,8 +2294,9 @@ def choices(**parameters: List[Choice[ChoiceT]]) -> Callable[[T], T]: """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_choices(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_choices(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_choices__.update(parameters) # type: ignore # Runtime attribute access @@ -2351,8 +2354,9 @@ def autocomplete(**parameters: AutocompleteCallback[GroupT, ChoiceT]) -> Callabl """ def decorator(inner: T) -> T: - if isinstance(inner, Command): - _populate_autocomplete(inner._params, parameters) + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, Command): + _populate_autocomplete(unwrapped._params, parameters) else: try: inner.__discord_app_commands_param_autocomplete__.update(parameters) # type: ignore # Runtime attribute access @@ -2408,13 +2412,14 @@ def guilds(*guild_ids: Union[Snowflake, int]) -> Callable[[T], T]: defaults: List[int] = [g if isinstance(g, int) else g.id for g in guild_ids] def decorator(inner: T) -> T: - if isinstance(inner, (Group, ContextMenu)): - inner._guild_ids = defaults - elif isinstance(inner, Command): - if inner.parent is not None: + unwrapped = getattr(inner, '__discord_app_commands_unwrap__', inner) or inner + if isinstance(unwrapped, (Group, ContextMenu)): + unwrapped._guild_ids = defaults + elif isinstance(unwrapped, Command): + if unwrapped.parent is not None: raise ValueError('child commands of a group cannot have default guilds set') - inner._guild_ids = defaults + unwrapped._guild_ids = defaults else: # Runtime attribute assignment inner.__discord_app_commands_default_guilds__ = defaults # type: ignore @@ -2470,13 +2475,14 @@ def check(predicate: Check) -> Callable[[T], T]: """ def decorator(func: CheckInputParameter) -> CheckInputParameter: - if isinstance(func, (Command, ContextMenu)): - func.checks.append(predicate) + unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func + if isinstance(unwrapped, (Command, ContextMenu)): + unwrapped.checks.append(predicate) else: if not hasattr(func, '__discord_app_commands_checks__'): - func.__discord_app_commands_checks__ = [] + func.__discord_app_commands_checks__ = [] # type: ignore # Runtime attribute assignment - func.__discord_app_commands_checks__.append(predicate) + func.__discord_app_commands_checks__.append(predicate) # type: ignore # Runtime attribute access return func @@ -2513,10 +2519,11 @@ def guild_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = True - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = True + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: f.__discord_app_commands_guild_only__ = True # type: ignore # Runtime attribute assignment @@ -2567,10 +2574,11 @@ def private_channel_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]] """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2617,10 +2625,11 @@ def dm_only(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2658,10 +2667,11 @@ def allowed_contexts(guilds: bool = MISSING, dms: bool = MISSING, private_channe """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - f.guild_only = False - allowed_contexts = f.allowed_contexts or AppCommandContext() - f.allowed_contexts = allowed_contexts + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.guild_only = False + allowed_contexts = unwrapped.allowed_contexts or AppCommandContext() + unwrapped.allowed_contexts = allowed_contexts else: allowed_contexts = getattr(f, '__discord_app_commands_contexts__', None) or AppCommandContext() f.__discord_app_commands_contexts__ = allowed_contexts # type: ignore # Runtime attribute assignment @@ -2709,9 +2719,10 @@ def guild_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2757,9 +2768,10 @@ def user_install(func: Optional[T] = None) -> Union[T, Callable[[T], T]]: """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2801,9 +2813,10 @@ def allowed_installs( """ def inner(f: T) -> T: - if isinstance(f, (Command, Group, ContextMenu)): - allowed_installs = f.allowed_installs or AppInstallationType() - f.allowed_installs = allowed_installs + unwrapped = getattr(f, '__discord_app_commands_unwrap__', f) or f + if isinstance(unwrapped, (Command, Group, ContextMenu)): + allowed_installs = unwrapped.allowed_installs or AppInstallationType() + unwrapped.allowed_installs = allowed_installs else: allowed_installs = getattr(f, '__discord_app_commands_installation_types__', None) or AppInstallationType() f.__discord_app_commands_installation_types__ = allowed_installs # type: ignore # Runtime attribute assignment @@ -2874,8 +2887,9 @@ def default_permissions(perms_obj: Optional[Permissions] = None, /, **perms: Unp permissions = Permissions(**perms) def decorator(func: T) -> T: - if isinstance(func, (Command, Group, ContextMenu)): - func.default_permissions = permissions + unwrapped = getattr(func, '__discord_app_commands_unwrap__', func) or func + if isinstance(unwrapped, (Command, Group, ContextMenu)): + unwrapped.default_permissions = permissions else: func.__discord_app_commands_default_permissions__ = permissions # type: ignore # Runtime attribute assignment diff --git a/discord/ext/commands/hybrid.py b/discord/ext/commands/hybrid.py index 6687104cb..e7366c6e8 100644 --- a/discord/ext/commands/hybrid.py +++ b/discord/ext/commands/hybrid.py @@ -534,6 +534,10 @@ class HybridCommand(Command[CogT, P, T]): HybridAppCommand(self) if self.with_app_command else None ) + @property + def __discord_app_commands_unwrap__(self) -> Optional[HybridAppCommand[CogT, Any, T]]: + return self.app_command + @property def cog(self) -> CogT: return self._cog @@ -702,6 +706,10 @@ class HybridGroup(Group[CogT, P, T]): return None return self.app_command.get_command(self.fallback) # type: ignore + @property + def __discord_app_commands_unwrap__(self) -> Optional[app_commands.Group]: + return self.app_command + @property def cog(self) -> CogT: return self._cog From 2f1c3fde7bfb02cdf10f7604d0ab5e7afbbcc5e1 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 18 Feb 2026 22:27:23 -0500 Subject: [PATCH 45/88] Fix Message.call raising an attribute error when accessed Fix #10404 --- discord/message.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/discord/message.py b/discord/message.py index 9db351d54..779c3aacc 100644 --- a/discord/message.py +++ b/discord/message.py @@ -2221,6 +2221,7 @@ class Message(PartialMessage, Hashable): self.application_id: Optional[int] = utils._get_as_snowflake(data, 'application_id') self.stickers: List[StickerItem] = [StickerItem(data=d, state=state) for d in data.get('sticker_items', [])] self.message_snapshots: List[MessageSnapshot] = MessageSnapshot._from_value(state, data.get('message_snapshots')) + self.call: Optional[CallMessage] = None # Set by Messageable.pins self._pinned_at: Optional[datetime.datetime] = None @@ -2513,11 +2514,8 @@ class Message(PartialMessage, Hashable): self.interaction_metadata = MessageInteractionMetadata(state=self._state, guild=self.guild, data=data) def _handle_call(self, data: CallMessagePayload): - self.call: Optional[CallMessage] if data is not None: self.call = CallMessage(state=self._state, message=self, data=data) - else: - self.call = None def _rebind_cached_references( self, From 680ca5ee20da1f788cb93f3514ac9ef788f0a803 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 22 Feb 2026 21:02:14 +0100 Subject: [PATCH 46/88] Add command_id and custom_id attributes to Interaction --- discord/interactions.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/discord/interactions.py b/discord/interactions.py index e295de0c6..2724b38b8 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -65,6 +65,8 @@ if TYPE_CHECKING: ApplicationCommandInteractionData, InteractionCallback as InteractionCallbackPayload, InteractionCallbackActivity as InteractionCallbackActivityPayload, + MessageComponentInteractionData, + ModalSubmitInteractionData, ) from .types.webhook import ( Webhook as WebhookPayload, @@ -191,6 +193,8 @@ class Interaction(Generic[ClientT]): 'channel', '_cs_namespace', '_cs_command', + '_cs_command_id', + '_cs_custom_id', ) def __init__(self, *, data: InteractionPayload, state: ConnectionState[ClientT]): @@ -376,6 +380,21 @@ class Interaction(Generic[ClientT]): else: return tree._get_context_menu(data) + @utils.cached_slot_property('_cs_command_id') + def command_id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of the command that triggered this interaction. + + Only applicable if :attr:`type` is one of, :attr:`InteractionType.application_command` or + :attr:`InteractionType.autocomplete`. + + .. versionadded:: 2.7 + """ + if self.type not in (InteractionType.application_command, InteractionType.autocomplete): + return None + + data: ApplicationCommandInteractionData = self.data # type: ignore + return int(data.get('id', 0)) + @utils.cached_slot_property('_cs_response') def response(self) -> InteractionResponse[ClientT]: """:class:`InteractionResponse`: Returns an object responsible for handling responding to the interaction. @@ -405,6 +424,21 @@ class Interaction(Generic[ClientT]): """:class:`datetime.datetime`: When the interaction expires.""" return self.created_at + datetime.timedelta(minutes=15) + @utils.cached_slot_property('_cs_custom_id') + def custom_id(self) -> Optional[str]: + """Optional[:class:`str`]: The custom ID of the component that triggered this interaction. + + Only applicable if :attr:`type` is one of, :attr:`InteractionType.component` or + :attr:`InteractionType.modal_submit`. + + .. versionadded:: 2.7 + """ + if self.type not in (InteractionType.component, InteractionType.modal_submit): + return None + + data: Union[MessageComponentInteractionData, ModalSubmitInteractionData] = self.data # type: ignore + return data.get('custom_id') + def is_expired(self) -> bool: """:class:`bool`: Returns ``True`` if the interaction is expired.""" return utils.utcnow() >= self.expires_at From e45c8e60e1e16b3e9cb68179a37d5a19229f6ef8 Mon Sep 17 00:00:00 2001 From: Sacul <183588943+Sacul0457@users.noreply.github.com> Date: Mon, 23 Feb 2026 04:53:02 +0800 Subject: [PATCH 47/88] Add bypass slowmode permissions --- discord/app_commands/models.py | 6 ++---- discord/channel.py | 12 ++++-------- discord/message.py | 6 +++--- discord/permissions.py | 24 +++++++++++++++++++----- discord/threads.py | 3 +-- 5 files changed, 29 insertions(+), 22 deletions(-) diff --git a/discord/app_commands/models.py b/discord/app_commands/models.py index b51339c26..b3a4b151e 100644 --- a/discord/app_commands/models.py +++ b/discord/app_commands/models.py @@ -597,8 +597,7 @@ class AppCommandChannel(Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~discord.Permissions.manage_channels` or - :attr:`~discord.Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.6 nsfw: :class:`bool` @@ -779,8 +778,7 @@ class AppCommandThread(Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this thread. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~discord.Permissions.manage_channels` or - :attr:`~discord.Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~discord.Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.6 message_count: :class:`int` diff --git a/discord/channel.py b/discord/channel.py index 17a1c0fb2..8aee381e9 100644 --- a/discord/channel.py +++ b/discord/channel.py @@ -322,8 +322,7 @@ class TextChannel(discord.abc.Messageable, discord.abc.GuildChannel, Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. nsfw: :class:`bool` If the channel is marked as "not safe for work" or "age restricted". default_auto_archive_duration: :class:`int` @@ -1516,8 +1515,7 @@ class VoiceChannel(VocalGuildChannel): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.2 """ @@ -1744,8 +1742,7 @@ class StageChannel(VocalGuildChannel): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this channel. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. .. versionadded:: 2.2 """ @@ -2409,8 +2406,7 @@ class ForumChannel(discord.abc.GuildChannel, Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between creating threads in this forum. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. nsfw: :class:`bool` If the forum is marked as "not safe for work" or "age restricted". default_auto_archive_duration: :class:`int` diff --git a/discord/message.py b/discord/message.py index 779c3aacc..7b209fc59 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1453,7 +1453,7 @@ class PartialMessage(Hashable): Pins the message. - You must have :attr:`~Permissions.manage_messages` to do + You must have :attr:`~Permissions.pin_messages` to do this in a non-private channel context. Parameters @@ -1471,7 +1471,7 @@ class PartialMessage(Hashable): The message or channel was not found or deleted. HTTPException Pinning the message failed, probably due to the channel - having more than 50 pinned messages. + having more than 250 pinned messages. """ await self._state.http.pin_message(self.channel.id, self.id, reason=reason) @@ -1483,7 +1483,7 @@ class PartialMessage(Hashable): Unpins the message. - You must have :attr:`~Permissions.manage_messages` to do + You must have :attr:`~Permissions.pin_messages` to do this in a non-private channel context. Parameters diff --git a/discord/permissions.py b/discord/permissions.py index a1e0d21c2..e09af313c 100644 --- a/discord/permissions.py +++ b/discord/permissions.py @@ -95,6 +95,7 @@ if TYPE_CHECKING: create_polls: BoolOrNoneT use_external_apps: BoolOrNoneT pin_messages: BoolOrNoneT + bypass_slowmode: BoolOrNoneT class _PermissionsKwargs(_BasePermissionsKwargs[bool]): ... @@ -253,7 +254,7 @@ class Permissions(BaseFlags): permissions set to ``True``. """ # Some of these are 0 because we don't want to set unnecessary bits - return cls(0b0000_0000_0000_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) + return cls(0b0000_0000_0001_1111_0111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111_1111) @classmethod def _timeout_mask(cls) -> int: @@ -273,6 +274,7 @@ class Permissions(BaseFlags): base.create_public_threads = False base.manage_threads = False base.send_messages_in_threads = False + base.bypass_slowmode = False return base @classmethod @@ -326,8 +328,11 @@ class Permissions(BaseFlags): .. versionchanged:: 2.4 Added :attr:`send_polls`, :attr:`send_voice_messages`, attr:`use_external_sounds`, :attr:`use_embedded_activities`, and :attr:`use_external_apps` permissions. + + .. versionchanged:: 2.7 + Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions. """ - return cls(0b0000_0000_0000_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) + return cls(0b0000_0000_0001_1110_0110_0100_1111_1101_1011_0011_1111_0111_1111_1111_0101_0001) @classmethod def general(cls) -> Self: @@ -377,9 +382,9 @@ class Permissions(BaseFlags): Added :attr:`send_polls` and :attr:`use_external_apps` permissions. .. versionchanged:: 2.7 - Added :attr:`pin_messages` permission. + Added :attr:`pin_messages` and :attr:`bypass_slowmode` permissions. """ - return cls(0b0000_0000_0000_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) + return cls(0b0000_0000_0001_1110_0100_0000_0111_1100_1000_0000_0000_0111_1111_1000_0100_0000) @classmethod def voice(cls) -> Self: @@ -577,7 +582,7 @@ class Permissions(BaseFlags): @flag_value def manage_messages(self) -> int: - """:class:`bool`: Returns ``True`` if a user can delete messages or bypass slowmode in a text channel. + """:class:`bool`: Returns ``True`` if a user can delete messages in a text channel. .. note:: @@ -884,6 +889,14 @@ class Permissions(BaseFlags): """ return 1 << 51 + @flag_value + def bypass_slowmode(self) -> int: + """:class:`bool`: Returns ``True`` if a user can bypass slowmode. + + .. versionadded:: 2.7 + """ + return 1 << 52 + def _augment_from_permissions(cls): cls.VALID_NAMES = set(Permissions.VALID_FLAGS) @@ -1009,6 +1022,7 @@ class PermissionOverwrite: create_polls: Optional[bool] use_external_apps: Optional[bool] pin_messages: Optional[bool] + bypass_slowmode: Optional[bool] def __init__(self, **kwargs: Unpack[_PermissionOverwriteKwargs]) -> None: self._values: Dict[str, Optional[bool]] = {} diff --git a/discord/threads.py b/discord/threads.py index 1700a5e61..daa3a4a2b 100644 --- a/discord/threads.py +++ b/discord/threads.py @@ -103,8 +103,7 @@ class Thread(Messageable, Hashable): slowmode_delay: :class:`int` The number of seconds a member must wait between sending messages in this thread. A value of ``0`` denotes that it is disabled. - Bots and users with :attr:`~Permissions.manage_channels` or - :attr:`~Permissions.manage_messages` bypass slowmode. + Bots and users with :attr:`~Permissions.bypass_slowmode` bypass slowmode. message_count: :class:`int` An approximate number of messages in this thread. member_count: :class:`int` From dae46f7d0f08be8baafe833b71063c9e1dded1d2 Mon Sep 17 00:00:00 2001 From: Steve C Date: Sun, 22 Feb 2026 15:59:46 -0500 Subject: [PATCH 48/88] Add generics to Interaction params --- discord/app_commands/commands.py | 4 ++-- discord/ext/commands/cog.py | 4 +++- discord/ui/view.py | 17 +++++++++-------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index cc0aaef9b..da8879827 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -1802,7 +1802,7 @@ class Group: yield from command.walk_commands() @mark_overrideable - async def on_error(self, interaction: Interaction, error: AppCommandError, /) -> None: + async def on_error(self, interaction: Interaction[ClientT], error: AppCommandError, /) -> None: """|coro| A callback that is called when a child's command raises an :exc:`AppCommandError`. @@ -1850,7 +1850,7 @@ class Group: self.on_error = coro # type: ignore return coro - async def interaction_check(self, interaction: Interaction, /) -> bool: + async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: """|coro| A callback that is called when an interaction happens within the group diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index 4b2f2c2fa..e229345cc 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -646,7 +646,9 @@ class Cog(metaclass=CogMeta): pass @_cog_special_method - async def cog_app_command_error(self, interaction: discord.Interaction, error: app_commands.AppCommandError) -> None: + async def cog_app_command_error( + self, interaction: discord.Interaction[ClientT], error: app_commands.AppCommandError + ) -> None: """|coro| A special method that is called whenever an error within diff --git a/discord/ui/view.py b/discord/ui/view.py index 6b4a3ca90..57b0e2229 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -82,6 +82,7 @@ if TYPE_CHECKING: import re from ..interactions import Interaction + from .._types import ClientT from ..message import Message from ..types.components import ComponentBase as ComponentBasePayload from ..types.interactions import ( @@ -485,7 +486,7 @@ class BaseView: """ return _utils_get(self.walk_children(), id=id) - async def interaction_check(self, interaction: Interaction, /) -> bool: + async def interaction_check(self, interaction: Interaction[ClientT], /) -> bool: """|coro| A callback that is called when an interaction happens within the view @@ -520,7 +521,7 @@ class BaseView: """ pass - async def on_error(self, interaction: Interaction, error: Exception, item: Item[Any], /) -> None: + async def on_error(self, interaction: Interaction[ClientT], error: Exception, item: Item[Any], /) -> None: """|coro| A callback that is called when an item's callback or :meth:`interaction_check` @@ -539,7 +540,7 @@ class BaseView: """ _log.error('Ignoring exception in view %r for item %r', self, item, exc_info=error) - async def _scheduled_task(self, item: Item, interaction: Interaction): + async def _scheduled_task(self, item: Item[Any], interaction: Interaction[ClientT]): try: item._refresh_state(interaction, interaction.data) # type: ignore @@ -574,7 +575,7 @@ class BaseView: self.__stopped.set_result(True) asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') - def _dispatch_item(self, item: Item, interaction: Interaction) -> Optional[asyncio.Task[None]]: + def _dispatch_item(self, item: Item[Any], interaction: Interaction[ClientT]) -> Optional[asyncio.Task[None]]: if self.__stopped is None or self.__stopped.done(): return None @@ -935,7 +936,7 @@ class ViewStore: self, component_type: int, factory: Type[DynamicItem[Item[Any]]], - interaction: Interaction, + interaction: Interaction[ClientT], custom_id: str, match: re.Match[str], ) -> None: @@ -986,7 +987,7 @@ class ViewStore: except Exception: _log.exception('Ignoring exception in dynamic item callback for %r', item) - def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + def dispatch_dynamic_items(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None: for pattern, item in self._dynamic_items.items(): match = pattern.fullmatch(custom_id) if match is not None: @@ -997,7 +998,7 @@ class ViewStore: ) ) - def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction) -> None: + def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None: self.dispatch_dynamic_items(component_type, custom_id, interaction) interaction_id: Optional[int] = None message_id: Optional[int] = None @@ -1051,7 +1052,7 @@ class ViewStore: def dispatch_modal( self, custom_id: str, - interaction: Interaction, + interaction: Interaction[ClientT], components: List[ModalSubmitComponentInteractionDataPayload], resolved: ResolvedDataPayload, ) -> None: From 05816daa7e02f2e033b46a80c8b57af735a0d891 Mon Sep 17 00:00:00 2001 From: Thanos <111999343+Sachaa-Thanasius@users.noreply.github.com> Date: Sun, 22 Feb 2026 16:00:49 -0500 Subject: [PATCH 49/88] Remove black config and transition isort config to ruff --- pyproject.toml | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 1c45d5f7a..d18e35277 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -89,13 +89,12 @@ packages = [ ] include-package-data = true -[tool.black] -line-length = 125 -skip-string-normalization = true - [tool.ruff] line-length = 125 +[tool.ruff.lint.isort] +combine-as-imports = true + [tool.ruff.format] line-ending = "lf" quote-style = "single" @@ -113,12 +112,6 @@ exclude_lines = [ "@overload", ] -[tool.isort] -profile = "black" -combine_as_imports = true -combine_star = true -line_length = 125 - [tool.pyright] include = [ "discord", From e5263c0870d5b4eb604c9380e4f11833bec2b144 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:43:06 +0100 Subject: [PATCH 50/88] Add support for new modal components --- discord/components.py | 362 ++++++++++++++++++++++++++++--- discord/enums.py | 4 + discord/types/components.py | 62 +++++- discord/types/interactions.py | 51 ++++- discord/ui/__init__.py | 2 + discord/ui/checkbox.py | 391 ++++++++++++++++++++++++++++++++++ discord/ui/item.py | 3 + discord/ui/radio.py | 246 +++++++++++++++++++++ discord/ui/select.py | 6 +- docs/interactions/api.rst | 93 ++++++++ 10 files changed, 1169 insertions(+), 51 deletions(-) create mode 100644 discord/ui/checkbox.py create mode 100644 discord/ui/radio.py diff --git a/discord/components.py b/discord/components.py index 06caf24f2..9536e93a3 100644 --- a/discord/components.py +++ b/discord/components.py @@ -73,6 +73,11 @@ if TYPE_CHECKING: UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, FileUploadComponent as FileUploadComponentPayload, + RadioGroupComponent as RadioGroupComponentPayload, + RadioGroupOption as RadioGroupOptionPayload, + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxGroupOption as CheckboxGroupOptionPayload, + CheckboxComponent as CheckboxComponentPayload, ) from .emoji import Emoji @@ -92,6 +97,7 @@ if TYPE_CHECKING: 'SectionComponent', 'Component', ] + OptionPayload = Union[SelectOptionPayload, RadioGroupOptionPayload, CheckboxGroupOptionPayload] __all__ = ( @@ -114,6 +120,11 @@ __all__ = ( 'SeparatorComponent', 'LabelComponent', 'FileUploadComponent', + 'RadioGroupComponent', + 'CheckboxGroupComponent', + 'CheckboxComponent', + 'RadioGroupOption', + 'CheckboxGroupOption', ) @@ -170,6 +181,71 @@ class Component: raise NotImplementedError +class BaseOption: + """Represents a base option for components that have options. + + This currently implements: + + - :class:`SelectOption` + - :class:`RadioGroupOption` + - :class:`CheckboxGroupOption` + + .. versionadded:: 2.7 + """ + + __slots__: Tuple[str, ...] = ('label', 'value', 'description', 'default') + + __repr_info__: ClassVar[Tuple[str, ...]] = ('label', 'value', 'description', 'default') + + def __init__( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + self.label: str = label + self.value: str = label if value is MISSING else value + self.description: Optional[str] = description + self.default: bool = default + + def __repr__(self) -> str: + attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__) + return f'<{self.__class__.__name__} {attrs}>' + + def __str__(self) -> str: + base = self.label + + if self.description: + return f'{base}\n{self.description}' + return base + + @classmethod + def from_dict(cls, data: OptionPayload) -> Self: + return cls( + label=data['label'], + value=data['value'], + description=data.get('description'), + default=data.get('default', False), + ) + + def to_dict(self) -> OptionPayload: + payload: OptionPayload = { + 'label': self.label, + 'value': self.value, + 'default': self.default, + } + + if self.description: + payload['description'] = self.description + + return payload + + def copy(self) -> Self: + return self.__class__.from_dict(self.to_dict()) + + class ActionRow(Component): """Represents a Discord Bot UI Kit Action Row. @@ -416,7 +492,7 @@ class SelectMenu(Component): return payload -class SelectOption: +class SelectOption(BaseOption): """Represents a select menu's option. These can be created by users. @@ -454,13 +530,8 @@ class SelectOption: Whether this option is selected by default. """ - __slots__: Tuple[str, ...] = ( - 'label', - 'value', - 'description', - '_emoji', - 'default', - ) + __slots__: Tuple[str, ...] = BaseOption.__slots__ + ('_emoji',) + __repr_info__ = BaseOption.__repr_info__ + ('emoji',) def __init__( self, @@ -471,18 +542,9 @@ class SelectOption: emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, default: bool = False, ) -> None: - self.label: str = label - self.value: str = label if value is MISSING else value - self.description: Optional[str] = description + super().__init__(label=label, value=value, description=description, default=default) self.emoji = emoji - self.default: bool = default - - def __repr__(self) -> str: - return ( - f'' - ) def __str__(self) -> str: if self.emoji: @@ -512,7 +574,7 @@ class SelectOption: self._emoji = None @classmethod - def from_dict(cls, data: SelectOptionPayload) -> SelectOption: + def from_dict(cls, data: SelectOptionPayload) -> Self: try: emoji = PartialEmoji.from_dict(data['emoji']) # pyright: ignore[reportTypedDictNotRequiredAccess] except KeyError: @@ -522,28 +584,18 @@ class SelectOption: label=data['label'], value=data['value'], description=data.get('description'), - emoji=emoji, default=data.get('default', False), + emoji=emoji, ) def to_dict(self) -> SelectOptionPayload: - payload: SelectOptionPayload = { - 'label': self.label, - 'value': self.value, - 'default': self.default, - } + payload: SelectOptionPayload = super().to_dict() # type: ignore if self.emoji: payload['emoji'] = self.emoji.to_dict() - if self.description: - payload['description'] = self.description - return payload - def copy(self) -> SelectOption: - return self.__class__.from_dict(self.to_dict()) - class TextInput(Component): """Represents a text input from the Discord Bot UI Kit. @@ -1453,6 +1505,248 @@ class FileUploadComponent(Component): return payload +class RadioGroupComponent(Component): + """Represents a radio group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a radio group is + :class:`discord.ui.RadioGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + options: List[:class:`RadioGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: RadioGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.options: List[RadioGroupOption] = [RadioGroupOption.from_dict(option) for option in data.get('options', [])] + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.radio_group + + def to_dict(self) -> RadioGroupComponentPayload: + payload: RadioGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class RadioGroupOption(BaseOption): + """Represents a radio group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxGroupComponent(Component): + """Represents a checkbox group component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox group is + :class:`discord.ui.CheckboxGroup` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + min_values: :class:`int` + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + max_values: :class:`int` + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + options: List[:class:`CheckboxGroupOption`] + A list of options that can be selected in this group. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'required', 'id', 'min_values', 'max_values', 'options') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxGroupComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.required: bool = data.get('required', True) + self.id: Optional[int] = data.get('id') + self.min_values: int = data.get('min_values', 0) + self.max_values: int = data.get('max_values', 1) + self.options: List[CheckboxGroupOption] = [ + CheckboxGroupOption.from_dict(option) for option in data.get('options', []) + ] + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox_group + + def to_dict(self) -> CheckboxGroupComponentPayload: + payload: CheckboxGroupComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + if self.options: + payload['options'] = [option.to_dict() for option in self.options] + + return payload + + +class CheckboxGroupOption(BaseOption): + """Represents a checkbox group's option + + These can be created by users. + + .. versionadded:: 2.7 + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Attributes + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + value: :class:`str` + The value of the option. This is not displayed to users. + If not provided when constructed then it defaults to the + label. + description: Optional[:class:`str`] + An additional description of the option, if any. + default: :class:`bool` + Whether this option is selected by default. + """ + + +class CheckboxComponent(Component): + """Represents a checkbox component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a checkbox is + :class:`discord.ui.Checkbox` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + id: Optional[:class:`int`] + The ID of this component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __slots__: Tuple[str, ...] = ('custom_id', 'default', 'id') + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: CheckboxComponentPayload, /) -> None: + self.custom_id: str = data['custom_id'] + self.id: Optional[int] = data.get('id') + self.default: bool = data.get('default', False) + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.checkbox + + def to_dict(self) -> CheckboxComponentPayload: + payload: CheckboxComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'default': self.default, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1480,3 +1774,9 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return LabelComponent(data, state) elif data['type'] == 19: return FileUploadComponent(data) + elif data['type'] == 21: + return RadioGroupComponent(data) + elif data['type'] == 22: + return CheckboxGroupComponent(data) + elif data['type'] == 23: + return CheckboxComponent(data) diff --git a/discord/enums.py b/discord/enums.py index 260222894..025b54cb4 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -694,6 +694,10 @@ class ComponentType(Enum): container = 17 label = 18 file_upload = 19 + # checkpoint = 20 + radio_group = 21 + checkbox_group = 22 + checkbox = 23 def __int__(self) -> int: return self.value diff --git a/discord/types/components.py b/discord/types/components.py index 5522da38a..0d7b6d80d 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired from .emoji import PartialEmoji from .channel import ChannelType -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19, 21, 22, 23] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -43,6 +43,13 @@ class ComponentBase(TypedDict): type: int +class OptionBase(TypedDict): + label: str + value: str + default: NotRequired[bool] + description: NotRequired[str] + + class ActionRow(ComponentBase): type: Literal[1] components: List[ActionRowChildComponent] @@ -59,11 +66,7 @@ class ButtonComponent(ComponentBase): sku_id: NotRequired[str] -class SelectOption(TypedDict): - label: str - value: str - default: bool - description: NotRequired[str] +class SelectOption(OptionBase): emoji: NotRequired[PartialEmoji] @@ -192,7 +195,7 @@ class LabelComponent(ComponentBase): type: Literal[18] label: str description: NotRequired[str] - component: Union[SelectMenu, TextInput, FileUploadComponent] + component: LabelChildComponent class FileUploadComponent(ComponentBase): @@ -203,6 +206,34 @@ class FileUploadComponent(ComponentBase): required: NotRequired[bool] +class RadioGroupComponent(ComponentBase): + type: Literal[21] + custom_id: str + options: NotRequired[List[RadioGroupOption]] + required: NotRequired[bool] + + +RadioGroupOption = OptionBase + + +class CheckboxGroupComponent(ComponentBase): + type: Literal[22] + custom_id: str + options: NotRequired[List[CheckboxGroupOption]] + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] + + +CheckboxGroupOption = OptionBase + + +class CheckboxComponent(ComponentBase): + type: Literal[23] + custom_id: str + default: NotRequired[bool] + + ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] ContainerChildComponent = Union[ ActionRow, @@ -211,8 +242,21 @@ ContainerChildComponent = Union[ FileComponent, SectionComponent, SectionComponent, - ContainerComponent, SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, LabelComponent, FileUploadComponent, ContainerChildComponent] +LabelChildComponent = Union[ + TextInput, + SelectMenu, + FileUploadComponent, + RadioGroupComponent, + CheckboxGroupComponent, + CheckboxComponent, +] +Component = Union[ + ActionRowChildComponent, + LabelComponent, + LabelChildComponent, + ContainerChildComponent, + ContainerComponent, +] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 6e6d9ef39..463800a90 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -27,7 +27,12 @@ from __future__ import annotations from typing import TYPE_CHECKING, Dict, List, Literal, TypedDict, Union, Optional from typing_extensions import NotRequired -from .channel import ChannelTypeWithoutThread, GuildChannel, InteractionDMChannel, GroupDMChannel +from .channel import ( + ChannelTypeWithoutThread, + GuildChannel, + InteractionDMChannel, + GroupDMChannel, +) from .sku import Entitlement from .threads import ThreadType, ThreadMetadata from .member import Member @@ -223,14 +228,40 @@ class ModalSubmitFileUploadInteractionData(ComponentBase): values: List[str] -ModalSubmitComponentItemInteractionData = Union[ - ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData, ModalSubmitFileUploadInteractionData +class ModalSubmitRadioGroupInteractionData(ComponentBase): + type: Literal[21] + custom_id: str + id: int + value: Optional[str] + + +class ModalSubmitCheckboxGroupInteractionData(ComponentBase): + type: Literal[22] + custom_id: str + id: int + values: List[str] + + +class ModalSubmitCheckboxInteractionData(ComponentBase): + type: Literal[23] + custom_id: str + id: int + value: bool + + +ModalSubmitLabelComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, + ModalSubmitTextInputInteractionData, + ModalSubmitFileUploadInteractionData, + ModalSubmitRadioGroupInteractionData, + ModalSubmitCheckboxGroupInteractionData, + ModalSubmitCheckboxInteractionData, ] class ModalSubmitActionRowInteractionData(TypedDict): type: Literal[1] - components: List[ModalSubmitComponentItemInteractionData] + components: List[ModalSubmitTextInputInteractionData] class ModalSubmitTextDisplayInteractionData(ComponentBase): @@ -240,7 +271,7 @@ class ModalSubmitTextDisplayInteractionData(ComponentBase): class ModalSubmitLabelInteractionData(ComponentBase): type: Literal[18] - component: ModalSubmitComponentItemInteractionData + component: ModalSubmitLabelComponentItemInteractionData ModalSubmitComponentInteractionData = Union[ @@ -301,7 +332,12 @@ class ModalSubmitInteraction(_BaseInteraction): data: ModalSubmitInteractionData -Interaction = Union[PingInteraction, ApplicationCommandInteraction, MessageComponentInteraction, ModalSubmitInteraction] +Interaction = Union[ + PingInteraction, + ApplicationCommandInteraction, + MessageComponentInteraction, + ModalSubmitInteraction, +] class MessageInteraction(TypedDict): @@ -349,7 +385,8 @@ class MessageComponentMessageInteractionMetadata(_MessageInteractionMetadata): class ModalSubmitMessageInteractionMetadata(_MessageInteractionMetadata): type: Literal[5] triggering_interaction_metadata: Union[ - ApplicationCommandMessageInteractionMetadata, MessageComponentMessageInteractionMetadata + ApplicationCommandMessageInteractionMetadata, + MessageComponentMessageInteractionMetadata, ] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 061c1ef60..c5ce5e390 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -26,3 +26,5 @@ from .thumbnail import * from .action_row import * from .label import * from .file_upload import * +from .radio import * +from .checkbox import * diff --git a/discord/ui/checkbox.py b/discord/ui/checkbox.py new file mode 100644 index 000000000..e64895ed2 --- /dev/null +++ b/discord/ui/checkbox.py @@ -0,0 +1,391 @@ +""" +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. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import CheckboxGroupComponent, CheckboxComponent, CheckboxGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitCheckboxGroupInteractionData as ModalSubmitCheckboxGroupInteractionDataPayload, + ModalSubmitCheckboxInteractionData as ModalSubmitCheckboxInteractionDataPayload, + ) + from ..types.components import ( + CheckboxGroupComponent as CheckboxGroupComponentPayload, + CheckboxComponent as CheckboxComponentPayload, + ) + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'CheckboxGroup', + 'Checkbox', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class CheckboxGroup(Item[V]): + """Represents a checkbox group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.CheckboxGroupOption`] + A list of options that can be selected in this checkbox group. + Can only contain up to 10 items. + max_values: Optional[:class:`int`] + The maximum number of options that can be selected in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of options that must be selected in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + options: List[CheckboxGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxGroupComponent = CheckboxGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + min_values=min_values, + max_values=max_values, + ) + self.id = id + self._values: List[str] = [] + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def values(self) -> List[str]: + """List[:class:`str`]: A list of values that have been selected by the user.""" + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox_group + + @property + def options(self) -> List[CheckboxGroupOption]: + """List[:class:`discord.CheckboxGroupOption`]: A list of options that can be selected in this menu.""" + return self._underlying.options + + @options.setter + def options(self, value: List[CheckboxGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, CheckboxGroupOption) for obj in value): + raise TypeError('options must be a list of CheckboxGroupOption') + self._underlying.options = value + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of options that must be selected before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of options that can be selected before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the checkbox group. + + To append a pre-existing :class:`discord.CheckboxGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = CheckboxGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: CheckboxGroupOption) -> None: + """Appends an option to the checkbox group. + + Parameters + ----------- + option: :class:`discord.CheckboxGroupOption` + The option to append to the checkbox group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = data.get('values', []) + + @classmethod + def from_component(cls, component: CheckboxGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + min_values=component.min_values, + max_values=component.max_values, + ) + return self + + def is_dispatchable(self) -> bool: + return False + + +class Checkbox(Item[V]): + """Represents a checkbox component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + default: :class:`bool` + Whether this checkbox is selected by default. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'default', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + default: bool = False, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: CheckboxComponent = CheckboxComponent._raw_construct( + id=id, + custom_id=custom_id, + default=default, + ) + self.id = id + self._value: bool = default + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def value(self) -> bool: + """:class:`bool`: ``True`` if this checkbox was selected, otherwise ``False``.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.checkbox]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.checkbox + + @property + def default(self) -> bool: + """:class:`bool`: Whether this checkbox is selected by default.""" + return self._underlying.default + + @default.setter + def default(self, value: bool) -> None: + self._underlying.default = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> CheckboxComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: CheckboxComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitCheckboxInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value', False) + + @classmethod + def from_component(cls, component: CheckboxComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + default=component.default, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/item.py b/discord/ui/item.py index 4c0dd6110..c6f165d5c 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -87,6 +87,9 @@ class Item(Generic[V]): - :class:`discord.ui.TextDisplay` - :class:`discord.ui.Thumbnail` - :class:`discord.ui.Label` + - :class:`discord.ui.RadioGroup` + - :class:`discord.ui.CheckboxGroup` + - :class:`discord.ui.Checkbox` .. versionadded:: 2.0 """ diff --git a/discord/ui/radio.py b/discord/ui/radio.py new file mode 100644 index 000000000..4c02c6638 --- /dev/null +++ b/discord/ui/radio.py @@ -0,0 +1,246 @@ +""" +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. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import RadioGroupComponent, RadioGroupOption +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..interactions import Interaction + from ..types.interactions import ( + ModalSubmitRadioGroupInteractionData as ModalSubmitRadioGroupInteractionDataPayload, + ) + from ..types.components import RadioGroupComponent as RadioGroupComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'RadioGroup', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class RadioGroup(Item[V]): + """Represents a radio group component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the component. + options: List[:class:`discord.RadioGroupOption`] + A list of options that can be selected in this radio group. + Can contain between 2 and 10 items. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'options', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + options: List[RadioGroupOption] = MISSING, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: RadioGroupComponent = RadioGroupComponent._raw_construct( + id=id, + custom_id=custom_id, + required=required, + options=options or [], + ) + self.id = id + self._value: Optional[str] = None + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def value(self) -> Optional[str]: + """Optional[:class:`str`]: The value have been selected by the user, if any.""" + return self._value + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def type(self) -> Literal[ComponentType.radio_group]: + """:class:`.ComponentType`: The type of this component.""" + return ComponentType.radio_group + + @property + def options(self) -> List[RadioGroupOption]: + """List[:class:`discord.RadioGroupOption`]: A list of options that can be selected in this radio group.""" + return self._underlying.options + + @options.setter + def options(self, value: List[RadioGroupOption]) -> None: + if not isinstance(value, list) or not all(isinstance(obj, RadioGroupOption) for obj in value): + raise TypeError('options must be a list of RadioGroupOption') + + self._underlying.options = value + + def add_option( + self, + *, + label: str, + value: str = MISSING, + description: Optional[str] = None, + default: bool = False, + ) -> None: + """Adds an option to the group. + + To append a pre-existing :class:`discord.RadioGroupOption` use the + :meth:`append_option` method instead. + + Parameters + ----------- + label: :class:`str` + The label of the option. This is displayed to users. + Can only be up to 100 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + If not given, defaults to the label. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 100 characters. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + option = RadioGroupOption( + label=label, + value=value, + description=description, + default=default, + ) + + self.append_option(option) + + def append_option(self, option: RadioGroupOption) -> None: + """Appends an option to the group. + + Parameters + ----------- + option: :class:`discord.RadioGroupOption` + The option to append to the group. + + Raises + ------- + ValueError + The number of options exceeds 10. + """ + + if len(self._underlying.options) >= 10: + raise ValueError('maximum number of options already provided (10)') + + self._underlying.options.append(option) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> RadioGroupComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: RadioGroupComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitRadioGroupInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._value = data.get('value') + + @classmethod + def from_component(cls, component: RadioGroupComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + options=component.options, + required=component.required, + ) + return self + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/select.py b/discord/ui/select.py index b003f8fcb..4c516358a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -506,10 +506,8 @@ class Select(BaseSelect[V]): @options.setter def options(self, value: List[SelectOption]) -> None: - if not isinstance(value, list): + if not isinstance(value, list) or not all(isinstance(obj, SelectOption) for obj in value): raise TypeError('options must be a list of SelectOption') - if not all(isinstance(obj, SelectOption) for obj in value): - raise TypeError('all list items must subclass SelectOption') self._underlying.options = value @@ -576,7 +574,7 @@ class Select(BaseSelect[V]): """ if len(self._underlying.options) >= 25: - raise ValueError('maximum number of options already provided') + raise ValueError('maximum number of options already provided (25)') self._underlying.options.append(option) diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 107e4e2e4..2a5543c60 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -202,6 +202,33 @@ FileUploadComponent :members: :inherited-members: +RadioGroupComponent +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupComponent + +.. autoclass:: RadioGroupComponent() + :members: + :inherited-members: + +CheckboxComponent +~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxComponent + +.. autoclass:: CheckboxComponent() + :members: + :inherited-members: + +CheckboxGroupComponent +~~~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupComponent + +.. autoclass:: CheckboxGroupComponent() + :members: + :inherited-members: + AppCommand ~~~~~~~~~~~ @@ -330,6 +357,21 @@ MediaGalleryItem .. autoclass:: MediaGalleryItem :members: +RadioGroupOption +~~~~~~~~~~~~~~~~ + +.. attributetable:: RadioGroupOption + +.. autoclass:: RadioGroupOption() + :members: + +CheckboxGroupOption +~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: CheckboxGroupOption + +.. autoclass:: CheckboxGroupOption() + :members: Enumerations ------------- @@ -494,6 +536,24 @@ Enumerations Represents a file upload component, usually in a modal. .. versionadded:: 2.7 + + .. attribute:: radio_group + + Represents a radio group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox_group + + Represents a checkbox group component. + + .. versionadded:: 2.7 + + .. attribute:: checkbox + + Represents a checkbox component. + + .. versionadded:: 2.7 .. class:: ButtonStyle @@ -882,6 +942,39 @@ FileUpload :inherited-members: :exclude-members: callback, interaction_check +RadioGroup +~~~~~~~~~~~ + +.. attributetable:: discord.ui.RadioGroup + +.. autoclass:: discord.ui.RadioGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +Checkbox +~~~~~~~~~ + +.. attributetable:: discord.ui.Checkbox + +.. autoclass:: discord.ui.Checkbox + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + +CheckboxGroup +~~~~~~~~~~~~~~ + +.. attributetable:: discord.ui.CheckboxGroup + +.. autoclass:: discord.ui.CheckboxGroup + :members: + :inherited-members: + :exclude-members: callback, interaction_check + + .. _discord_app_commands: Application Commands From 46000f78c70a74ebe018c4099af2582acd2786e3 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 23:43:44 +0200 Subject: [PATCH 51/88] Add guild and user context to autocomplete logs --- discord/app_commands/tree.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index aa446a01f..ce756306a 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -1289,7 +1289,12 @@ class CommandTree(Generic[ClientT]): await command._invoke_autocomplete(interaction, focused, namespace) except Exception: # Suppress exception since it can't be handled anyway. - _log.exception('Ignoring exception in autocomplete for %r', command.qualified_name) + _log.exception( + 'Ignoring exception in autocomplete for %r (Guild: %s, User: %s)', + command.qualified_name, + interaction.guild_id, + interaction.user.id, + ) return From 60e746ca943fd093af7502a9d397c4b7832f1064 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 23:44:08 +0200 Subject: [PATCH 52/88] Exclude category property from CategoryChannel docs --- docs/api.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/api.rst b/docs/api.rst index 495636b12..ad86df8a2 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5453,6 +5453,7 @@ CategoryChannel .. autoclass:: CategoryChannel() :members: :inherited-members: + :exclude-members: category DMChannel ~~~~~~~~~ From 93fa3cb9d65be3ce6815ae2b6ae6f3e262237895 Mon Sep 17 00:00:00 2001 From: DA344 <108473820+DA-344@users.noreply.github.com> Date: Sun, 22 Feb 2026 22:45:49 +0100 Subject: [PATCH 53/88] Fix (Sync)Webhook.edit_message missing the view parameter --- discord/webhook/async_.py | 38 +++++++++++++++++++++++++------------- discord/webhook/sync.py | 37 +++++++++++++++++++++++++++++++++++-- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/discord/webhook/async_.py b/discord/webhook/async_.py index 9d4fa0da6..5768c7200 100644 --- a/discord/webhook/async_.py +++ b/discord/webhook/async_.py @@ -364,6 +364,7 @@ class AsyncWebhookAdapter: multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[Sequence[File]] = None, thread_id: Optional[int] = None, + with_components: bool = False, ) -> Response[MessagePayload]: route = Route( 'PATCH', @@ -372,7 +373,9 @@ class AsyncWebhookAdapter: webhook_token=token, message_id=message_id, ) - params = None if thread_id is None else {'thread_id': thread_id} + params = {'with_components': int(with_components)} + if thread_id: + params['thread_id'] = thread_id return self.request( route, session=session, @@ -848,7 +851,15 @@ class WebhookMessage(Message): See :meth:`.abc.Messageable.send` for more information. view: Optional[:class:`~discord.ui.View`] The updated view to update this message with. If ``None`` is passed then - the view is removed. + the view is removed. If the webhook is partial or is not managed by the + library, then you can not send interactable components. Otherwise, you + can send views with any type of components. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. .. versionadded:: 2.0 @@ -1772,7 +1783,7 @@ class Webhook(BaseWebhook): .. versionadded:: 1.4 view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] The view to send with the message. If the webhook is partial or - is not managed by the library, then you can only send URL buttons. + is not managed by the library, then you can not send interactable components. Otherwise, you can send views with any type of components. .. versionadded:: 2.0 @@ -1857,12 +1868,10 @@ class Webhook(BaseWebhook): if view is not MISSING: if not hasattr(view, '__discord_ui_view__'): - raise TypeError(f'expected view parameter to be of type View not {view.__class__.__name__}') + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') if isinstance(self._state, _WebhookState) and view.is_dispatchable(): - raise ValueError( - 'Webhook views with any component other than URL buttons require an associated state with the webhook' - ) + raise ValueError('Webhook views with interactable components require an associated state with the webhook') if ephemeral is True and view.timeout is None and view.is_dispatchable(): view.timeout = 15 * 60.0 @@ -2048,8 +2057,9 @@ class Webhook(BaseWebhook): See :meth:`.abc.Messageable.send` for more information. view: Optional[Union[:class:`~discord.ui.View`, :class:`~discord.ui.LayoutView`]] The updated view to update this message with. If ``None`` is passed then - the view is removed. The webhook must have state attached, similar to - :meth:`send`. + the view is removed. If the webhook is partial or is not managed by the + library, then you can not send interactable components. Otherwise, you + can send views with any type of components. .. note:: @@ -2085,11 +2095,12 @@ class Webhook(BaseWebhook): if self.token is None: raise ValueError('This webhook does not have a token associated with it') - if view is not MISSING: - if isinstance(self._state, _WebhookState): - raise ValueError('This webhook does not have state associated with it') + if view: + if not hasattr(view, '__discord_ui_view__'): + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') - self._state.prevent_view_updates_for(message_id) + if isinstance(self._state, _WebhookState) and view.is_dispatchable(): + raise ValueError('Webhook views with interactable components require an associated state with the webhook') previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None) with handle_message_parameters( @@ -2117,6 +2128,7 @@ class Webhook(BaseWebhook): multipart=params.multipart, files=params.files, thread_id=thread_id, + with_components=bool(view), ) message = self._create_message(data, thread=thread) diff --git a/discord/webhook/sync.py b/discord/webhook/sync.py index 1786496fa..b76af8337 100644 --- a/discord/webhook/sync.py +++ b/discord/webhook/sync.py @@ -329,6 +329,7 @@ class WebhookAdapter: multipart: Optional[List[Dict[str, Any]]] = None, files: Optional[Sequence[File]] = None, thread_id: Optional[int] = None, + with_components: bool = False, ) -> MessagePayload: route = Route( 'PATCH', @@ -337,7 +338,9 @@ class WebhookAdapter: webhook_token=token, message_id=message_id, ) - params = None if thread_id is None else {'thread_id': thread_id} + params = {'with_components': int(with_components)} + if thread_id: + params['thread_id'] = thread_id return self.request(route, session, payload=payload, multipart=multipart, files=files, params=params) def delete_webhook_message( @@ -415,6 +418,7 @@ class SyncWebhookMessage(Message): embed: Optional[Embed] = MISSING, attachments: Sequence[Union[Attachment, File]] = MISSING, allowed_mentions: Optional[AllowedMentions] = None, + view: Optional[BaseView] = MISSING, ) -> SyncWebhookMessage: """Edits the message. @@ -443,6 +447,19 @@ class SyncWebhookMessage(Message): allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. See :meth:`.abc.Messageable.send` for more information. + view: Union[:class:`discord.ui.View`, :class:`discord.ui.LayoutView`] + The updated view to update this message with. This can only have non-interactible items, which do not + require a state to be attached to it. If ``None`` is passed then the view is removed. + + If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. + + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + + .. versionadded:: 2.7 Raises ------- @@ -451,7 +468,7 @@ class SyncWebhookMessage(Message): Forbidden Edited a message that is not yours. TypeError - You specified both ``embed`` and ``embeds`` + You specified both ``embed`` and ``embeds``. ValueError The length of ``embeds`` was invalid or there was no token associated with this webhook. @@ -469,6 +486,7 @@ class SyncWebhookMessage(Message): attachments=attachments, allowed_mentions=allowed_mentions, thread=self._state._thread, + view=view, ) def add_files(self, *files: File) -> SyncWebhookMessage: @@ -1245,6 +1263,12 @@ class SyncWebhook(BaseWebhook): If you want to edit a webhook message with any component attached to it, check :meth:`WebhookMessage.edit`. + .. note:: + + To update the message to add a :class:`~discord.ui.LayoutView`, you + must explicitly set the ``content``, ``embed``, ``embeds``, and + ``attachments`` parameters to either ``None`` or an empty array, as appropriate. + .. versionadded:: 2.6 allowed_mentions: :class:`AllowedMentions` Controls the mentions being processed in this message. @@ -1270,6 +1294,13 @@ class SyncWebhook(BaseWebhook): if self.token is None: raise ValueError('This webhook does not have a token associated with it') + if view: + if not hasattr(view, '__discord_ui_view__'): + raise TypeError(f'expected view parameter to be of type View or LayoutView, not {view.__class__.__name__}') + + if view.is_dispatchable(): + raise ValueError('SyncWebhooks can not send interactable components') + previous_mentions: Optional[AllowedMentions] = getattr(self._state, 'allowed_mentions', None) with handle_message_parameters( content=content, @@ -1278,6 +1309,7 @@ class SyncWebhook(BaseWebhook): embeds=embeds, allowed_mentions=allowed_mentions, previous_allowed_mentions=previous_mentions, + view=view, ) as params: thread_id: Optional[int] = None if thread is not MISSING: @@ -1293,6 +1325,7 @@ class SyncWebhook(BaseWebhook): multipart=params.multipart, files=params.files, thread_id=thread_id, + with_components=bool(view), ) return self._create_message(data, thread=thread) From 91f958cbac9185ac84d98984b62538e5a2ff5f26 Mon Sep 17 00:00:00 2001 From: Steve C Date: Sun, 22 Feb 2026 16:46:24 -0500 Subject: [PATCH 54/88] Add missing wait_for overloads for soundboard & voice effects --- discord/client.py | 44 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 88c390be0..2d78fc2f4 100644 --- a/discord/client.py +++ b/discord/client.py @@ -88,7 +88,7 @@ if TYPE_CHECKING: from .abc import Messageable, PrivateChannel, Snowflake, SnowflakeTime from .app_commands import Command, ContextMenu from .automod import AutoModAction, AutoModRule - from .channel import DMChannel, GroupChannel + from .channel import DMChannel, GroupChannel, VoiceChannelEffect from .ext.commands import AutoShardedBot, Bot, Context, CommandError from .guild import GuildChannel from .integrations import Integration @@ -1753,6 +1753,38 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[ScheduledEvent, User]: ... + @overload + async def wait_for( + self, + event: Literal['scheduled_event_update'], + /, + *, + check: Optional[Callable[[ScheduledEvent, ScheduledEvent], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[ScheduledEvent, ScheduledEvent]: ... + + # Soundboard + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_create', 'soundboard_sound_delete'], + /, + *, + check: Optional[Callable[[SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> SoundboardSound: ... + + @overload + async def wait_for( + self, + event: Literal['soundboard_sound_update'], + /, + *, + check: Optional[Callable[[SoundboardSound, SoundboardSound], bool]] = ..., + timeout: Optional[float] = ..., + ) -> Tuple[SoundboardSound, SoundboardSound]: ... + # Stages @overload @@ -1859,6 +1891,16 @@ class Client: timeout: Optional[float] = ..., ) -> Tuple[Member, VoiceState, VoiceState]: ... + @overload + async def wait_for( + self, + event: Literal['voice_channel_effect'], + /, + *, + check: Optional[Callable[[VoiceChannelEffect], bool]] = ..., + timeout: Optional[float] = ..., + ) -> VoiceChannelEffect: ... + # Polls @overload From 38d5d8e47a195cbaa61be2f227e6b6756a7cbf82 Mon Sep 17 00:00:00 2001 From: Michael Date: Sun, 22 Feb 2026 23:47:15 +0200 Subject: [PATCH 55/88] Use walk_children within remove_view --- discord/ui/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 57b0e2229..ed105b5d6 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -913,14 +913,14 @@ class ViewStore: if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view - def remove_view(self, view: View) -> None: + def remove_view(self, view: BaseView) -> None: if view.__discord_ui_modal__: self._modals.pop(view.custom_id, None) # type: ignore return dispatch_info = self._views.get(view._cache_key) if dispatch_info: - for item in view._children: + for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ self._dynamic_items.pop(pattern, None) From f780f044478162c38e86772775a64775737aeffb Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sun, 22 Feb 2026 16:59:18 -0500 Subject: [PATCH 56/88] Update last_send when receiving a HEARTBEAT request --- discord/gateway.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/discord/gateway.py b/discord/gateway.py index 75acf7e98..3f283ef71 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -210,6 +210,10 @@ class KeepAliveHandler(threading.Thread): def tick(self) -> None: self._last_recv = time.perf_counter() + def beat(self) -> Dict[str, Any]: + self._last_send = time.perf_counter() + return self.get_payload() + def ack(self) -> None: ack_time = time.perf_counter() self._last_ack = ack_time @@ -541,7 +545,7 @@ class DiscordWebSocket: if op == self.HEARTBEAT: if self._keep_alive: - beat = self._keep_alive.get_payload() + beat = self._keep_alive.beat() await self.send_as_json(beat) return From 598a16e62f073812816e558629eeddcf2ef6be13 Mon Sep 17 00:00:00 2001 From: Soheab <33902984+Soheab@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:29:20 +0100 Subject: [PATCH 57/88] Add support for getting an integration's scopes --- discord/integrations.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/discord/integrations.py b/discord/integrations.py index 5fd238f55..891bd9d02 100644 --- a/discord/integrations.py +++ b/discord/integrations.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -from typing import Any, Dict, Optional, TYPE_CHECKING, Type, Tuple +from typing import Any, Dict, List, Optional, TYPE_CHECKING, Type, Tuple from .utils import _get_as_snowflake, parse_time, MISSING from .user import User from .enums import try_enum, ExpireBehaviour @@ -98,6 +98,10 @@ class Integration: The account linked to this integration. user: :class:`User` The user that added this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -109,6 +113,7 @@ class Integration: 'account', 'user', 'enabled', + 'scopes', ) def __init__(self, *, data: IntegrationPayload, guild: Guild) -> None: @@ -128,6 +133,7 @@ class Integration: user = data.get('user') self.user: Optional[User] = User(state=self._state, data=user) if user else None self.enabled: bool = data['enabled'] + self.scopes: List[str] = data.get('scopes', []) async def delete(self, *, reason: Optional[str] = None) -> None: """|coro| @@ -184,6 +190,10 @@ class StreamIntegration(Integration): The integration account information. synced_at: :class:`datetime.datetime` An aware UTC datetime representing when the integration was last synced. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ( @@ -352,6 +362,10 @@ class BotIntegration(Integration): The integration account information. application: :class:`IntegrationApplication` The application tied to this integration. + scopes: List[:class:`str`] + The OAuth2 scopes the application has been authorized for. + + .. versionadded:: 2.7 """ __slots__ = ('application',) From fd5a218d7c2c1258aa98ee58f4368dc2a7cc6e3c Mon Sep 17 00:00:00 2001 From: n6ck <90283633+n6ck@users.noreply.github.com> Date: Sun, 22 Feb 2026 19:12:39 -0800 Subject: [PATCH 58/88] Add Message.is_forwardable to check if a message can be forwarded --- discord/message.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/discord/message.py b/discord/message.py index 7b209fc59..19d78dd53 100644 --- a/discord/message.py +++ b/discord/message.py @@ -3051,3 +3051,30 @@ class Message(PartialMessage, Hashable): The newly edited message. """ return await self.edit(attachments=[a for a in self.attachments if a not in attachments]) + + def is_forwardable(self) -> bool: + """:class:`bool`: Whether the message can be forwarded using :meth:`Message.forward`. + + A message is forwardable only if it is a basic message type and does not + contain a poll, call, or activity, and is not a system message. + + .. versionadded:: 2.7 + """ + if self.type not in ( + MessageType.default, + MessageType.reply, + MessageType.chat_input_command, + MessageType.context_menu_command, + ): + return False + + if self.poll is not None: + return False + + if self.call is not None: + return False + + if self.activity is not None: + return False + + return True From 8bad09e1d8defd9fc877bcc8edf675635be73bbd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alex=20N=C3=B8rgaard?= Date: Mon, 23 Feb 2026 03:13:10 +0000 Subject: [PATCH 59/88] Add Discord timestamp converter and transformer --- discord/app_commands/transformers.py | 39 +++++++++++++++++++++++++++- discord/ext/commands/converter.py | 24 +++++++++++++++++ discord/ext/commands/errors.py | 19 ++++++++++++++ discord/utils.py | 1 + docs/ext/commands/api.rst | 5 ++++ docs/interactions/api.rst | 8 ++++++ 6 files changed, 95 insertions(+), 1 deletion(-) diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 212991cbe..531f06e66 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -23,6 +23,7 @@ DEALINGS IN THE SOFTWARE. """ from __future__ import annotations +import datetime import inspect from dataclasses import dataclass @@ -52,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel, from ..abc import GuildChannel from ..threads import Thread from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale -from ..utils import MISSING, maybe_coroutine, _human_join +from ..utils import MISSING, maybe_coroutine, _human_join, TIMESTAMP_PATTERN from ..user import User from ..role import Role from ..member import Member @@ -62,6 +63,7 @@ from .._types import ClientT __all__ = ( 'Transformer', 'Transform', + 'Timestamp', 'Range', ) @@ -681,6 +683,41 @@ class UnionChannelTransformer(BaseChannelTransformer[ClientT]): return resolved +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Transformer[ClientT]): + """A type annotation that can be applied to a parameter for transforming a :ddocs:`Discord style timestamp ` input to a + :class:`datetime.datetime`. + + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + + Examples + --------- + + .. code-block:: python3 + + @app_commands.command() + async def datetime(interaction: discord.Interaction, value: app_commands.Timestamp): + await interaction.response.send_message(value.isoformat()) + """ + + @property + def type(self) -> AppCommandOptionType: + return AppCommandOptionType.string + + async def transform(self, interaction: Interaction[ClientT], value: Any, /): + match = TIMESTAMP_PATTERN.match(value) + if not match: + raise TransformerError(value, AppCommandOptionType.string, self) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + CHANNEL_TO_TYPES: Dict[Any, List[ChannelType]] = { AppCommandChannel: [ ChannelType.stage_voice, diff --git a/discord/ext/commands/converter.py b/discord/ext/commands/converter.py index baf22c626..a4b9b3b7d 100644 --- a/discord/ext/commands/converter.py +++ b/discord/ext/commands/converter.py @@ -24,6 +24,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations +import datetime import inspect import re from typing import ( @@ -86,6 +87,7 @@ __all__ = ( 'clean_content', 'Greedy', 'Range', + 'Timestamp', 'run_converters', ) @@ -893,6 +895,28 @@ class GuildStickerConverter(IDConverter[discord.GuildSticker]): return result +if TYPE_CHECKING: + Timestamp = datetime.datetime +else: + + class Timestamp(Converter[str]): + """Converts to a :class:`datetime.datetime`. + + Conversion is attempted based on the :ddocs:`Discord style timestamp ` input format. + + .. versionadded:: 2.7 + + .. warning:: + Due to a Discord limitation, no timezone is provided with the input. The UTC timezone has been supplanted instead. + """ + + async def convert(self, ctx: Context[BotT], argument: str) -> datetime.datetime: + match = discord.utils.TIMESTAMP_PATTERN.match(argument) + if not match: + raise BadTimestampArgument(argument) + return datetime.datetime.fromtimestamp(int(match[1]), tz=datetime.timezone.utc) + + class ScheduledEventConverter(IDConverter[discord.ScheduledEvent]): """Converts to a :class:`~discord.ScheduledEvent`. diff --git a/discord/ext/commands/errors.py b/discord/ext/commands/errors.py index 97841ec6a..3c8b60181 100644 --- a/discord/ext/commands/errors.py +++ b/discord/ext/commands/errors.py @@ -79,6 +79,7 @@ __all__ = ( 'SoundboardSoundNotFound', 'PartialEmojiConversionFailure', 'BadBoolArgument', + 'BadTimestampArgument', 'MissingRole', 'BotMissingRole', 'MissingAnyRole', @@ -602,6 +603,24 @@ class BadBoolArgument(BadArgument): super().__init__(f'{argument} is not a recognised boolean option') +class BadTimestampArgument(BadArgument): + """Exception raised when a timestamp argument was not convertable. + + This inherits from :exc:`BadArgument` + + .. versionadded:: 2.7 + + Attributes + ----------- + argument: :class:`str` + The datetime/timestamp argument supplied by the caller that was not a valid timestamp format. + """ + + def __init__(self, argument: str) -> None: + self.argument: str = argument + super().__init__(f'{argument} is not a recognised datetime or timestamp option') + + class RangeError(BadArgument): """Exception raised when an argument is out of range. diff --git a/discord/utils.py b/discord/utils.py index ce4b9e396..ba7bdd3e1 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -118,6 +118,7 @@ __all__ = ( DISCORD_EPOCH = 1420070400000 DEFAULT_FILE_SIZE_LIMIT_BYTES = 10485760 +TIMESTAMP_PATTERN: re.Pattern[str] = re.compile(r'') class _MissingSentinel: diff --git a/docs/ext/commands/api.rst b/docs/ext/commands/api.rst index 3da5cae16..7f4dc28c8 100644 --- a/docs/ext/commands/api.rst +++ b/docs/ext/commands/api.rst @@ -536,6 +536,11 @@ Converters .. autoclass:: discord.ext.commands.SoundboardSoundConverter :members: +.. attributetable:: discord.ext.commands.Timestamp + +.. autoclass:: discord.ext.commands.Timestamp + :members: + .. attributetable:: discord.ext.commands.clean_content .. autoclass:: discord.ext.commands.clean_content diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index 2a5543c60..a7a015f3a 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -1169,6 +1169,14 @@ Range .. autoclass:: discord.app_commands.Range :members: +Timestamp +++++++++++ + +.. attributetable:: discord.app_commands.Timestamp + +.. autoclass:: discord.app_commands.Timestamp + :members: + Translations ~~~~~~~~~~~~~ From ef1cb6a089bd84b53bb40f294f5f8b2e3c28c96a Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 23 Feb 2026 02:30:05 -0500 Subject: [PATCH 60/88] Prevent empty dictionaries from being added to the ViewStore Fix #10405 --- discord/ui/view.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index ed105b5d6..8dc6b01aa 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -899,7 +899,7 @@ class ViewStore: self._modals[view.custom_id] = view # type: ignore return - dispatch_info = self._views.setdefault(message_id, {}) + dispatch_info = self._views.get(message_id, {}) is_fully_dynamic = True for item in view.walk_children(): if isinstance(item, DynamicItem): @@ -910,6 +910,9 @@ class ViewStore: is_fully_dynamic = False view._cache_key = message_id + if dispatch_info: + self._views[message_id] = dispatch_info + if message_id is not None and not is_fully_dynamic: self._synced_message_views[message_id] = view @@ -927,8 +930,8 @@ class ViewStore: elif item.is_dispatchable(): dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore - if len(dispatch_info) == 0: - self._views.pop(view._cache_key, None) + if dispatch_info is not None and len(dispatch_info) == 0: + self._views.pop(view._cache_key, None) self._synced_message_views.pop(view._cache_key, None) # type: ignore From 79b709290cfbe7ee9bc7ca861c3db4c65299d9e5 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 24 Feb 2026 09:47:10 -0500 Subject: [PATCH 61/88] Add client parameter to PartialEmoji.from_str Fix #10407 --- discord/partial_emoji.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/discord/partial_emoji.py b/discord/partial_emoji.py index d028244c0..3301864a6 100644 --- a/discord/partial_emoji.py +++ b/discord/partial_emoji.py @@ -39,6 +39,7 @@ __all__ = ( if TYPE_CHECKING: from typing_extensions import Self + from .client import Client from .state import ConnectionState from datetime import datetime from .types.emoji import Emoji as EmojiPayload, PartialEmoji as PartialEmojiPayload @@ -114,7 +115,7 @@ class PartialEmoji(_EmojiTag, AssetMixin): ) @classmethod - def from_str(cls, value: str) -> Self: + def from_str(cls, value: str, *, client: Client = utils.MISSING) -> Self: """Converts a Discord string representation of an emoji to a :class:`PartialEmoji`. The formats accepted are: @@ -132,6 +133,11 @@ class PartialEmoji(_EmojiTag, AssetMixin): ------------ value: :class:`str` The string representation of an emoji. + client: :class:`Client` + The client to initialise this emoji with. This allows it to + attach the client's internal state. + + .. versionadded:: 2.7 Returns -------- @@ -144,8 +150,12 @@ class PartialEmoji(_EmojiTag, AssetMixin): animated = bool(groups['animated']) emoji_id = int(groups['id']) name = groups['name'] + if client is not utils.MISSING: + return cls.with_state(name=name, animated=animated, id=emoji_id, state=client._connection) return cls(name=name, animated=animated, id=emoji_id) + if client is not utils.MISSING: + return cls.with_state(name=value, animated=False, id=None, state=client._connection) return cls(name=value, id=None, animated=False) def to_dict(self) -> EmojiPayload: From 59d7a55a4198f503431f6eeddc532d7a5c72614a Mon Sep 17 00:00:00 2001 From: Steve C Date: Wed, 25 Feb 2026 11:54:11 -0500 Subject: [PATCH 62/88] [tasks] Add overloads --- discord/ext/tasks/__init__.py | 41 ++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index 6ed0273e3..ba11d6322 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -37,6 +37,7 @@ from typing import ( Type, TypeVar, Union, + overload, ) import aiohttp @@ -176,7 +177,7 @@ class Loop(Generic[LF]): if self.count is not None and self.count <= 0: raise ValueError('count must be greater than 0 or None.') - self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) + self.change_interval(seconds=seconds, minutes=minutes, hours=hours, time=time) # type: ignore self._last_iteration_failed = False self._last_iteration: datetime.datetime = MISSING self._next_iteration = None @@ -710,6 +711,22 @@ class Loop(Generic[LF]): ret = sorted(set(ret)) # de-dupe and sort times return ret + @overload + def change_interval( + self, + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + ) -> None: ... + + @overload + def change_interval( + self, + *, + time: Union[datetime.time, Sequence[datetime.time]], + ) -> None: ... + def change_interval( self, *, @@ -777,6 +794,28 @@ class Loop(Generic[LF]): self._handle.recalculate(self._next_iteration) +@overload +def loop( + *, + seconds: float = 0, + minutes: float = 0, + hours: float = 0, + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + +@overload +def loop( + *, + time: Union[datetime.time, Sequence[datetime.time]], + count: Optional[int] = None, + reconnect: bool = True, + name: Optional[str] = None, +) -> Callable[[LF], Loop[LF]]: ... + + def loop( *, seconds: float = MISSING, From 7f9c3b1b4044dbea1a3abeb3e5b23fc308dae26d Mon Sep 17 00:00:00 2001 From: levin <123833241+levinismynameirl@users.noreply.github.com> Date: Wed, 25 Feb 2026 18:03:02 +0100 Subject: [PATCH 63/88] Fix FFmpeg errors not sent to after callback --- discord/errors.py | 10 ++++++ discord/player.py | 79 +++++++++++++++++++++++++++++++++++++++++------ docs/api.rst | 3 ++ 3 files changed, 83 insertions(+), 9 deletions(-) diff --git a/discord/errors.py b/discord/errors.py index c07a7ed15..11f5cfaa2 100644 --- a/discord/errors.py +++ b/discord/errors.py @@ -48,6 +48,7 @@ __all__ = ( 'PrivilegedIntentsRequired', 'InteractionResponded', 'MissingApplicationID', + 'FFmpegProcessError', ) APP_ID_NOT_FOUND = ( @@ -74,6 +75,15 @@ class ClientException(DiscordException): pass +class FFmpegProcessError(ClientException): + """Exception that's raised when an FFmpeg process fails. + + .. versionadded:: 2.7 + """ + + pass + + class GatewayNotFound(DiscordException): """An exception that is raised when the gateway for Discord could not be found""" diff --git a/discord/player.py b/discord/player.py index 6243c0417..2a1fdf95d 100644 --- a/discord/player.py +++ b/discord/player.py @@ -40,7 +40,7 @@ import io from typing import Any, Callable, Generic, IO, Optional, TYPE_CHECKING, Tuple, TypeVar, Union from .enums import SpeakingState -from .errors import ClientException +from .errors import ClientException, FFmpegProcessError from .opus import Encoder as OpusEncoder, OPUS_SILENCE from .oggparse import OggStream from .utils import MISSING @@ -186,6 +186,8 @@ class FFmpegAudio(AudioSource): self._stderr: Optional[IO[bytes]] = None self._pipe_writer_thread: Optional[threading.Thread] = None self._pipe_reader_thread: Optional[threading.Thread] = None + self._current_error: Optional[Exception] = None + self._stopped: bool = False if piping_stdin: n = f'popen-stdin-writer:pid-{self._process.pid}' @@ -212,25 +214,72 @@ class FFmpegAudio(AudioSource): else: return process + def _check_process_returncode(self) -> None: + """Set _current_error if FFmpeg exited with a non-zero code.""" + if self._process is MISSING: + return + + ret = self._process.poll() + if ret is None: + return # still running + + if self._stopped: + return # intentionally stopped + + if ret != 0 and self._current_error is None: + # Only set error once, on first detection + # read stderr if available + stderr_text = None + if self._stderr: + try: + stderr_text = self._stderr.read(8192).decode(errors='ignore') + except Exception: + stderr_text = '' + + stderr_info = stderr_text if stderr_text else '' + self._current_error = FFmpegProcessError(f'FFmpeg exited with code {ret}. Stderr: {stderr_info}') + def _kill_process(self) -> None: + # check if FFmpeg process failed + self._check_process_returncode() + # this function gets called in __del__ so instance attributes might not even exist proc = getattr(self, '_process', MISSING) + # Only proceed if proc is a subprocess.Popen instance if proc is MISSING: return - _log.debug('Preparing to terminate ffmpeg process %s.', proc.pid) + pid = getattr(proc, 'pid', 'unknown') + _log.debug('Preparing to terminate ffmpeg process %s.', pid) try: proc.kill() except Exception: - _log.exception('Ignoring error attempting to kill ffmpeg process %s', proc.pid) + _log.exception('Ignoring error attempting to kill ffmpeg process %s', pid) + + try: + still_running = proc.poll() is None + except Exception: + _log.exception('Error checking poll() on ffmpeg process %s', pid) + still_running = False - if proc.poll() is None: - _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', proc.pid) - proc.communicate() - _log.info('ffmpeg process %s should have terminated with a return code of %s.', proc.pid, proc.returncode) + if still_running: + _log.info('ffmpeg process %s has not terminated. Waiting to terminate...', pid) + try: + proc.communicate() + except Exception: + pass + _log.info( + 'ffmpeg process %s should have terminated with a return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) else: - _log.info('ffmpeg process %s successfully terminated with return code of %s.', proc.pid, proc.returncode) + _log.info( + 'ffmpeg process %s successfully terminated with return code of %s.', + pid, + getattr(proc, 'returncode', 'unknown'), + ) def _pipe_writer(self, source: io.BufferedIOBase) -> None: while self._process: @@ -267,6 +316,7 @@ class FFmpegAudio(AudioSource): return def cleanup(self) -> None: + self._stopped = True self._kill_process() self._process = self._stdout = self._stdin = self._stderr = MISSING @@ -348,6 +398,8 @@ class FFmpegPCMAudio(FFmpegAudio): def read(self) -> bytes: ret = self._stdout.read(OpusEncoder.FRAME_SIZE) if len(ret) != OpusEncoder.FRAME_SIZE: + # Check for FFmpeg process failure when read returns incomplete data + self._check_process_returncode() return b'' return ret @@ -646,7 +698,11 @@ class FFmpegOpusAudio(FFmpegAudio): return codec, bitrate def read(self) -> bytes: - return next(self._packet_iter, b'') + data = next(self._packet_iter, b'') + if not data: + # Check for FFmpeg process failure when read returns empty + self._check_process_returncode() + return data def is_opus(self) -> bool: return True @@ -745,6 +801,11 @@ class AudioPlayer(threading.Thread): data = self.source.read() if not data: + # Check if the source has an error (e.g., from FFmpegAudio process failure) + if self._current_error is None: + source_error = getattr(self.source, '_current_error', None) + if source_error: + self._current_error = source_error self.stop() break diff --git a/docs/api.rst b/docs/api.rst index ad86df8a2..5d816087e 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -6216,6 +6216,8 @@ The following exceptions are thrown by the library. .. autoexception:: MissingApplicationID +.. autoexception:: FFmpegProcessError + .. autoexception:: discord.opus.OpusError .. autoexception:: discord.opus.OpusNotLoaded @@ -6234,6 +6236,7 @@ Exception Hierarchy - :exc:`PrivilegedIntentsRequired` - :exc:`InteractionResponded` - :exc:`MissingApplicationID` + - :exc:`FFmpegProcessError` - :exc:`GatewayNotFound` - :exc:`HTTPException` - :exc:`Forbidden` From 198549822e1be3d15118783d67d82fe7f67b3abf Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 25 Feb 2026 12:08:49 -0500 Subject: [PATCH 64/88] Close websocket when reconnecting websocket during polling Close #10409 --- discord/gateway.py | 1 + 1 file changed, 1 insertion(+) diff --git a/discord/gateway.py b/discord/gateway.py index 3f283ef71..45034d01a 100644 --- a/discord/gateway.py +++ b/discord/gateway.py @@ -654,6 +654,7 @@ class DiscordWebSocket: self._keep_alive.stop() self._keep_alive = None + await self.socket.close(code=4000) if isinstance(e, asyncio.TimeoutError): _log.debug('Timed out receiving packet. Attempting a reconnect.') raise ReconnectWebSocket(self.shard_id) from None From e55b308c1adb705a99bb0b30aa1d6dcc8ce05790 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Thu, 26 Feb 2026 01:33:25 -0500 Subject: [PATCH 65/88] Add AI contribution guideline --- .github/CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 68f037c31..9b631df15 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -34,6 +34,10 @@ If the bug report is missing this information then it'll take us longer to fix t Submitting a pull request is fairly simple, just make sure it focuses on a single aspect and doesn't manage to have scope creep and it's probably good to go. It would be incredibly lovely if the style is consistent to that found in the project. This project follows PEP-8 guidelines (mostly) with a column limit of 125. +### AI Contributions + +This repository does not accept any AI contributions at all. Using tools like Claude Code, Copilot, Gemini, ChatGPT, OpenAI Codex, etc. are simply blanket banned. AI contributions are typically nonsensical and just take up very valuable review time and thus are banned. Pull requests that are made with AI tools will be instantly closed without review, no matter how small the changeset is. + ### Git Commit Guidelines - Use present tense (e.g. "Add feature" not "Added feature") From 8c232c1cbd0237b57b0c038b79e58f5352f9dc76 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 27 Feb 2026 13:48:51 -0500 Subject: [PATCH 66/88] Add v2.7.0 changelog --- docs/whats_new.rst | 67 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 33b6ec090..6ca40e094 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,73 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p7p0: + +v2.7.0 +------- + +New Features +~~~~~~~~~~~~~ + +- Add DAVE protocol support for voice connections (:issue:`10300`) +- Add support for new :class:`ui.Modal` components (:issue:`10390`) + - :class:`CheckboxGroupComponent` corresponds to :class:`ui.CheckboxGroup` + - :class:`CheckboxComponent` corresponds to :class:`ui.Checkbox` + - :class:`RadioGroupComponent` corresponds to :class:`ui.RadioGroup` + - :class:`CheckboxGroupOption` and :class:`RadioGroupOption` allow creating these options + +- Add timestamp converter and transformer for use with new ``@time`` markdown option (:issue:`10388`) + - This is accessible via :class:`app_commands.Timestamp` and :class:`ext.commands.Timestamp` as an annotation + +- Add several new permissions: + - :attr:`Permissions.bypass_slowmode` (:issue:`10350`) + - :attr:`Permissions.set_voice_channel_status` (:issue:`10279`) + - :attr:`Permissions.pin_messages` + +- Add ``client`` parameter to :meth:`PartialEmoji.from_str` (:issue:`10407`) +- Add support for user collectibles accessible via :attr:`User.collectibles` and :attr:`Member.collectibles` (:issue:`10277`) +- Add :meth:`Message.is_forwardable` to check if a message can be forwarded (:issue:`10353`) +- Add support for getting an integration's scopes (:issue:`10352`) +- Add :attr:`Interaction.command_id` and :attr:`Interaction.custom_id` helpers (:issue:`10321`) +- Support new fields in :meth:`Member.edit` (:issue:`10303`) +- Add support for getting role member counts via :meth:`Guild.role_member_counts` +- Add :attr:`MessageType.is_deletable` +- Add ``reason`` keyword argument to :meth:`Client.delete_invite` (:issue:`10318`, :issue:`10340`) +- Add ``silent`` parameter to :meth:`ForumChannel.create_thread` (:issue:`10304`) +- Add support for :attr:`MessageType.emoji_added` (:issue:`10284`) +- Add channel attribute to automod quarantine user AuditLogAction (:issue:`10274`) + +Bug Fixes +~~~~~~~~~~ + +- Fix FFmpeg errors not sent to after callback (:issue:`10387`) +- Fix :meth:`Webhook.edit_message` missing the view parameter (:issue:`10395`, :issue:`10398`) +- Fix :meth:`TextChannel.purge` failing when encountering certain system messages +- Fix :attr:`Message.call` raising an attribute error when accessed (:issue:`10404`) +- Fix certain component IDs not being able to be settable afterwards +- Fix :class:`ui.Modal` not raising when hitting the 5 item limit +- Fix :attr:`ui.Item.row` not being set appropriately when used in a :class:`ui.Modal` (:issue:`10397`) +- Fix ``compression.zstd`` not working as expected when Discord does not send encoding information (:issue:`10344`) +- Fix rare bug where :attr:`Client.latency` was incorrect due to not updating heartbeat state +- Fix overzealous exporting of symbols within an internal ``primary_guild`` module (:issue:`10295`) +- Close websocket when reconnecting websocket during polling (:issue:`10409`) +- Use :meth:`ui.View.walk_children` when removing items from the view cache (:issue:`10402`) +- |commands| Fix flag annotations not working under Python 3.14 +- |commands| Fix decorator order mattering for hybrid commands +- |commands| Fix :meth:`~ext.commands.Context.from_interaction` derived :attr:`Message.type` being incorrect + +Miscellaneous +~~~~~~~~~~~~~~ + +- Allow :class:`ui.View` initialization without a running event loop (:issue:`10367`) +- Optimise :func:`utils.find` and specialise :func:`utils.as_chunks` (:issue:`10351`) +- Detach :attr:`ui.Item.view` when the item is removed (:issue:`10348`) +- Change ``description`` to be optional when creating emoji (:issue:`10346`) +- Don't assume Python 3.14 always has ``compression.zstd`` (:issue:`10328`) +- Use webp as the default emoji URL format +- |tasks| Log handled exceptions before sleeping + + .. _vp2p6p4: v2.6.4 From 5df6e2230a8ffa5211eb49b8d690d86126c1121e Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 27 Feb 2026 13:49:19 -0500 Subject: [PATCH 67/88] Version bump to v2.7.0 --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index 3279f8b8c..c3819cefe 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.7.0a' +__version__ = '2.7.0' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='final', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From 9798e5921abb818c0f020b2e7773fcede26b82b4 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 27 Feb 2026 13:49:41 -0500 Subject: [PATCH 68/88] Version bump for development --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index c3819cefe..bfa652b91 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.7.0' +__version__ = '2.8.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=0, releaselevel='final', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From 616137875b2c4ab442f5cdb75874d5ee77d6d7ef Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 2 Mar 2026 11:31:05 -0500 Subject: [PATCH 69/88] Fix memory leak with the view store when removing items The previous code would maintain items in the dispatch mapping if nested children were removed between calls because it would only remove items that are live in the view at the point of removal. This meant that calling something like ActionRow.clear_items() would keep all the removed items within the mapping and would not be evicted. This attempts to fix it by maintaining a cache state snapshot and making a diff between the two versions to know which keys are now safe to delete since they are no longer in the live view at all. --- discord/ui/view.py | 64 +++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 57 insertions(+), 7 deletions(-) diff --git a/discord/ui/view.py b/discord/ui/view.py index 8dc6b01aa..d7168d95c 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -207,6 +207,24 @@ class _ViewWeights: self.weights = [0, 0, 0, 0, 0] +class _ViewCacheSnapshot: + __slots__ = ('items', 'dynamic_items') + + def __init__(self) -> None: + self.items: Set[Tuple[int, str]] = set() + self.dynamic_items: Set[re.Pattern[str]] = set() + + @classmethod + def diff(cls, older: _ViewCacheSnapshot, newer: _ViewCacheSnapshot) -> Self: + self = cls() + self.items = older.items - newer.items + self.dynamic_items = older.dynamic_items - newer.dynamic_items + return self + + def __repr__(self) -> str: + return f'<_ViewCacheSnapshot items={self.items!r} dynamic_items={self.dynamic_items!r}>' + + class BaseView: __discord_ui_view__: ClassVar[bool] = False __discord_ui_modal__: ClassVar[bool] = False @@ -220,6 +238,7 @@ class BaseView: self.__cancel_callback: Optional[Callable[[BaseView], None]] = None self.__timeout_expiry: Optional[float] = None self.__timeout_task: Optional[asyncio.Task[None]] = None + self.__snapshot: Optional[_ViewCacheSnapshot] = None try: loop = asyncio.get_running_loop() @@ -326,6 +345,31 @@ class BaseView: def _add_count(self, value: int) -> None: self._total_children = max(0, self._total_children + value) + @property + def _snapshot(self) -> Optional[_ViewCacheSnapshot]: + return self.__snapshot + + def _get_snapshot_diff(self) -> Optional[_ViewCacheSnapshot]: + if self.__snapshot is None: + self.__snapshot = self._get_snapshot() + return None + + newer = self._get_snapshot() + diff = _ViewCacheSnapshot.diff(older=self.__snapshot, newer=newer) + # Update our snapshot to the newer version after diffing it + self.__snapshot = newer + return diff + + def _get_snapshot(self) -> _ViewCacheSnapshot: + snapshot = _ViewCacheSnapshot() + for item in self.walk_children(): + if isinstance(item, DynamicItem): + snapshot.dynamic_items.add(item.__discord_ui_compiled_template__) + elif item.is_dispatchable(): + custom_id = item.custom_id # type: ignore + snapshot.items.add((item.type.value, custom_id)) + return snapshot + @property def children(self) -> List[Item[Self]]: """List[:class:`Item`]: The list of children attached to this view.""" @@ -901,6 +945,7 @@ class ViewStore: dispatch_info = self._views.get(message_id, {}) is_fully_dynamic = True + snapshot = view._get_snapshot_diff() for item in view.walk_children(): if isinstance(item, DynamicItem): pattern = item.__discord_ui_compiled_template__ @@ -909,6 +954,12 @@ class ViewStore: dispatch_info[(item.type.value, item.custom_id)] = item # type: ignore is_fully_dynamic = False + if snapshot is not None: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) + view._cache_key = message_id if dispatch_info: self._views[message_id] = dispatch_info @@ -922,13 +973,12 @@ class ViewStore: return dispatch_info = self._views.get(view._cache_key) - if dispatch_info: - for item in view.walk_children(): - if isinstance(item, DynamicItem): - pattern = item.__discord_ui_compiled_template__ - self._dynamic_items.pop(pattern, None) - elif item.is_dispatchable(): - dispatch_info.pop((item.type.value, item.custom_id), None) # type: ignore + snapshot = view._snapshot + if dispatch_info and snapshot: + for key in snapshot.items: + dispatch_info.pop(key, None) + for key in snapshot.dynamic_items: + self._dynamic_items.pop(key, None) if dispatch_info is not None and len(dispatch_info) == 0: self._views.pop(view._cache_key, None) From b01de35fa284d7206987bc31e7faeeebe9251498 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Mon, 2 Mar 2026 12:29:02 -0500 Subject: [PATCH 70/88] Remove old workaround to /callback not having a return type The old code needed a workaround using interaction_id to differentiate between multiple instances being reused since they would all go into the `None` key. Since /callback now returns a proper message_id this could be used as a key instead of None. From testing, it seems this is true for both edit_message and send_message responses. --- discord/interactions.py | 49 ++++++++++++++++++----------------------- discord/message.py | 6 +---- discord/state.py | 4 +--- discord/ui/view.py | 23 ------------------- 4 files changed, 23 insertions(+), 59 deletions(-) diff --git a/discord/interactions.py b/discord/interactions.py index 2724b38b8..ebcec1a6b 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -615,7 +615,7 @@ class Interaction(Generic[ClientT]): state = _InteractionMessageState(self, self._state) message = InteractionMessage(state=state, channel=self.channel, data=data) # type: ignore if view and not view.is_finished() and view.is_dispatchable(): - self._state.store_view(view, message.id, interaction_id=self.id) + self._state.store_view(view, message.id) return message async def delete_original_response(self) -> None: @@ -1082,7 +1082,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1090,17 +1090,19 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.channel_message + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view is not MISSING and not view.is_finished(): if ephemeral and view.timeout is None: view.timeout = 15 * 60.0 - # If the interaction type isn't an application command then there's no way - # to obtain this interaction_id again, so just default to None - entity_id = parent.id if parent.type is InteractionType.application_command else None - self._parent._state.store_view(view, entity_id) - - self._response_type = InteractionResponseType.channel_message + self._parent._state.store_view(view, response.message_id) if delete_after is not None: @@ -1113,12 +1115,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def edit_message( self, @@ -1205,12 +1202,8 @@ class InteractionResponse(Generic[ClientT]): state = parent._state if msg is not None: message_id = msg.id - # If this was invoked via an application command then we can use its original interaction ID - # Since this is used as a cache key for view updates - original_interaction_id = msg.interaction_metadata.id if msg.interaction_metadata is not None else None else: message_id = None - original_interaction_id = None if parent.type not in (InteractionType.component, InteractionType.modal_submit): return @@ -1238,7 +1231,7 @@ class InteractionResponse(Generic[ClientT]): ) http = parent._state.http - response = await adapter.create_interaction_response( + data = await adapter.create_interaction_response( parent.id, parent.token, session=parent._session, @@ -1246,11 +1239,16 @@ class InteractionResponse(Generic[ClientT]): proxy_auth=http.proxy_auth, params=params, ) + self._response_type = InteractionResponseType.message_update + response = InteractionCallbackResponse( + data=data, + parent=self._parent, + state=self._parent._state, + type=self._response_type, + ) if view and not view.is_finished() and view.is_dispatchable(): - state.store_view(view, message_id, interaction_id=original_interaction_id) - - self._response_type = InteractionResponseType.message_update + state.store_view(view, message_id or response.message_id) if delete_after is not None: @@ -1263,12 +1261,7 @@ class InteractionResponse(Generic[ClientT]): asyncio.create_task(inner_call()) - return InteractionCallbackResponse( - data=response, - parent=self._parent, - state=self._parent._state, - type=self._response_type, - ) + return response async def send_modal(self, modal: Modal, /) -> InteractionCallbackResponse[ClientT]: """|coro| diff --git a/discord/message.py b/discord/message.py index 19d78dd53..5192a54d2 100644 --- a/discord/message.py +++ b/discord/message.py @@ -1415,11 +1415,7 @@ class PartialMessage(Hashable): message = Message(state=self._state, channel=self.channel, data=data) if view and not view.is_finished() and view.is_dispatchable(): - interaction: Optional[MessageInteractionMetadata] = getattr(self, 'interaction_metadata', None) - if interaction is not None: - self._state.store_view(view, self.id, interaction_id=interaction.id) - else: - self._state.store_view(view, self.id) + self._state.store_view(view, self.id) if delete_after is not None: await self.delete(delay=delete_after) diff --git a/discord/state.py b/discord/state.py index 7ef3bbd15..f2a610bdf 100644 --- a/discord/state.py +++ b/discord/state.py @@ -412,9 +412,7 @@ class ConnectionState(Generic[ClientT]): self._stickers[sticker_id] = sticker = GuildSticker(state=self, data=data) return sticker - def store_view(self, view: BaseView, message_id: Optional[int] = None, interaction_id: Optional[int] = None) -> None: - if interaction_id is not None: - self._view_store.remove_interaction_mapping(interaction_id) + def store_view(self, view: BaseView, message_id: Optional[int] = None) -> None: self._view_store.add_view(view, message_id) def prevent_view_updates_for(self, message_id: int) -> Optional[BaseView]: diff --git a/discord/ui/view.py b/discord/ui/view.py index d7168d95c..582c619d7 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -1053,15 +1053,12 @@ class ViewStore: def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction[ClientT]) -> None: self.dispatch_dynamic_items(component_type, custom_id, interaction) - interaction_id: Optional[int] = None message_id: Optional[int] = None # Realistically, in a component based interaction the Interaction.message will never be None # However, this guard is just in case Discord screws up somehow msg = interaction.message if msg is not None: message_id = msg.id - if msg.interaction_metadata: - interaction_id = msg.interaction_metadata.id key = (component_type, custom_id) @@ -1070,21 +1067,6 @@ class ViewStore: if message_id is not None: item = self._views.get(message_id, {}).get(key) - if item is None and interaction_id is not None: - try: - items = self._views.pop(interaction_id) - except KeyError: - item = None - else: - item = items.get(key) - # If we actually got the items, then these keys should probably be moved - # to the proper message_id instead of the interaction_id as they are now. - # An interaction_id is only used as a temporary stop gap for - # InteractionResponse.send_message so multiple view instances do not - # override each other. - # NOTE: Fix this mess if /callback endpoint ever gets proper return types - self._views.setdefault(message_id, {}).update(items) - if item is None: # Fallback to None message_id searches in case a persistent view # was added without an associated message_id @@ -1116,11 +1098,6 @@ class ViewStore: self.add_task(modal._dispatch_submit(interaction, components, resolved)) - def remove_interaction_mapping(self, interaction_id: int) -> None: - # This is called before re-adding the view - self._views.pop(interaction_id, None) - self._synced_message_views.pop(interaction_id, None) - def is_message_tracked(self, message_id: int) -> bool: return message_id in self._synced_message_views From 699a97f3eb3fbb90b9b019ef15f5d770838d24af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=82=B3=E2=B1=A0=E2=82=A5=C3=98=E2=82=B2?= <47316655+SolitudePy@users.noreply.github.com> Date: Mon, 2 Mar 2026 22:11:04 +0200 Subject: [PATCH 71/88] Fix aiohttp websocket timeout deprecation warning --- discord/http.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/discord/http.py b/discord/http.py index 0219c1196..05c1e69fc 100644 --- a/discord/http.py +++ b/discord/http.py @@ -551,11 +551,16 @@ class HTTPClient: self.__session = MISSING async def ws_connect(self, url: str, *, compress: int = 0) -> aiohttp.ClientWebSocketResponse: + try: + timeout: Any = aiohttp.ClientWSTimeout(ws_close=30.0) # pyright: ignore[reportCallIssue] + except (AttributeError, TypeError): + timeout = 30.0 + kwargs = { 'proxy_auth': self.proxy_auth, 'proxy': self.proxy, 'max_msg_size': 0, - 'timeout': 30.0, + 'timeout': timeout, 'autoclose': False, 'headers': { 'User-Agent': self.user_agent, From 2383467ccda0323ec7935517e6c870d6bd63bcbd Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:00:45 -0500 Subject: [PATCH 72/88] Show davey version in --version output --- discord/__main__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/discord/__main__.py b/discord/__main__.py index 455c5e8ed..1b8d50211 100644 --- a/discord/__main__.py +++ b/discord/__main__.py @@ -48,6 +48,14 @@ def show_version() -> None: entries.append(f' - discord.py metadata: v{version}') entries.append(f'- aiohttp v{aiohttp.__version__}') + + try: + import davey # type: ignore + except ImportError: + entries.append('- davey not found') + else: + entries.append(f'- davey v{davey.__version__}') + uname = platform.uname() entries.append('- system info: {0.system} {0.release} {0.version}'.format(uname)) print('\n'.join(entries)) From 92c715e388900b9a3ce0f2cc52d30fe4f45aee65 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:11:15 -0500 Subject: [PATCH 73/88] Unpin strict davey dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index d18e35277..8323381f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dependencies = { file = "requirements.txt" } [project.optional-dependencies] voice = [ "PyNaCl>=1.5.0,<1.6", - "davey==0.1.0" + "davey>=0.1.0" ] docs = [ "sphinx==4.4.0", From 9cf04aec3c9bd621367e5f653f6b1f9dd19e6eeb Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:27:13 -0500 Subject: [PATCH 74/88] Add warning and raise if davey is not installed --- discord/client.py | 4 ++++ discord/voice_client.py | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/discord/client.py b/discord/client.py index 2d78fc2f4..1c1ed9f48 100644 --- a/discord/client.py +++ b/discord/client.py @@ -340,6 +340,10 @@ class Client: VoiceClient.warn_nacl = False _log.warning('PyNaCl is not installed, voice will NOT be supported') + if VoiceClient.warn_dave: + VoiceClient.warn_dave = False + _log.warning('davey is not installed, voice will NOT be supported') + async def __aenter__(self) -> Self: await self._async_setup_hook() return self diff --git a/discord/voice_client.py b/discord/voice_client.py index 3b489b7d0..f138a8c8a 100644 --- a/discord/voice_client.py +++ b/discord/voice_client.py @@ -34,7 +34,7 @@ from .gateway import * from .errors import ClientException from .player import AudioPlayer, AudioSource from .utils import MISSING -from .voice_state import VoiceConnectionState +from .voice_state import VoiceConnectionState, has_dave if TYPE_CHECKING: from .gateway import DiscordVoiceWebSocket @@ -218,6 +218,8 @@ class VoiceClient(VoiceProtocol): def __init__(self, client: Client, channel: abc.Connectable) -> None: if not has_nacl: raise RuntimeError('PyNaCl library needed in order to use voice') + if not has_dave: + raise RuntimeError('davey library needed in order to use voice') super().__init__(client, channel) state = client._connection @@ -235,6 +237,7 @@ class VoiceClient(VoiceProtocol): self._connection: VoiceConnectionState = self.create_connection_state() warn_nacl: bool = not has_nacl + warn_dave: bool = not has_dave supported_modes: Tuple[SupportedModes, ...] = ( 'aead_xchacha20_poly1305_rtpsize', 'xsalsa20_poly1305_lite', From 64fcfe93333acecedf4eba82457fda6ec21c11f1 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:35:17 -0500 Subject: [PATCH 75/88] Add changelog for v2.7.1 --- docs/whats_new.rst | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/whats_new.rst b/docs/whats_new.rst index 6ca40e094..cda9eca44 100644 --- a/docs/whats_new.rst +++ b/docs/whats_new.rst @@ -11,6 +11,24 @@ Changelog This page keeps a detailed human friendly rendering of what's new and changed in specific versions. +.. _vp2p7p1: + +v2.7.1 +------- + +Bug Fixes +~~~~~~~~~~ + +- Fix memory leak when using :class:`ui.LayoutView` and removing items but those items not being removed from internal cache. +- Fix ``aiohttp`` deprecation warning for websocket timeouts (:issue:`10418`) + +Miscellaneous +~~~~~~~~~~~~~~ + +- Show ``davey`` dependency output in ``python -m discord --version`` to debug DAVE issues +- Raise an error and warn when ``davey`` is not installed and using voice +- Change how views are bound to the internal cache when using interactions + .. _vp2p7p0: v2.7.0 From dfd1144b2246a7adafe3f1c64a4dd9bc2187fcee Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:38:56 -0500 Subject: [PATCH 76/88] Version bump to v2.7.1 --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index bfa652b91..f8526994a 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.8.0a' +__version__ = '2.7.1' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=1, releaselevel='final', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From 41e6639bbc5e3357c1b6e8fded69d4f00b547314 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 13:41:03 -0500 Subject: [PATCH 77/88] Version bump for development --- discord/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/__init__.py b/discord/__init__.py index f8526994a..bfa652b91 100644 --- a/discord/__init__.py +++ b/discord/__init__.py @@ -13,7 +13,7 @@ __title__ = 'discord' __author__ = 'Rapptz' __license__ = 'MIT' __copyright__ = 'Copyright 2015-present Rapptz' -__version__ = '2.7.1' +__version__ = '2.8.0a' __path__ = __import__('pkgutil').extend_path(__path__, __name__) @@ -86,7 +86,7 @@ class VersionInfo(NamedTuple): serial: int -version_info: VersionInfo = VersionInfo(major=2, minor=7, micro=1, releaselevel='final', serial=0) +version_info: VersionInfo = VersionInfo(major=2, minor=8, micro=0, releaselevel='alpha', serial=0) logging.getLogger(__name__).addHandler(logging.NullHandler()) From 026a882d526f04421c05951a91ada237a05d287c Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 3 Mar 2026 20:42:56 -0500 Subject: [PATCH 78/88] Raise context menu command limit to 15 to match Discord --- discord/app_commands/tree.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index ce756306a..4db1d83f6 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -257,7 +257,7 @@ class CommandTree(Generic[ClientT]): -------- CommandLimitReached The maximum number of commands was reached for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ try: @@ -277,9 +277,9 @@ class CommandTree(Generic[ClientT]): counter = Counter(cmd_type for _, _, cmd_type in ctx_menu) for cmd_type, count in counter.items(): - if count > 5: + if count > 15: as_enum = AppCommandType(cmd_type) - raise CommandLimitReached(guild_id=guild.id, limit=5, type=as_enum) + raise CommandLimitReached(guild_id=guild.id, limit=15, type=as_enum) self._context_menus.update(ctx_menu) self._guild_commands[guild.id] = mapping @@ -338,7 +338,7 @@ class CommandTree(Generic[ClientT]): Or, ``guild`` and ``guilds`` were both given. CommandLimitReached The maximum number of commands was reached globally or for that guild. - This is currently 100 for slash commands and 5 for context menu commands. + This is currently 100 for slash commands and 15 for context menu commands. """ guild_ids = _retrieve_guild_ids(command, guild, guilds) @@ -361,8 +361,8 @@ class CommandTree(Generic[ClientT]): # read as `0 if override and found else 1` if confusing to_add = not (override and found) total = sum(1 for _, g, t in self._context_menus if g == guild_id and t == type) - if total + to_add > 5: - raise CommandLimitReached(guild_id=guild_id, limit=5, type=AppCommandType(type)) + if total + to_add > 15: + raise CommandLimitReached(guild_id=guild_id, limit=15, type=AppCommandType(type)) data[key] = command if guild_ids is None: From 108e9abb5264d02056e5106fd89488ee122510f7 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Wed, 4 Mar 2026 22:01:50 +0100 Subject: [PATCH 79/88] Avoid AttributeError on AutoShardedClient.close() call before async setup --- discord/shard.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/discord/shard.py b/discord/shard.py index 7198887cf..b01818bf8 100644 --- a/discord/shard.py +++ b/discord/shard.py @@ -41,6 +41,7 @@ from .errors import ( ConnectionClosed, PrivilegedIntentsRequired, ) +from .utils import MISSING from .enums import Status @@ -389,6 +390,7 @@ class AutoShardedClient(Client): self.__shards = {} self._connection._get_websocket = self._get_websocket self._connection._get_client = lambda: self + self.__queue: asyncio.PriorityQueue = MISSING def _get_websocket(self, guild_id: Optional[int] = None, *, shard_id: Optional[int] = None) -> DiscordWebSocket: if shard_id is None: @@ -554,7 +556,8 @@ class AutoShardedClient(Client): await asyncio.wait(to_close) await self.http.close() - self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) + if self.__queue is not MISSING: + self.__queue.put_nowait(EventItem(EventType.clean_close, None, None)) self._closing_task = asyncio.create_task(_close()) await self._closing_task From 14b2c1afd35bf5311c4bf214045e47a9c4354a7b Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 14 Mar 2026 11:29:38 -0400 Subject: [PATCH 80/88] Don't cache SoundboardSound when Intents.expressions is disabled --- discord/guild.py | 2 +- discord/state.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/guild.py b/discord/guild.py index f93cf175d..0dd999de8 100644 --- a/discord/guild.py +++ b/discord/guild.py @@ -677,7 +677,7 @@ class Guild(Hashable): scheduled_event = ScheduledEvent(data=s, state=self._state) self._scheduled_events[scheduled_event.id] = scheduled_event - if 'soundboard_sounds' in guild: + if 'soundboard_sounds' in guild and state.cache_guild_expressions: for s in guild['soundboard_sounds']: soundboard_sound = SoundboardSound(guild=self, data=s, state=self._state) self._add_soundboard_sound(soundboard_sound) diff --git a/discord/state.py b/discord/state.py index f2a610bdf..9c119acfc 100644 --- a/discord/state.py +++ b/discord/state.py @@ -279,7 +279,7 @@ class ConnectionState(Generic[ClientT]): # So this is checked instead, it's a small penalty to pay @property def cache_guild_expressions(self) -> bool: - return self._intents.emojis_and_stickers + return self._intents.expressions async def close(self) -> None: for voice in self.voice_clients: From 5d74ed3e0ce5178cf454825bd5a71f0248738f54 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Sat, 14 Mar 2026 12:19:35 -0400 Subject: [PATCH 81/88] Bump PyNaCl version to allow 1.6.x version installs --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 8323381f7..37b5f5bc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,7 +37,7 @@ dependencies = { file = "requirements.txt" } [project.optional-dependencies] voice = [ - "PyNaCl>=1.5.0,<1.6", + "PyNaCl>=1.6.0,<1.7", "davey>=0.1.0" ] docs = [ From 02b819cd0bd9073e34ef9595d09e5102a519edf2 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Tue, 14 Apr 2026 18:31:59 +0200 Subject: [PATCH 82/88] Update ruff configuration to exclude docs/tests formatting --- .github/workflows/lint.yml | 2 +- pyproject.toml | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 73992a155..2c9f648a8 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -45,4 +45,4 @@ jobs: - name: Run ruff if: ${{ always() && steps.install-deps.outcome == 'success' }} run: | - ruff format --check discord examples + ruff format --check diff --git a/pyproject.toml b/pyproject.toml index 37b5f5bc5..360676812 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -91,6 +91,7 @@ include-package-data = true [tool.ruff] line-length = 125 +extend-exclude = ["docs", "tests"] [tool.ruff.lint.isort] combine-as-imports = true From dae01425542db734c01903fa2de42f91758a5a3b Mon Sep 17 00:00:00 2001 From: Pipythonmc <47196755+pythonmcpi@users.noreply.github.com> Date: Tue, 14 Apr 2026 09:32:31 -0700 Subject: [PATCH 83/88] Change the default 'required' value to True for all select variants Fixes #10339 --- discord/ui/select.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/discord/ui/select.py b/discord/ui/select.py index 4c516358a..baa1e4df8 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -241,7 +241,7 @@ class BaseSelect(Item[V]): min_values: Optional[int] = None, max_values: Optional[int] = None, disabled: bool = False, - required: bool = False, + required: bool = True, options: List[SelectOption] = MISSING, channel_types: List[ChannelType] = MISSING, default_values: Sequence[SelectDefaultValue] = MISSING, @@ -640,7 +640,7 @@ class UserSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, - required: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -748,7 +748,7 @@ class RoleSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, - required: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -852,7 +852,7 @@ class MentionableSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, - required: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -966,7 +966,7 @@ class ChannelSelect(BaseSelect[V]): min_values: int = 1, max_values: int = 1, disabled: bool = False, - required: bool = False, + required: bool = True, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, From f0195a5ffbc7a451a08180c9072d281c422f60fa Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Tue, 14 Apr 2026 18:37:51 +0200 Subject: [PATCH 84/88] Remove datetime.utcnow() use (deprecated since 3.12) --- discord/member.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/member.py b/discord/member.py index ad3c300af..59c181724 100644 --- a/discord/member.py +++ b/discord/member.py @@ -977,7 +977,7 @@ class Member(discord.abc.Messageable, _UserTag): await http.edit_my_voice_state(guild_id, voice_state_payload) else: if not suppress: - voice_state_payload['request_to_speak_timestamp'] = datetime.datetime.utcnow().isoformat() + voice_state_payload['request_to_speak_timestamp'] = utils.utcnow().isoformat() await http.edit_voice_state(guild_id, self.id, voice_state_payload) if voice_channel is not MISSING: @@ -1038,7 +1038,7 @@ class Member(discord.abc.Messageable, _UserTag): payload = { 'channel_id': self.voice.channel.id, - 'request_to_speak_timestamp': datetime.datetime.utcnow().isoformat(), + 'request_to_speak_timestamp': utils.utcnow().isoformat(), } if self._state.self_id != self.id: From 85144ec5e42533ab1d13b18c1bf56ea482733f47 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Tue, 14 Apr 2026 13:19:40 -0400 Subject: [PATCH 85/88] Fix ActionRow remove and add item not using Item.width --- discord/ui/action_row.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/discord/ui/action_row.py b/discord/ui/action_row.py index 38fc3daaa..863564973 100644 --- a/discord/ui/action_row.py +++ b/discord/ui/action_row.py @@ -274,7 +274,7 @@ class ActionRow(Item[V]): item._update_view(self.view) item._parent = self - self._weight += 1 + self._weight += item.width self._children.append(item) return self @@ -298,7 +298,7 @@ class ActionRow(Item[V]): else: if self._view: self._view._add_count(-1) - self._weight -= 1 + self._weight -= item.width return self From 3270121c8098539fa8420ce89104943e3adb88e2 Mon Sep 17 00:00:00 2001 From: Jakub Kuczys Date: Tue, 14 Apr 2026 20:46:19 +0200 Subject: [PATCH 86/88] Use iscoroutinefunction from inspect instead of asyncio on 3.12+ --- discord/app_commands/commands.py | 27 ++++++++++++++++++--------- discord/app_commands/transformers.py | 4 ++-- discord/app_commands/tree.py | 8 ++++---- discord/client.py | 4 ++-- discord/ext/commands/bot.py | 9 ++++----- discord/ext/commands/cog.py | 6 +++--- discord/ext/commands/core.py | 14 +++++++------- discord/ext/tasks/__init__.py | 10 +++++----- discord/member.py | 3 +-- discord/ui/button.py | 4 ++-- discord/ui/select.py | 5 ++--- discord/utils.py | 9 +++++++++ 12 files changed, 59 insertions(+), 44 deletions(-) diff --git a/discord/app_commands/commands.py b/discord/app_commands/commands.py index da8879827..4000f2968 100644 --- a/discord/app_commands/commands.py +++ b/discord/app_commands/commands.py @@ -58,7 +58,16 @@ from ..message import Message from ..user import User from ..member import Member from ..permissions import Permissions -from ..utils import resolve_annotation, MISSING, is_inside_class, maybe_coroutine, async_all, _shorten, _to_kebab_case +from ..utils import ( + resolve_annotation, + MISSING, + is_inside_class, + maybe_coroutine, + async_all, + _iscoroutinefunction, + _shorten, + _to_kebab_case, +) if TYPE_CHECKING: from typing_extensions import ParamSpec, Concatenate, Unpack @@ -346,7 +355,7 @@ def _populate_autocomplete(params: Dict[str, CommandParameter], autocomplete: Di if callback is MISSING: continue - if not inspect.iscoroutinefunction(callback): + if not _iscoroutinefunction(callback): raise TypeError('autocomplete callback must be a coroutine function') if param.type not in (AppCommandOptionType.string, AppCommandOptionType.number, AppCommandOptionType.integer): @@ -1037,7 +1046,7 @@ class Command(Generic[GroupT, P, T]): The coroutine passed is not actually a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error = coro @@ -1098,7 +1107,7 @@ class Command(Generic[GroupT, P, T]): """ def decorator(coro: AutocompleteCallback[GroupT, ChoiceT]) -> AutocompleteCallback[GroupT, ChoiceT]: - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The autocomplete callback must be a coroutine function.') try: @@ -1347,7 +1356,7 @@ class ContextMenu: The coroutine passed is not actually a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error = coro @@ -1840,7 +1849,7 @@ class Group: The coroutine passed is not actually a coroutine, or is an invalid coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') params = inspect.signature(coro).parameters @@ -1990,7 +1999,7 @@ class Group: """ def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -2051,7 +2060,7 @@ def command( """ def decorator(func: CommandCallback[GroupT, P, T]) -> Command[GroupT, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -2123,7 +2132,7 @@ def context_menu( """ def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('context menu function must be a coroutine function') actual_name = func.__name__.title() if name is MISSING else name diff --git a/discord/app_commands/transformers.py b/discord/app_commands/transformers.py index 531f06e66..3a6665634 100644 --- a/discord/app_commands/transformers.py +++ b/discord/app_commands/transformers.py @@ -53,7 +53,7 @@ from ..channel import StageChannel, VoiceChannel, TextChannel, CategoryChannel, from ..abc import GuildChannel from ..threads import Thread from ..enums import Enum as InternalEnum, AppCommandOptionType, ChannelType, Locale -from ..utils import MISSING, maybe_coroutine, _human_join, TIMESTAMP_PATTERN +from ..utils import MISSING, maybe_coroutine, _human_join, _iscoroutinefunction, TIMESTAMP_PATTERN from ..user import User from ..role import Role from ..member import Member @@ -814,7 +814,7 @@ def get_supported_annotation( params = inspect.signature(transform_classmethod.__func__).parameters if len(params) != 3: raise TypeError('Inline transformer with transform classmethod requires 3 parameters') - if not inspect.iscoroutinefunction(transform_classmethod.__func__): + if not _iscoroutinefunction(transform_classmethod.__func__): raise TypeError('Inline transformer with transform classmethod must be a coroutine') return (InlineTransformer(annotation), MISSING, False) diff --git a/discord/app_commands/tree.py b/discord/app_commands/tree.py index 4db1d83f6..9350af4d6 100644 --- a/discord/app_commands/tree.py +++ b/discord/app_commands/tree.py @@ -62,7 +62,7 @@ from .installs import AppCommandContext, AppInstallationType from .translator import Translator, locale_str from ..errors import ClientException, HTTPException from ..enums import AppCommandType, InteractionType -from ..utils import MISSING, _get_as_snowflake, _is_submodule, _shorten +from ..utils import MISSING, _get_as_snowflake, _iscoroutinefunction, _is_submodule, _shorten from .._types import ClientT @@ -839,7 +839,7 @@ class CommandTree(Generic[ClientT]): not match the signature. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') params = inspect.signature(coro).parameters @@ -908,7 +908,7 @@ class CommandTree(Generic[ClientT]): """ def decorator(func: CommandCallback[Group, P, T]) -> Command[Group, P, T]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('command function must be a coroutine function') if description is MISSING: @@ -1005,7 +1005,7 @@ class CommandTree(Generic[ClientT]): """ def decorator(func: ContextMenuCallback) -> ContextMenu: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('context menu function must be a coroutine function') actual_name = func.__name__.title() if name is MISSING else name diff --git a/discord/client.py b/discord/client.py index 1c1ed9f48..cc77ae5f8 100644 --- a/discord/client.py +++ b/discord/client.py @@ -68,7 +68,7 @@ from .voice_client import VoiceClient from .http import HTTPClient from .state import ConnectionState from . import utils -from .utils import MISSING, time_snowflake, deprecated +from .utils import MISSING, time_snowflake, deprecated, _iscoroutinefunction from .object import Object from .backoff import ExponentialBackoff from .webhook import Webhook @@ -2098,7 +2098,7 @@ class Client: The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('event registered must be a coroutine function') setattr(self, coro.__name__, coro) diff --git a/discord/ext/commands/bot.py b/discord/ext/commands/bot.py index 0bb4cf95f..46b5f5850 100644 --- a/discord/ext/commands/bot.py +++ b/discord/ext/commands/bot.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations -import asyncio import collections import collections.abc import inspect @@ -53,7 +52,7 @@ from typing import ( import discord from discord import app_commands from discord.app_commands.tree import _retrieve_guild_ids -from discord.utils import MISSING, _is_submodule +from discord.utils import MISSING, _iscoroutinefunction, _is_submodule from .core import GroupMixin from .view import StringView @@ -581,7 +580,7 @@ class BotBase(GroupMixin[None]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The pre-invoke hook must be a coroutine.') self._before_invoke = coro @@ -618,7 +617,7 @@ class BotBase(GroupMixin[None]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError('The post-invoke hook must be a coroutine.') self._after_invoke = coro @@ -654,7 +653,7 @@ class BotBase(GroupMixin[None]): """ name = func.__name__ if name is MISSING else name - if not asyncio.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('Listeners must be coroutines') if name in self.extra_events: diff --git a/discord/ext/commands/cog.py b/discord/ext/commands/cog.py index e229345cc..7f553c42d 100644 --- a/discord/ext/commands/cog.py +++ b/discord/ext/commands/cog.py @@ -28,7 +28,7 @@ import inspect import discord import logging from discord import app_commands -from discord.utils import maybe_coroutine, _to_kebab_case +from discord.utils import maybe_coroutine, _iscoroutinefunction, _to_kebab_case from typing import ( Any, @@ -233,7 +233,7 @@ class CogMeta(type): if elem.startswith(('cog_', 'bot_')): raise TypeError(no_bot_cog.format(base, elem)) cog_app_commands[elem] = value - elif inspect.iscoroutinefunction(value): + elif _iscoroutinefunction(value): try: getattr(value, '__cog_listener__') except AttributeError: @@ -522,7 +522,7 @@ class Cog(metaclass=CogMeta): actual = func if isinstance(actual, staticmethod): actual = actual.__func__ - if not inspect.iscoroutinefunction(actual): + if not _iscoroutinefunction(actual): raise TypeError('Listener function must be a coroutine function.') actual.__cog_listener__ = True to_assign = name or actual.__name__ diff --git a/discord/ext/commands/core.py b/discord/ext/commands/core.py index 949539b61..4adaf1fb1 100644 --- a/discord/ext/commands/core.py +++ b/discord/ext/commands/core.py @@ -427,7 +427,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): /, **kwargs: Unpack[_CommandKwargs], ) -> None: - if not asyncio.iscoroutinefunction(func): + if not discord.utils._iscoroutinefunction(func): raise TypeError('Callback must be a coroutine.') name = kwargs.get('name') or func.__name__ @@ -1102,7 +1102,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The error handler must be a coroutine.') self.on_error: Error[CogT, Any] = coro @@ -1140,7 +1140,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The pre-invoke hook must be a coroutine.') self._before_invoke = coro @@ -1171,7 +1171,7 @@ class Command(_BaseCommand, Generic[CogT, P, T]): TypeError The coroutine passed is not actually a coroutine. """ - if not asyncio.iscoroutinefunction(coro): + if not discord.utils._iscoroutinefunction(coro): raise TypeError('The post-invoke hook must be a coroutine.') self._after_invoke = coro @@ -1945,7 +1945,7 @@ def check(predicate: UserCheck[ContextT], /) -> Check[ContextT]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: @@ -2369,7 +2369,7 @@ def guild_only() -> Check[Any]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: @@ -2444,7 +2444,7 @@ def is_nsfw() -> Check[Any]: return func - if inspect.iscoroutinefunction(predicate): + if discord.utils._iscoroutinefunction(predicate): decorator.predicate = predicate else: diff --git a/discord/ext/tasks/__init__.py b/discord/ext/tasks/__init__.py index ba11d6322..e3d9c33e7 100644 --- a/discord/ext/tasks/__init__.py +++ b/discord/ext/tasks/__init__.py @@ -46,7 +46,7 @@ import inspect from collections.abc import Sequence from discord.backoff import ExponentialBackoff -from discord.utils import MISSING +from discord.utils import MISSING, _iscoroutinefunction _log = logging.getLogger(__name__) @@ -182,7 +182,7 @@ class Loop(Generic[LF]): self._last_iteration: datetime.datetime = MISSING self._next_iteration = None - if not inspect.iscoroutinefunction(self.coro): + if not _iscoroutinefunction(self.coro): raise TypeError(f'Expected coroutine function, not {type(self.coro).__name__!r}.') async def _call_loop_function(self, name: str, *args: Any, **kwargs: Any) -> None: @@ -574,7 +574,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._before_loop = coro @@ -602,7 +602,7 @@ class Loop(Generic[LF]): The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._after_loop = coro @@ -632,7 +632,7 @@ class Loop(Generic[LF]): TypeError The function was not a coroutine. """ - if not inspect.iscoroutinefunction(coro): + if not _iscoroutinefunction(coro): raise TypeError(f'Expected coroutine function, received {coro.__class__.__name__}.') self._error = coro # type: ignore diff --git a/discord/member.py b/discord/member.py index 59c181724..8f7342877 100644 --- a/discord/member.py +++ b/discord/member.py @@ -25,7 +25,6 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations import datetime -import inspect import itertools from operator import attrgetter from typing import Any, Awaitable, Callable, Collection, Dict, List, Optional, TYPE_CHECKING, Tuple, TypeVar, Union @@ -190,7 +189,7 @@ def flatten_user(cls: T) -> T: # probably a member function by now def generate_function(x): # We want sphinx to properly show coroutine functions as coroutines - if inspect.iscoroutinefunction(value): + if utils._iscoroutinefunction(value): async def general(self, *args, **kwargs): # type: ignore return await getattr(self._user, x)(*args, **kwargs) diff --git a/discord/ui/button.py b/discord/ui/button.py index 4c1e4cc89..a0d6258ff 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -26,7 +26,6 @@ from __future__ import annotations import copy from typing import Callable, Literal, Optional, TYPE_CHECKING, Tuple, TypeVar, Union -import inspect import os @@ -34,6 +33,7 @@ from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCall from ..enums import ButtonStyle, ComponentType from ..partial_emoji import PartialEmoji, _EmojiTag from ..components import Button as ButtonComponent +from ..utils import _iscoroutinefunction __all__ = ( 'Button', @@ -370,7 +370,7 @@ def button( """ def decorator(func: ItemCallbackType[S, Button[V]]) -> ItemCallbackType[S, Button[V]]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('button function must be a coroutine function') func.__discord_ui_model_type__ = Button diff --git a/discord/ui/select.py b/discord/ui/select.py index baa1e4df8..735c0c34a 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -40,14 +40,13 @@ from typing import ( ) from contextvars import ContextVar import copy -import inspect import os from .item import Item, ContainedItemCallbackType as ItemCallbackType, _ItemCallback from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..partial_emoji import PartialEmoji from ..emoji import Emoji -from ..utils import MISSING, _human_join +from ..utils import MISSING, _human_join, _iscoroutinefunction from ..components import ( SelectOption, SelectMenu, @@ -1209,7 +1208,7 @@ def select( """ def decorator(func: ItemCallbackType[S, BaseSelectT]) -> ItemCallbackType[S, BaseSelectT]: - if not inspect.iscoroutinefunction(func): + if not _iscoroutinefunction(func): raise TypeError('select function must be a coroutine function') callback_cls = getattr(cls, '__origin__', cls) if not issubclass(callback_cls, BaseSelect): diff --git a/discord/utils.py b/discord/utils.py index ba7bdd3e1..b4738a2b3 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -26,6 +26,7 @@ from __future__ import annotations import array import asyncio +import inspect from textwrap import TextWrapper from typing import ( Any, @@ -1542,3 +1543,11 @@ class _RawReprMixin: def __repr__(self) -> str: value = ' '.join(f'{attr}={getattr(self, attr)!r}' for attr in self.__slots__) return f'<{self.__class__.__name__} {value}>' + + +# `inspect.iscoroutinefunction()` only became equivalent to (now deprecated) `inspect.iscoroutinefunction()` in Python 3.12 +# https://github.com/python/cpython/issues/122858#issuecomment-2466239748 +if sys.version_info >= (3, 12): + _iscoroutinefunction = inspect.iscoroutinefunction +else: + _iscoroutinefunction = asyncio.iscoroutinefunction From 23e2c4d8deff2dd7ae00a1240b5c26590f55a38f Mon Sep 17 00:00:00 2001 From: LegoFan9 <97139608+Lego-Fan9@users.noreply.github.com> Date: Tue, 14 Apr 2026 21:28:22 -0400 Subject: [PATCH 87/88] docs: fix: Safari sidebar ul has toolbar cut off --- docs/_static/style.css | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/_static/style.css b/docs/_static/style.css index 4354344ec..69728a807 100644 --- a/docs/_static/style.css +++ b/docs/_static/style.css @@ -387,7 +387,7 @@ aside { background-color: var(--mobile-nav-background); color: var(--mobile-nav-text); z-index: 2; - max-height: 100vh; + max-height: 100dvh; overflow-y: auto; overscroll-behavior-y: contain; } @@ -1285,7 +1285,7 @@ div.code-block-caption { display: inline-block; position: sticky; top: 1em; - max-height: calc(100vh - 2em); + max-height: calc(100dvh - 2em); max-width: 100%; overflow-y: auto; margin: 1em; From 84f9877860d434969443c68ad2bed5f66ac0270f Mon Sep 17 00:00:00 2001 From: Mikey Whiston Date: Wed, 15 Apr 2026 02:32:09 +0100 Subject: [PATCH 88/88] Fix comment about inspect replacing inspect --- discord/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/discord/utils.py b/discord/utils.py index b4738a2b3..cb826d6d2 100644 --- a/discord/utils.py +++ b/discord/utils.py @@ -1545,7 +1545,7 @@ class _RawReprMixin: return f'<{self.__class__.__name__} {value}>' -# `inspect.iscoroutinefunction()` only became equivalent to (now deprecated) `inspect.iscoroutinefunction()` in Python 3.12 +# `inspect.iscoroutinefunction()` only became equivalent to (now deprecated) `asyncio.iscoroutinefunction()` in Python 3.12 # https://github.com/python/cpython/issues/122858#issuecomment-2466239748 if sys.version_info >= (3, 12): _iscoroutinefunction = inspect.iscoroutinefunction