From 2f1c3fde7bfb02cdf10f7604d0ab5e7afbbcc5e1 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Wed, 18 Feb 2026 22:27:23 -0500 Subject: [PATCH 1/9] 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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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 8/9] 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 9/9] 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)