From b2e4bdd380ef071da2066b8a09a44de3ad621901 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Tue, 12 Aug 2025 22:41:37 -0400 Subject: [PATCH 1/4] feat: add support for selects in modals (untested) Many attempts have been made to make this non-breaking - this means the UX is a little bit off. Also, as title says, untested. --- interactions/__init__.py | 2 + interactions/models/__init__.py | 2 + interactions/models/discord/__init__.py | 3 +- interactions/models/discord/enums.py | 2 + interactions/models/discord/modal.py | 77 ++++++++++++++++++++----- 5 files changed, 71 insertions(+), 15 deletions(-) diff --git a/interactions/__init__.py b/interactions/__init__.py index 292e71f38..c84ade0ad 100644 --- a/interactions/__init__.py +++ b/interactions/__init__.py @@ -202,6 +202,7 @@ Invite, InviteTargetType, is_owner, + LabelComponent, LeakyBucketSystem, listen, Listener, @@ -562,6 +563,7 @@ "InviteTargetType", "is_owner", "kwarg_spam", + "LabelComponent", "LeakyBucketSystem", "listen", "Listener", diff --git a/interactions/models/__init__.py b/interactions/models/__init__.py index fb8c6d74c..152f40779 100644 --- a/interactions/models/__init__.py +++ b/interactions/models/__init__.py @@ -97,6 +97,7 @@ InvitableMixin, Invite, InviteTargetType, + LabelComponent, MaterialColors, MaterialColours, MediaGalleryComponent, @@ -492,6 +493,7 @@ "Invite", "InviteTargetType", "is_owner", + "LabelComponent", "LeakyBucketSystem", "listen", "Listener", diff --git a/interactions/models/discord/__init__.py b/interactions/models/discord/__init__.py index 667024caf..28e1ab226 100644 --- a/interactions/models/discord/__init__.py +++ b/interactions/models/discord/__init__.py @@ -166,7 +166,7 @@ process_message_payload, process_message_reference, ) -from .modal import InputText, Modal, ParagraphText, ShortText, TextStyles +from .modal import InputText, Modal, ParagraphText, ShortText, TextStyles, LabelComponent from .onboarding import Onboarding, OnboardingPrompt, OnboardingPromptOption from .poll import PollMedia, PollAnswer, PollAnswerCount, PollResults, Poll from .reaction import Reaction, ReactionUsers @@ -287,6 +287,7 @@ "InvitableMixin", "Invite", "InviteTargetType", + "LabelComponent", "MaterialColors", "MaterialColours", "MediaGalleryComponent", diff --git a/interactions/models/discord/enums.py b/interactions/models/discord/enums.py index 41f081986..930702980 100644 --- a/interactions/models/discord/enums.py +++ b/interactions/models/discord/enums.py @@ -701,6 +701,8 @@ class ComponentType(CursedIntEnum): """Separator component for visual separation""" CONTAINER = 17 """Container component for grouping together other components""" + LABEL = 18 + """Label component for modals""" # TODO: this is hacky, is there a better way to do this? @staticmethod diff --git a/interactions/models/discord/modal.py b/interactions/models/discord/modal.py index c1b8000a1..1cdd3dca8 100644 --- a/interactions/models/discord/modal.py +++ b/interactions/models/discord/modal.py @@ -1,16 +1,17 @@ import uuid from enum import IntEnum from typing import Union, Optional, Any, TypeVar +from typing_extensions import Self import discord_typings from interactions.client.const import MISSING from interactions.client.mixins.serialization import DictSerializationMixin -from interactions.client.utils import dict_filter -from interactions.models.discord.components import ComponentType +from interactions.client.utils import dict_filter, dict_filter_none +from interactions.models.discord.components import ComponentType, BaseComponent, StringSelectMenu from interactions.models.internal.application_commands import CallbackType -__all__ = ("InputText", "Modal", "ParagraphText", "ShortText", "TextStyles") +__all__ = ("InputText", "Modal", "ParagraphText", "ShortText", "TextStyles", "LabelComponent") T = TypeVar("T", bound="InputText") @@ -62,7 +63,7 @@ def to_dict( ) @classmethod - def from_dict(cls, data: dict[str, Any]) -> T: + def from_dict(cls, data: dict[str, Any]) -> Self: if data["style"] == TextStyles.SHORT: cls = ShortText elif data["style"] == TextStyles.PARAGRAPH: @@ -127,36 +128,84 @@ def __init__( ) +class LabelComponent(BaseComponent): + def __init__( + self, + *, + label: str, + description: Optional[str] = None, + component: StringSelectMenu | InputText, + ): + self.label = label + self.component = component + self.description = description + self.type = ComponentType.LABEL + + def to_dict(self) -> dict: + return dict_filter_none( + { + "type": self.type, + "label": self.label, + "description": self.description, + "component": self.component.to_dict() if hasattr(self.component, "to_dict") else self.component, + } + ) + + @classmethod + def from_dict(cls, data: dict) -> Self: + return cls( + label=data["label"], + description=data.get("description"), + component=BaseComponent.from_dict_factory( + data["component"], + alternate_mapping={ + ComponentType.INPUT_TEXT: InputText, + ComponentType.STRING_SELECT: StringSelectMenu, + }, + ), + ) + + class Modal: def __init__( self, - *components: InputText, + *components: InputText | LabelComponent, title: str, custom_id: Optional[str] = None, ) -> None: self.title: str = title - self.components: list[InputText] = list(components) + self.components: list[InputText | LabelComponent] = list(components) self.custom_id: str = custom_id or str(uuid.uuid4()) self.type = CallbackType.MODAL def to_dict(self) -> discord_typings.ModalInteractionData: + dict_components: list[dict] = [] + + for component in self.components: + if isinstance(component, InputText): + dict_components.append({"type": ComponentType.ACTION_ROW, "components": [component.to_dict()]}) + elif isinstance(component, LabelComponent): + dict_components.append(component.to_dict()) + else: + # backwards compatibility behavior, remove in v6 + dict_components.append( + { + "type": ComponentType.ACTION_ROW, + "components": [component], + } + ) + return { "type": self.type, "data": { "title": self.title, "custom_id": self.custom_id, - "components": [ - { - "type": ComponentType.ACTION_ROW, - "components": [c.to_dict() if hasattr(c, "to_dict") else c], - } - for c in self.components - ], + "components": dict_components, }, } - def add_components(self, *components: InputText) -> None: + def add_components(self, *components: InputText | LabelComponent) -> None: """ Add components to the modal. From 3c968e6c2197f8abe179c53cdaef8d258093e284 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:57:25 -0400 Subject: [PATCH 2/4] feat: update classes to support new select features --- interactions/models/discord/components.py | 32 ++++++++++++++++++----- interactions/models/discord/modal.py | 28 ++++++++++++++++++-- 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/interactions/models/discord/components.py b/interactions/models/discord/components.py index d0b08bf97..f643d8a41 100644 --- a/interactions/models/discord/components.py +++ b/interactions/models/discord/components.py @@ -366,6 +366,7 @@ class BaseSelectMenu(InteractiveComponent): max_values Optional[int]: The maximum number of items that can be chosen. (default 1, max 25) disabled bool: Disable the select and make it not intractable, default false. type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -377,12 +378,14 @@ def __init__( max_values: int = 1, custom_id: str | None = None, disabled: bool = False, + required: bool = True, ) -> None: self.custom_id: str = custom_id or str(uuid.uuid4()) self.placeholder: str | None = placeholder self.min_values: int = min_values self.max_values: int = max_values self.disabled: bool = disabled + self.required: bool = required self.type: ComponentType = MISSING @@ -394,10 +397,11 @@ def from_dict(cls, data: discord_typings.SelectMenuComponentData) -> "BaseSelect max_values=data["max_values"], custom_id=data["custom_id"], disabled=data.get("disabled", False), + required=data.get("required", True), ) def __repr__(self) -> str: - return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled}>" + return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled} required={self.required}>" def to_dict(self) -> discord_typings.SelectMenuComponentData: return { @@ -407,6 +411,7 @@ def to_dict(self) -> discord_typings.SelectMenuComponentData: "min_values": self.min_values, "max_values": self.max_values, "disabled": self.disabled, + "required": self.required, } @@ -585,7 +590,8 @@ class StringSelectMenu(BaseSelectMenu): min_values Optional[int]: The minimum number of items that must be chosen. (default 1, min 0, max 25) max_values Optional[int]: The maximum number of items that can be chosen. (default 1, max 25) disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. + type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -600,6 +606,7 @@ def __init__( max_values: int = 1, custom_id: str | None = None, disabled: bool = False, + required: bool = True, ) -> None: super().__init__( placeholder=placeholder, @@ -607,6 +614,7 @@ def __init__( max_values=max_values, custom_id=custom_id, disabled=disabled, + required=required, ) if isinstance(options, (list, tuple)) and len(options) == 1 and isinstance(options[0], (list, tuple)): # user passed in a list of options, expand it out @@ -624,10 +632,11 @@ def from_dict(cls, data: discord_typings.SelectMenuComponentData) -> "StringSele max_values=data["max_values"], custom_id=data["custom_id"], disabled=data.get("disabled", False), + required=data.get("required", True), ) def __repr__(self) -> str: - return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled} options={self.options}>" + return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled} required={self.required} options={self.options}>" def to_dict(self) -> discord_typings.SelectMenuComponentData: return { @@ -647,7 +656,8 @@ class UserSelectMenu(DefaultableSelectMenu): custom_id str: A developer-defined identifier for the select, max 100 characters. default_values list[BaseUser, Member, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. + type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -669,6 +679,7 @@ def __init__( | None ) = None, disabled: bool = False, + required: bool = True, ) -> None: super().__init__( placeholder=placeholder, @@ -676,6 +687,7 @@ def __init__( max_values=max_values, custom_id=custom_id, disabled=disabled, + required=required, defaults=default_values, ) @@ -693,7 +705,8 @@ class RoleSelectMenu(DefaultableSelectMenu): custom_id str: A developer-defined identifier for the select, max 100 characters. default_values list[Role, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. + type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -705,6 +718,7 @@ def __init__( max_values: int = 1, custom_id: str | None = None, disabled: bool = False, + required: bool = True, default_values: ( list[ Union[ @@ -721,6 +735,7 @@ def __init__( max_values=max_values, custom_id=custom_id, disabled=disabled, + required=required, defaults=default_values, ) @@ -750,6 +765,7 @@ def __init__( max_values: int = 1, custom_id: str | None = None, disabled: bool = False, + required: bool = True, default_values: ( list[ Union[ @@ -769,6 +785,7 @@ def __init__( max_values=max_values, custom_id=custom_id, disabled=disabled, + required=required, defaults=default_values, ) @@ -799,6 +816,7 @@ def __init__( max_values: int = 1, custom_id: str | None = None, disabled: bool = False, + required: bool = True, default_values: ( list[ Union[ @@ -815,6 +833,7 @@ def __init__( max_values=max_values, custom_id=custom_id, disabled=disabled, + required=required, defaults=default_values, ) @@ -831,11 +850,12 @@ def from_dict(cls, data: discord_typings.SelectMenuComponentData) -> "ChannelSel max_values=data["max_values"], custom_id=data["custom_id"], disabled=data.get("disabled", False), + required=data.get("required", True), channel_types=data.get("channel_types", []), ) def __repr__(self) -> str: - return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled} channel_types={self.channel_types}>" + return f"<{self.__class__.__name__} type={self.type} custom_id={self.custom_id} placeholder={self.placeholder} min_values={self.min_values} max_values={self.max_values} disabled={self.disabled} required={self.required} channel_types={self.channel_types}>" def to_dict(self) -> discord_typings.SelectMenuComponentData: return { diff --git a/interactions/models/discord/modal.py b/interactions/models/discord/modal.py index 1cdd3dca8..49188261e 100644 --- a/interactions/models/discord/modal.py +++ b/interactions/models/discord/modal.py @@ -8,7 +8,16 @@ from interactions.client.const import MISSING from interactions.client.mixins.serialization import DictSerializationMixin from interactions.client.utils import dict_filter, dict_filter_none -from interactions.models.discord.components import ComponentType, BaseComponent, StringSelectMenu +from interactions.models.discord.components import ( + ChannelSelectMenu, + ComponentType, + BaseComponent, + BaseSelectMenu, + MentionableSelectMenu, + RoleSelectMenu, + StringSelectMenu, + UserSelectMenu, +) from interactions.models.internal.application_commands import CallbackType __all__ = ("InputText", "Modal", "ParagraphText", "ShortText", "TextStyles", "LabelComponent") @@ -129,12 +138,23 @@ def __init__( class LabelComponent(BaseComponent): + """ + A top-level layout component that wraps modal components with text as a label and optional description. + + Attributes: + label: The text label for the component. + description: An optional description for the component. + component: The component to be wrapped, either an InputText or a select menu. + type: The type of the component, always ComponentType.LABEL. + + """ + def __init__( self, *, label: str, description: Optional[str] = None, - component: StringSelectMenu | InputText, + component: BaseSelectMenu | InputText, ): self.label = label self.component = component @@ -160,7 +180,11 @@ def from_dict(cls, data: dict) -> Self: data["component"], alternate_mapping={ ComponentType.INPUT_TEXT: InputText, + ComponentType.CHANNEL_SELECT: ChannelSelectMenu, ComponentType.STRING_SELECT: StringSelectMenu, + ComponentType.USER_SELECT: UserSelectMenu, + ComponentType.ROLE_SELECT: RoleSelectMenu, + ComponentType.MENTIONABLE_SELECT: MentionableSelectMenu, }, ), ) From 6cccfdc0bad48f31de1b6bc0ad5d189030a18941 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 18 Sep 2025 11:29:52 -0400 Subject: [PATCH 3/4] =?UTF-8?q?feat=F0=9F=92=A5:=20make=20ModalContext=20a?= =?UTF-8?q?ble=20to=20handle=20new=20component=20types?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This requires breaking the assumption that responses always has string keys. That being said, since this is Discord breaking things, not us, this should be fine for a non-breaking release. --- interactions/models/internal/context.py | 59 ++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 5 deletions(-) diff --git a/interactions/models/internal/context.py b/interactions/models/internal/context.py index 7eac000be..635d60003 100644 --- a/interactions/models/internal/context.py +++ b/interactions/models/internal/context.py @@ -925,7 +925,7 @@ def component(self) -> typing.Optional[BaseComponent]: class ModalContext(InteractionContext[ClientT]): - responses: dict[str, str] + responses: dict[str, typing.Any] """The responses of the modal. The key is the `custom_id` of the component.""" custom_id: str """The developer defined custom ID of this modal""" @@ -933,11 +933,60 @@ class ModalContext(InteractionContext[ClientT]): """Whether to edit the original message instead of sending a new one.""" @classmethod - def from_dict(cls, client: "ClientT", payload: dict) -> Self: + def from_dict(cls, client: "ClientT", payload: dict) -> Self: # noqa: C901 instance = super().from_dict(client, payload) - instance.responses = { - comp["components"][0]["custom_id"]: comp["components"][0]["value"] for comp in payload["data"]["components"] - } + instance.responses = {} + + for component in payload["data"]["components"]: + if component["type"] == ComponentType.ACTION_ROW: + instance.responses[component["components"][0]["custom_id"]] = component["components"][0]["value"] + elif component["type"] == ComponentType.LABEL: + held_component = component["component"] + + if held_component["type"] == ComponentType.INPUT_TEXT: + instance.responses[held_component["custom_id"]] = held_component["value"] + elif held_component["type"] == ComponentType.STRING_SELECT: + instance.responses[held_component["custom_id"]] = held_component["values"] + elif held_component["type"] in ( + ComponentType.USER_SELECT, + ComponentType.CHANNEL_SELECT, + ComponentType.ROLE_SELECT, + ComponentType.MENTIONABLE_SELECT, + ): + searches = { + "users": held_component["type"] + in (ComponentType.USER_SELECT, ComponentType.MENTIONABLE_SELECT), + "members": instance.guild_id + and held_component["type"] in (ComponentType.USER_SELECT, ComponentType.MENTIONABLE_SELECT), + "channels": held_component["type"] + in (ComponentType.CHANNEL_SELECT, ComponentType.MENTIONABLE_SELECT), + "roles": instance.guild_id + and held_component["type"] in (ComponentType.ROLE_SELECT, ComponentType.MENTIONABLE_SELECT), + } + + values = held_component["values"] + + for i, value in enumerate(held_component["values"]): + if re.match(r"\d{17,}", value): + key = Snowflake(value) + + if resolved := instance.resolved.get(key): + values[i] = resolved + elif searches["members"] and ( + member := instance.client.cache.get_member(instance.guild_id, key) + ): + values[i] = member + elif searches["users"] and (user := instance.client.cache.get_user(key)): + values[i] = user + elif searches["roles"] and (role := instance.client.cache.get_role(key)): + values[i] = role + elif searches["channels"] and (channel := instance.client.cache.get_channel(key)): + values[i] = channel + + instance.responses[held_component["custom_id"]] = values + else: + raise ValueError(f"Unknown component type in modal: {held_component['type']}") + instance.kwargs = instance.responses instance.custom_id = payload["data"]["custom_id"] instance.edit_origin = False From 6c7bc2d9f3e70bd60722b198bca0bd9eb4cfa970 Mon Sep 17 00:00:00 2001 From: Astrea <25420078+AstreaTSS@users.noreply.github.com> Date: Thu, 18 Sep 2025 13:09:41 -0400 Subject: [PATCH 4/4] docs: update/fix docstrings of select menus Signed-off-by: Astrea <25420078+AstreaTSS@users.noreply.github.com> --- interactions/models/discord/components.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/interactions/models/discord/components.py b/interactions/models/discord/components.py index f643d8a41..42c92b43f 100644 --- a/interactions/models/discord/components.py +++ b/interactions/models/discord/components.py @@ -365,7 +365,7 @@ class BaseSelectMenu(InteractiveComponent): min_values Optional[int]: The minimum number of items that must be chosen. (default 1, min 0, max 25) max_values Optional[int]: The maximum number of items that can be chosen. (default 1, max 25) disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -590,7 +590,7 @@ class StringSelectMenu(BaseSelectMenu): min_values Optional[int]: The minimum number of items that must be chosen. (default 1, min 0, max 25) max_values Optional[int]: The maximum number of items that can be chosen. (default 1, max 25) disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -656,7 +656,7 @@ class UserSelectMenu(DefaultableSelectMenu): custom_id str: A developer-defined identifier for the select, max 100 characters. default_values list[BaseUser, Member, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -705,7 +705,7 @@ class RoleSelectMenu(DefaultableSelectMenu): custom_id str: A developer-defined identifier for the select, max 100 characters. default_values list[Role, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. - type Union[ComponentType, int]: The action role type number defined by discord. This cannot be modified. + type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -754,6 +754,7 @@ class MentionableSelectMenu(DefaultableSelectMenu): default_values list[BaseUser, Role, BaseChannel, Member, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """ @@ -804,6 +805,7 @@ class ChannelSelectMenu(DefaultableSelectMenu): default_values list[BaseChannel, SelectDefaultValues]: A list of default values to pre-select in the select. disabled bool: Disable the select and make it not intractable, default false. type Union[ComponentType, int]: The type of component, as defined by discord. This cannot be modified. + required bool: Whether this select menu is required to be filled out or not in modals. """