diff --git a/hikari/api/special_endpoints.py b/hikari/api/special_endpoints.py index 8df6b02f97..eb49b77a4f 100644 --- a/hikari/api/special_endpoints.py +++ b/hikari/api/special_endpoints.py @@ -42,12 +42,14 @@ "ContainerComponentBuilder", "ContextMenuCommandBuilder", "FileComponentBuilder", + "FileUploadComponentBuilder", "InteractionAutocompleteBuilder", "InteractionDeferredBuilder", "InteractionMessageBuilder", "InteractionModalBuilder", "InteractionResponseBuilder", "InteractiveButtonBuilder", + "LabelComponentBuilder", "LinkButtonBuilder", "MediaGalleryComponentBuilder", "MediaGalleryItemBuilder", @@ -1732,6 +1734,11 @@ def custom_id(self) -> str: def is_disabled(self) -> bool: """Whether the select menu should be marked as disabled.""" + @property + @abc.abstractmethod + def is_required(self) -> bool: + """Whether the select menu should be marked as required.""" + @property @abc.abstractmethod def placeholder(self) -> undefined.UndefinedOr[str]: @@ -1787,6 +1794,21 @@ def set_is_disabled(self, state: bool, /) -> Self: # noqa: FBT001 - Boolean-typ The builder object to enable chained calls. """ + @abc.abstractmethod + def set_is_required(self, state: bool, /) -> Self: # noqa: FBT001 - Boolean-typed positional argument + """Set whether this option is required. + + Parameters + ---------- + state + Whether this option is required. + + Returns + ------- + SelectMenuBuilder + The builder object to enable chained calls. + """ + @abc.abstractmethod def set_placeholder(self, value: undefined.UndefinedOr[str], /) -> Self: """Set place-holder text to be shown when no option is selected. @@ -2399,8 +2421,7 @@ def add_component(self, component: ModalActionRowBuilderComponentsT, /) -> Self: !!! warning It is generally better to use - [`hikari.api.special_endpoints.MessageActionRowBuilder.add_interactive_button`][] - and [`hikari.api.special_endpoints.MessageActionRowBuilder.add_select_menu`][] + [`hikari.api.special_endpoints.MessageActionRowBuilder.add_text_input`][] to add your component to the builder. Those methods utilize this one. Parameters @@ -2870,6 +2891,340 @@ def add_file( """ +class LabelComponentBuilder(ComponentBuilder, abc.ABC): + """Builder class for label components.""" + + __slots__: typing.Sequence[str] = () + + @property + @abc.abstractmethod + @typing_extensions.override + def type(self) -> typing.Literal[components_.ComponentType.LABEL]: + """Type of component this builder represents.""" + + @property + @abc.abstractmethod + def label(self) -> str: + """The label name of the label component.""" + + @property + @abc.abstractmethod + def description(self) -> undefined.UndefinedOr[str]: + """The label name of the label component.""" + + @property + @abc.abstractmethod + def component(self) -> LabelBuilderComponentsT: + """The component attached to the label.""" + + @abc.abstractmethod + def set_component(self, component: ModalActionRowBuilderComponentsT, /) -> Self: + """Set the child component of this label component. + + !!! warning + It is generally better to use + [`hikari.api.special_endpoints.LabelComponentBuilder.set_text_input`][] + [`hikari.api.special_endpoints.LabelComponentBuilder.set_select_menu`][] + [`hikari.api.special_endpoints.LabelComponentBuilder.set_text_menu`][] + [`hikari.api.special_endpoints.LabelComponentBuilder.set_channel_menu`][] + to add your component to the builder. Those methods utilize this one. + + Parameters + ---------- + component + The component builder to set as the component. + + Returns + ------- + LabelComponentBuilder + The label component builder to enable chained calls. + """ + + @abc.abstractmethod + def set_text_input( + self, + custom_id: str, + label: str, + /, + *, + style: components_.TextInputStyle = components_.TextInputStyle.SHORT, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + value: undefined.UndefinedOr[str] = undefined.UNDEFINED, + required: bool = True, + min_length: int = 0, + max_length: int = 4000, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + """Set the child component to a text input component for this label component. + + Parameters + ---------- + custom_id + Developer set custom ID used for identifying this text input. + label + Label above this text input. + style + The text input's style. + placeholder + Placeholder text to display when the text input is empty. + value + Default text to pre-fill the field with. + required + Whether text must be supplied for this text input. + min_length + Minimum length the input text can be. + + This can be greater than or equal to 0 and less than or equal to 4000. + max_length + Maximum length the input text can be. + + This can be greater than or equal to 1 and less than or equal to 4000. + + Returns + ------- + LabelComponentBuilder + The label component builder to enable call chaining. + """ + + @abc.abstractmethod + def set_select_menu( + self, + type_: components_.ComponentType | int, + custom_id: str, + /, + *, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + """Set the child component to a select menu component for this label component. + + For channel select menus and text select menus see + [`hikari.api.special_endpoints.MessageActionRowBuilder.add_channel_menu`][] + and [`hikari.api.special_endpoints.MessageActionRowBuilder.add_text_menu`][]. + + Parameters + ---------- + type_ + The type for the select menu. + custom_id + A developer-defined custom identifier used to identify which menu + triggered component interactions. + placeholder + Placeholder text to show when no entries have been selected. + min_values + The minimum amount of entries which need to be selected. + max_values + The maximum amount of entries which can be selected. + is_disabled + Whether this select menu should be marked as disabled. + is_required + Whether this select menu should be marked as required. + id + The ID to give to the menu. + + If not provided, auto populated through increment. + + Returns + ------- + LabelComponentBuilder + The label component builder to enable chained calls. + + Raises + ------ + ValueError + If an invalid select menu type is passed. + """ + + @abc.abstractmethod + def set_text_menu( + self, + custom_id: str, + /, + *, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> TextSelectMenuBuilder[Self]: + """Set the child component to a text menu component for this label component. + + Parameters + ---------- + custom_id + A developer-defined custom identifier used to identify which menu + triggered component interactions. + placeholder + Placeholder text to show when no entries have been selected. + min_values + The minimum amount of entries which need to be selected. + max_values + The maximum amount of entries which can be selected. + is_disabled + Whether this select menu should be marked as disabled. + is_required + Whether this select menu should be marked as required. + id + The ID to give to the menu. + + If not provided, auto populated through increment. + + Returns + ------- + TextSelectMenuBuilder + The text select menu builder. + + [`hikari.api.special_endpoints.TextSelectMenuBuilder.add_option`][] should be called to add + options to the returned builder then + [`hikari.api.special_endpoints.TextSelectMenuBuilder.parent`][] can be used to return to this + label component while chaining calls. + + Raises + ------ + ValueError + If an invalid select menu type is passed. + """ + + @abc.abstractmethod + def set_channel_menu( + self, + custom_id: str, + /, + *, + channel_types: typing.Sequence[channels.ChannelType] = (), + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + """Set the child component to a channel menu component for this label component. + + Parameters + ---------- + custom_id + A developer-defined custom identifier used to identify which menu + triggered component interactions. + channel_types + The channel types this select menu should allow. + + If left as an empty sequence then there will be no + channel type restriction. + placeholder + Placeholder text to show when no entries have been selected. + min_values + The minimum amount of entries which need to be selected. + max_values + The maximum amount of entries which can be selected. + is_disabled + Whether this select menu should be marked as disabled. + is_required + Whether this select menu should be marked as required. + id + The ID to give to the menu. + + If not provided, auto populated through increment. + + Returns + ------- + LabelComponentBuilder + The label component builder to enable chained calls. + + Raises + ------ + ValueError + If an invalid select menu type is passed. + """ + + @abc.abstractmethod + def set_file_upload( + self, + custom_id: str, + /, + *, + min_values: int = 1, + max_values: int = 1, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + """Set the child component to a file upload component for this label component. + + Parameters + ---------- + custom_id + A developer-defined custom identifier used to identify which menu + triggered component interactions. + min_values + The minimum amount of entries which need to be selected. + max_values + The maximum amount of entries which can be selected. + is_required + Whether this select menu should be marked as required. + id + The ID to give to the menu. + + If not provided, auto populated through increment. + + Returns + ------- + LabelComponentBuilder + The label component builder to enable chained calls. + + Raises + ------ + ValueError + If an invalid select menu type is passed. + """ + + +class FileUploadComponentBuilder(ComponentBuilder, abc.ABC): + """Builder class for file upload components.""" + + __slots__: typing.Sequence[str] = () + + @property + @abc.abstractmethod + @typing_extensions.override + def type(self) -> typing.Literal[components_.ComponentType.FILE_UPLOAD]: + """Type of component this builder represents.""" + + @property + @abc.abstractmethod + def custom_id(self) -> str: + """Developer set custom ID used for identifying interactions with this file upload component.""" + + @property + @abc.abstractmethod + def min_values(self) -> int: + """Minimum number of options which must be chosen. + + Defaults to 1. + Must be less than or equal to [`hikari.api.special_endpoints.FileUploadComponentBuilder.max_values`][] and greater + than or equal to 0. + """ + + @property + @abc.abstractmethod + def max_values(self) -> int: + """Maximum number of options which can be chosen. + + Defaults to 1. + Must be greater than or equal to [`hikari.api.special_endpoints.FileUploadComponentBuilder.min_values`][] and + less than or equal to 5. + """ + + @property + @abc.abstractmethod + def is_required(self) -> bool: + """Whether the select menu should be marked as required.""" + + class PollBuilder(abc.ABC): """Builder class for polls.""" @@ -2976,6 +3331,8 @@ def build(self) -> typing.MutableMapping[str, typing.Any]: SectionBuilderAccessoriesT = typing.Union[ButtonBuilder, ThumbnailComponentBuilder] SectionBuilderComponentsT = typing.Union[TextDisplayComponentBuilder] + LabelBuilderComponentsT = typing.Union[SelectMenuBuilder, TextInputBuilder, FileUploadComponentBuilder] + class AutoModActionBuilder(abc.ABC): """Builder class for auto mod actions.""" diff --git a/hikari/components.py b/hikari/components.py index 995c288634..a8a7fa977f 100644 --- a/hikari/components.py +++ b/hikari/components.py @@ -31,8 +31,11 @@ "ContainerComponent", "ContainerTypesT", "FileComponent", + "FileUploadComponent", "InteractiveButtonTypes", "InteractiveButtonTypesT", + "LabelComponent", + "LabelTypesT", "MediaGalleryComponent", "MediaGalleryItem", "MediaLoadingType", @@ -40,6 +43,7 @@ "MessageActionRowComponent", "MessageComponentTypesT", "ModalActionRowComponent", + "ModalActionRowComponentTypesT", "ModalComponentTypesT", "PartialComponent", "SectionComponent", @@ -71,6 +75,7 @@ from hikari import channels from hikari import colors from hikari import emojis + from hikari import snowflakes from hikari import undefined @@ -106,6 +111,7 @@ class ComponentType(int, enums.Enum): !!! note This component may only be used inside a modal container. + FIXME: This needs switching to a different item, like label. !!! note This cannot be top-level and must be within a container component such @@ -180,6 +186,15 @@ class ComponentType(int, enums.Enum): component and therefore will always be top-level. """ + LABEL = 18 + """A label component. + + FIXME: This needs a better description. + """ + + FILE_UPLOAD = 19 + """A file upload component.""" + @typing.final class ButtonStyle(int, enums.Enum): @@ -565,6 +580,25 @@ class ContainerComponent(PartialComponent): """The components within the container.""" +@attrs.define(kw_only=True, weakref_slot=False) +class LabelComponent(PartialComponent): + """Represents a label component.""" + + component: LabelTypesT = attrs.field() + """The component within the label.""" + + +@attrs.define(kw_only=True, weakref_slot=False) +class FileUploadComponent(PartialComponent): + """Represents a label component.""" + + custom_id: str = attrs.field() + """Developer set custom ID used for identifying interactions with this file upload.""" + + values: typing.Sequence[snowflakes.Snowflake] = attrs.field() + """A list of snowflakes in relation to the attachments, that can be found in the resolved interaction data.""" + + TopLevelComponentTypesT = typing.Union[ ActionRowComponent[PartialComponent], TextDisplayComponent, @@ -701,7 +735,17 @@ class ContainerComponent(PartialComponent): * [`hikari.components.ButtonComponent`][] * [`hikari.components.SelectMenuComponent`][] """ # noqa: E501 -ModalComponentTypesT = TextInputComponent + +ModalComponentTypesT = typing.Union[ActionRowComponent[PartialComponent], LabelComponent] +"""Type hint of the [`hikari.components.PartialComponent`][] that be contained in a [`hikari.components.PartialComponent`][]. + +The following values are valid for this: + +* [`hikari.components.ActionRowComponent`][] +* [`hikari.components.LabelComponent`][] +""" + +ModalActionRowComponentTypesT = TextInputComponent # FIXME: This is a breaking change. """Type hint of the [`hikari.components.PartialComponent`][] that be contained in a [`hikari.components.PartialComponent`][]. The following values are valid for this: @@ -709,7 +753,16 @@ class ContainerComponent(PartialComponent): * [`hikari.components.TextInputComponent`][] """ # noqa: E501 +LabelTypesT = typing.Union[SelectMenuComponent, TextInputComponent, FileUploadComponent] +"""Type hint of the [`hikari.components.PartialComponent`][] that be contained in a [`hikari.components.LabelComponent`][]. + +The following values are valid for this: + +* [`hikari.components.TextSelectMenuComponent`][] +* [`hikari.components.TextInputComponent`][] +""" + MessageActionRowComponent = ActionRowComponent[MessageComponentTypesT] """A message action row component.""" -ModalActionRowComponent = ActionRowComponent[ModalComponentTypesT] +ModalActionRowComponent = ActionRowComponent[ModalActionRowComponentTypesT] """A modal action row component.""" diff --git a/hikari/impl/entity_factory.py b/hikari/impl/entity_factory.py index 7cb0c04a33..e67683884f 100644 --- a/hikari/impl/entity_factory.py +++ b/hikari/impl/entity_factory.py @@ -460,6 +460,8 @@ class EntityFactoryImpl(entity_factory.EntityFactory): "_guild_channel_type_mapping", "_interaction_metadata_mapping", "_interaction_type_mapping", + "_label_component_type_mapping", + "_modal_action_row_component_type_mapping", "_modal_component_type_mapping", "_scheduled_event_type_mapping", "_section_accessory_mapping", @@ -558,7 +560,7 @@ def __init__(self, app: traits.RESTAware) -> None: component_models.ComponentType, typing.Callable[[data_binding.JSONObject], component_models.TopLevelComponentTypesT], ] = { - component_models.ComponentType.ACTION_ROW: self._deserialize_action_row_component, + component_models.ComponentType.ACTION_ROW: self._deserialize_message_action_row_component, component_models.ComponentType.SECTION: self._deserialize_section_component, component_models.ComponentType.TEXT_DISPLAY: self._deserialize_text_display_component, component_models.ComponentType.MEDIA_GALLERY: self._deserialize_media_gallery_component, @@ -588,7 +590,24 @@ def __init__(self, app: traits.RESTAware) -> None: } self._modal_component_type_mapping: dict[ int, typing.Callable[[data_binding.JSONObject], component_models.ModalComponentTypesT] + ] = { + component_models.ComponentType.ACTION_ROW: self._deserialize_modal_action_row_component, + component_models.ComponentType.LABEL: self._deserialize_label_component, + } + self._modal_action_row_component_type_mapping: dict[ + int, typing.Callable[[data_binding.JSONObject], component_models.ModalActionRowComponentTypesT] ] = {component_models.ComponentType.TEXT_INPUT: self._deserialize_text_input} + self._label_component_type_mapping: dict[ + int, typing.Callable[[data_binding.JSONObject], component_models.LabelTypesT] + ] = { + component_models.ComponentType.TEXT_INPUT: self._deserialize_text_input, + component_models.ComponentType.TEXT_SELECT_MENU: self._deserialize_text_select_menu, + component_models.ComponentType.USER_SELECT_MENU: self._deserialize_select_menu, + component_models.ComponentType.ROLE_SELECT_MENU: self._deserialize_select_menu, + component_models.ComponentType.CHANNEL_SELECT_MENU: self._deserialize_channel_select_menu, + component_models.ComponentType.MENTIONABLE_SELECT_MENU: self._deserialize_select_menu, + component_models.ComponentType.FILE_UPLOAD: self._deserialize_file_upload_component, + } self._dm_channel_type_mapping = { channel_models.ChannelType.DM: self.deserialize_dm, channel_models.ChannelType.GROUP_DM: self.deserialize_group_dm, @@ -3266,36 +3285,18 @@ def deserialize_guild_sticker(self, payload: data_binding.JSONObject) -> sticker def _deserialize_modal_components( self, payloads: data_binding.JSONArray - ) -> typing.Sequence[component_models.ModalActionRowComponent]: - top_level_components: list[component_models.ModalActionRowComponent] = [] + ) -> typing.Sequence[component_models.ModalComponentTypesT]: + top_level_components: list[component_models.ModalComponentTypesT] = [] for payload in payloads: top_level_component_type = component_models.ComponentType(payload["type"]) - if top_level_component_type != component_models.ComponentType.ACTION_ROW: - _LOGGER.debug("Unknown top-level message component type %s", top_level_component_type) + if deserializer := self._modal_component_type_mapping.get(top_level_component_type): + top_level_components.append(deserializer(payload)) + else: + _LOGGER.debug("Unknown component type %s", top_level_component_type) continue - components: list[component_models.ModalComponentTypesT] = [] - - for component_payload in payload["components"]: - component_type = component_models.ComponentType(component_payload["type"]) - - if (deserializer := self._modal_component_type_mapping.get(component_type)) is None: - _LOGGER.debug("Unknown component type %s", component_type) - continue - - components.append(deserializer(component_payload)) - - if components: - # If we somehow get a top-level component full of unknown components, ignore the top-level - # component all-together - top_level_components.append( - component_models.ActionRowComponent( - type=top_level_component_type, id=payload["id"], components=components - ) - ) - return top_level_components def _deserialize_top_level_components( @@ -3424,7 +3425,7 @@ def _deserialize_media(self, payload: data_binding.JSONObject) -> component_mode loading_state=loading_state, ) - def _deserialize_action_row_component( + def _deserialize_message_action_row_component( self, payload: data_binding.JSONObject ) -> component_models.ActionRowComponent[component_models.PartialComponent]: components: list[component_models.PartialComponent] = [] @@ -3442,6 +3443,24 @@ def _deserialize_action_row_component( type=component_models.ComponentType.ACTION_ROW, id=payload["id"], components=components ) + def _deserialize_modal_action_row_component( + self, payload: data_binding.JSONObject + ) -> component_models.ActionRowComponent[component_models.PartialComponent]: + components: list[component_models.PartialComponent] = [] + + for component_payload in payload["components"]: + component_type = component_models.ComponentType(component_payload["type"]) + + if (deserializer := self._modal_action_row_component_type_mapping.get(component_type)) is None: + _LOGGER.debug("Unknown component type %s", component_type) + continue + + components.append(deserializer(component_payload)) + + return component_models.ActionRowComponent( + type=component_models.ComponentType.ACTION_ROW, id=payload["id"], components=components + ) + def _deserialize_section_component(self, payload: data_binding.JSONObject) -> component_models.SectionComponent: accessory_payload = payload["accessory"] accessory_type = component_models.ComponentType(accessory_payload["type"]) @@ -3520,7 +3539,7 @@ def _deserialize_container_component(self, payload: data_binding.JSONObject) -> component_type = component_models.ComponentType(component_payload["type"]) if component_type == component_models.ComponentType.ACTION_ROW: - if action_row := self._deserialize_action_row_component(component_payload): + if action_row := self._deserialize_message_action_row_component(component_payload): components.append(action_row) continue @@ -3543,6 +3562,25 @@ def _deserialize_container_component(self, payload: data_binding.JSONObject) -> components=components, ) + def _deserialize_label_component(self, payload: data_binding.JSONObject) -> component_models.LabelComponent: + component_deserializer = self._label_component_type_mapping[payload["component"]["type"]] + + return component_models.LabelComponent( + type=component_models.ComponentType.LABEL, + id=payload.get("id", None), + component=component_deserializer(payload["component"]), + ) + + def _deserialize_file_upload_component( + self, payload: data_binding.JSONObject + ) -> component_models.FileUploadComponent: + return component_models.FileUploadComponent( + type=component_models.ComponentType.FILE_UPLOAD, + id=payload.get("id", None), + custom_id=payload["custom_id"], + values=[snowflakes.Snowflake(value) for value in payload["values"]], + ) + ################## # MESSAGE MODELS # ################## diff --git a/hikari/impl/special_endpoints.py b/hikari/impl/special_endpoints.py index e1bcccbdd6..ef8137e8e4 100644 --- a/hikari/impl/special_endpoints.py +++ b/hikari/impl/special_endpoints.py @@ -41,11 +41,13 @@ "ContainerComponentBuilder", "ContextMenuCommandBuilder", "FileComponentBuilder", + "FileUploadComponentBuilder", "InteractionAutocompleteBuilder", "InteractionDeferredBuilder", "InteractionMessageBuilder", "InteractionModalBuilder", "InteractiveButtonBuilder", + "LabelComponentBuilder", "LinkButtonBuilder", "MediaGalleryComponentBuilder", "MediaGalleryItemBuilder", @@ -1984,6 +1986,7 @@ class SelectMenuBuilder(special_endpoints.SelectMenuBuilder): _min_values: int = attrs.field(alias="min_values", default=0) _max_values: int = attrs.field(alias="max_values", default=1) _is_disabled: bool = attrs.field(alias="is_disabled", default=False) + _is_required: bool = attrs.field(alias="is_required", default=True) @property @typing_extensions.override @@ -2005,6 +2008,11 @@ def custom_id(self) -> str: def is_disabled(self) -> bool: return self._is_disabled + @property + @typing_extensions.override + def is_required(self) -> bool: + return self._is_required + @property @typing_extensions.override def placeholder(self) -> undefined.UndefinedOr[str]: @@ -2030,6 +2038,11 @@ def set_is_disabled(self, state: bool, /) -> Self: self._is_disabled = state return self + @typing_extensions.override + def set_is_required(self, state: bool, /) -> Self: + self._is_required = state + return self + @typing_extensions.override def set_placeholder(self, value: undefined.UndefinedOr[str], /) -> Self: self._placeholder = value @@ -2058,6 +2071,7 @@ def build( data.put("min_values", self._min_values) data.put("max_values", self._max_values) data.put("disabled", self._is_disabled) + data.put("required", self._is_required) return data, [] @@ -2086,6 +2100,7 @@ def __init__( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, ) -> None: ... @typing.overload @@ -2099,6 +2114,7 @@ def __init__( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, ) -> None: ... def __init__( @@ -2112,6 +2128,7 @@ def __init__( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, ) -> None: super().__init__( type=component_models.ComponentType.TEXT_SELECT_MENU, @@ -2121,6 +2138,7 @@ def __init__( min_values=min_values, max_values=max_values, is_disabled=is_disabled, + is_required=is_required, ) self._options = list(options) self._parent = parent @@ -2315,7 +2333,7 @@ def build( data["type"] = component_models.ComponentType.TEXT_INPUT data["style"] = self._style data["custom_id"] = self._custom_id - data["label"] = self._label + # data["label"] = self._label # FIXME: This needs to exist for action rows, but not for labels. data.put("id", self._id) data.put("placeholder", self._placeholder) data.put("value", self._value) @@ -2410,6 +2428,7 @@ def add_select_menu( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, id: undefined.UndefinedOr[int] = undefined.UNDEFINED, ) -> Self: return self.add_component( @@ -2421,6 +2440,7 @@ def add_select_menu( min_values=min_values, max_values=max_values, is_disabled=is_disabled, + is_required=is_required, ) ) @@ -2435,6 +2455,7 @@ def add_channel_menu( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, id: undefined.UndefinedOr[int] = undefined.UNDEFINED, ) -> Self: return self.add_component( @@ -2446,6 +2467,7 @@ def add_channel_menu( min_values=min_values, max_values=max_values, is_disabled=is_disabled, + is_required=is_required, ) ) @@ -2459,6 +2481,7 @@ def add_text_menu( min_values: int = 0, max_values: int = 1, is_disabled: bool = False, + is_required: bool = True, id: undefined.UndefinedOr[int] = undefined.UNDEFINED, ) -> special_endpoints.TextSelectMenuBuilder[Self]: component = TextSelectMenuBuilder( @@ -2469,6 +2492,7 @@ def add_text_menu( min_values=min_values, max_values=max_values, is_disabled=is_disabled, + is_required=is_required, ) self.add_component(component) return component @@ -3022,6 +3046,243 @@ def build( return payload, attachments +@attrs.define(kw_only=True, weakref_slot=False) +class LabelComponentBuilder(special_endpoints.LabelComponentBuilder): + """Standard implementation of [`hikari.api.special_endpoints.LabelComponentBuilder`][].""" + + _id: undefined.UndefinedOr[int] = attrs.field(alias="id", default=undefined.UNDEFINED) + _label: str = attrs.field(alias="label") + _description: undefined.UndefinedOr[str] = attrs.field(alias="description", default=undefined.UNDEFINED) + _component: special_endpoints.LabelBuilderComponentsT = attrs.field(alias="component") + + @property + @typing_extensions.override + def type(self) -> typing.Literal[component_models.ComponentType.LABEL]: + return component_models.ComponentType.LABEL + + @property + @typing_extensions.override + def id(self) -> undefined.UndefinedOr[int]: + return self._id + + @property + @typing_extensions.override + def label(self) -> str: + return self._label + + @property + @typing_extensions.override + def description(self) -> undefined.UndefinedOr[str]: + return self._description + + @property + @typing_extensions.override + def component(self) -> special_endpoints.LabelBuilderComponentsT: + return self._component + + @typing_extensions.override + def set_component(self, component: special_endpoints.LabelBuilderComponentsT, /) -> Self: + self._component = component + return self + + @typing_extensions.override + def set_text_input( + self, + custom_id: str, + label: str, + /, + *, + style: component_models.TextInputStyle = component_models.TextInputStyle.SHORT, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + value: undefined.UndefinedOr[str] = undefined.UNDEFINED, + required: bool = True, + min_length: int = 0, + max_length: int = 4000, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + return self.set_component( + TextInputBuilder( + id=id, + custom_id=custom_id, + label=label, + style=style, + placeholder=placeholder, + value=value, + required=required, + min_length=min_length, + max_length=max_length, + ) + ) + + @typing_extensions.override + def set_select_menu( + self, + type_: component_models.ComponentType | int, + custom_id: str, + /, + *, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + return self.set_component( + SelectMenuBuilder( + type=type_, + id=id, + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + is_disabled=is_disabled, + is_required=is_required, + ) + ) + + @typing_extensions.override + def set_text_menu( + self, + custom_id: str, + /, + *, + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> special_endpoints.TextSelectMenuBuilder[Self]: + component = TextSelectMenuBuilder( + id=id, + custom_id=custom_id, + parent=self, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + is_disabled=is_disabled, + is_required=is_required, + ) + self.set_component(component) + return component + + @typing_extensions.override + def set_channel_menu( + self, + custom_id: str, + /, + *, + channel_types: typing.Sequence[channels.ChannelType] = (), + placeholder: undefined.UndefinedOr[str] = undefined.UNDEFINED, + min_values: int = 0, + max_values: int = 1, + is_disabled: bool = False, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + component = ChannelSelectMenuBuilder( + id=id, + custom_id=custom_id, + placeholder=placeholder, + min_values=min_values, + max_values=max_values, + is_disabled=is_disabled, + is_required=is_required, + channel_types=channel_types, + ) + self.set_component(component) + return self + + @typing_extensions.override + def set_file_upload( + self, + custom_id: str, + /, + *, + min_values: int = 1, + max_values: int = 1, + is_required: bool = True, + id: undefined.UndefinedOr[int] = undefined.UNDEFINED, + ) -> Self: + component = FileUploadComponentBuilder( + id=id, custom_id=custom_id, min_values=min_values, max_values=max_values, is_required=is_required + ) + self.set_component(component) + return self + + @typing_extensions.override + def build( + self, + ) -> tuple[typing.MutableMapping[str, typing.Any], typing.Sequence[files.Resource[files.AsyncReader]]]: + payload = data_binding.JSONObjectBuilder() + payload["type"] = self.type + payload["label"] = self._label + payload.put("id", self._id) + if self._description is not None: + payload.put("description", self._description) + component_payload, attachments = self._component.build() + + payload.put("component", component_payload) + + return payload, attachments + + +@attrs.define(kw_only=True, weakref_slot=False) +class FileUploadComponentBuilder(special_endpoints.FileUploadComponentBuilder, abc.ABC): + """Builder class for file upload components.""" + + _id: undefined.UndefinedOr[int] = attrs.field(alias="id", default=undefined.UNDEFINED) + _custom_id: str = attrs.field(alias="custom_id") + _min_values: int = attrs.field(alias="min_values", default=1) + _max_values: int = attrs.field(alias="max_values", default=1) + _is_required: bool = attrs.field(alias="is_required", default=True) + + @property + @typing_extensions.override + def type(self) -> typing.Literal[component_models.ComponentType.FILE_UPLOAD]: + return component_models.ComponentType.FILE_UPLOAD + + @property + @typing_extensions.override + def id(self) -> undefined.UndefinedOr[int]: + return self._id + + @property + @typing_extensions.override + def custom_id(self) -> str: + return self._custom_id + + @property + @typing_extensions.override + def min_values(self) -> int: + return self._min_values + + @property + @typing_extensions.override + def max_values(self) -> int: + return self._max_values + + @property + @typing_extensions.override + def is_required(self) -> bool: + return self._is_required + + @typing_extensions.override + def build( + self, + ) -> tuple[typing.MutableMapping[str, typing.Any], typing.Sequence[files.Resource[files.AsyncReader]]]: + payload = data_binding.JSONObjectBuilder() + payload["type"] = self.type + payload.put("id", self._id) + payload.put("custom_id", self._custom_id) + payload.put("min_values", self._min_values) + payload.put("max_values", self._max_values) + payload.put("required", self._is_required) + + return payload, [] + + @attrs.define(kw_only=True, weakref_slot=False) class PollBuilder(special_endpoints.PollBuilder): """Standard implementation of [`hikari.api.special_endpoints.PollBuilder`][].""" diff --git a/hikari/interactions/modal_interactions.py b/hikari/interactions/modal_interactions.py index 21e585285c..7551614a0f 100644 --- a/hikari/interactions/modal_interactions.py +++ b/hikari/interactions/modal_interactions.py @@ -77,7 +77,7 @@ class ModalInteraction(base_interactions.MessageResponseMixin[ModalResponseTypes This will be [`None`][] if the modal was a response to a command. """ - components: typing.Sequence[components_.ModalActionRowComponent] = attrs.field(eq=False, hash=False, repr=True) + components: typing.Sequence[components_.ModalComponentTypesT] """Components in the modal.""" def build_response(self) -> special_endpoints.InteractionMessageBuilder: diff --git a/tests/hikari/impl/test_entity_factory.py b/tests/hikari/impl/test_entity_factory.py index 1eeac95f92..23003e50b0 100644 --- a/tests/hikari/impl/test_entity_factory.py +++ b/tests/hikari/impl/test_entity_factory.py @@ -6008,6 +6008,14 @@ def file_payload(self, media_payload): def container_payload(self, file_payload): return {"type": 17, "id": 5830957, "accent_color": 16757027, "spoiler": True, "components": [file_payload]} + @pytest.fixture + def label_payload(self, text_input_payload): + return {"type": 18, "id": 3294587, "component": text_input_payload} + + @pytest.fixture + def file_upload_payload(self): + return {"type": 19, "id": 4358903, "custom_id": "file-upload-yippee", "values": ["123", "456", "789"]} + def test__deserialize_media(self, entity_factory_impl, media_payload): media = entity_factory_impl._deserialize_media(media_payload) @@ -6055,8 +6063,8 @@ def test__deserialize_media_with_nullable_fields(self, entity_factory_impl, medi assert isinstance(media, component_models.MediaResource) - def test__deserialize_action_row_component(self, entity_factory_impl, action_row_payload, button_payload): - action_row = entity_factory_impl._deserialize_action_row_component(action_row_payload) + def test__deserialize_message_action_row_component(self, entity_factory_impl, action_row_payload, button_payload): + action_row = entity_factory_impl._deserialize_message_action_row_component(action_row_payload) assert action_row.type == component_models.ComponentType.ACTION_ROW @@ -6065,12 +6073,33 @@ def test__deserialize_action_row_component(self, entity_factory_impl, action_row assert isinstance(action_row, component_models.ActionRowComponent) - def test__deserialize_action_row_component_with_unknown_component_type( + def test__deserialize_message_action_row_component_with_unknown_component_type( self, entity_factory_impl, action_row_payload ): action_row_payload["components"] = [{"type": -9999}, {"type": 9999}] - action_row = entity_factory_impl._deserialize_action_row_component(action_row_payload) + action_row = entity_factory_impl._deserialize_message_action_row_component(action_row_payload) + + assert action_row.components == [] + + def test__deserialize_modal_action_row_component(self, entity_factory_impl, action_row_payload, text_input_payload): + action_row_payload["components"] = [text_input_payload] + + action_row = entity_factory_impl._deserialize_modal_action_row_component(action_row_payload) + + assert action_row.type == component_models.ComponentType.ACTION_ROW + + assert action_row.id == 8394572 + assert action_row.components == [entity_factory_impl._deserialize_text_input(text_input_payload)] + + assert isinstance(action_row, component_models.ActionRowComponent) + + def test__deserialize_modal_action_row_component_with_unknown_component_type( + self, entity_factory_impl, action_row_payload + ): + action_row_payload["components"] = [{"type": -9999}, {"type": 9999}] + + action_row = entity_factory_impl._deserialize_modal_action_row_component(action_row_payload) assert action_row.components == [] @@ -6216,6 +6245,39 @@ def test__deserialize_container_component_with_unknown_component_type(self, enti assert container.components == [] + def test__deserialize_label_component(self, entity_factory_impl, label_payload, text_input_payload): + label = entity_factory_impl._deserialize_label_component(label_payload) + + assert label.type == component_models.ComponentType.LABEL + assert label.id == 3294587 + assert label.component == entity_factory_impl._deserialize_text_input(text_input_payload) + + assert isinstance(label, component_models.LabelComponent) + + def test__deserialize_label_component_with_unset_fields(self, entity_factory_impl, label_payload): + del label_payload["id"] + + label = entity_factory_impl._deserialize_label_component(label_payload) + + assert label.id is None + + def test__deserialize_file_upload_component(self, entity_factory_impl, file_upload_payload): + file_upload = entity_factory_impl._deserialize_file_upload_component(file_upload_payload) + + assert file_upload.type == component_models.ComponentType.FILE_UPLOAD + assert file_upload.id == 4358903 + assert file_upload.custom_id == "file-upload-yippee" + assert file_upload.values == [snowflakes.Snowflake(123), snowflakes.Snowflake(456), snowflakes.Snowflake(789)] + + assert isinstance(file_upload, component_models.FileUploadComponent) + + def test__deserialize_file_upload_component_with_unset_fields(self, entity_factory_impl, file_upload_payload): + del file_upload_payload["id"] + + file_upload = entity_factory_impl._deserialize_file_upload_component(file_upload_payload) + + assert file_upload.id is None + def test__deserialize_message_components( self, entity_factory_impl, @@ -6239,7 +6301,9 @@ def test__deserialize_message_components( assert len(message_components) == 6 - assert message_components[0] == entity_factory_impl._deserialize_action_row_component(action_row_payload) + assert message_components[0] == entity_factory_impl._deserialize_message_action_row_component( + action_row_payload + ) assert message_components[1] == entity_factory_impl._deserialize_text_display_component(text_display_payload) @@ -6256,12 +6320,14 @@ def test__deserialize_message_components_handles_unknown_top_component_type(self assert len(message_components) == 0 - def test__deserialize_modal_components(self, entity_factory_impl, action_row_payload, text_input_payload): + def test__deserialize_modal_components( + self, entity_factory_impl, action_row_payload, text_input_payload, label_payload + ): action_row_payload["components"] = [text_input_payload] - modal_components = entity_factory_impl._deserialize_modal_components([action_row_payload]) + modal_components = entity_factory_impl._deserialize_modal_components([action_row_payload, label_payload]) - assert len(modal_components) == 1 + assert len(modal_components) == 2 assert modal_components[0] == component_models.ModalActionRowComponent( type=component_models.ComponentType.ACTION_ROW, @@ -6269,6 +6335,8 @@ def test__deserialize_modal_components(self, entity_factory_impl, action_row_pay components=[entity_factory_impl._deserialize_text_input(text_input_payload)], ) + assert modal_components[1] == entity_factory_impl._deserialize_label_component(label_payload) + def test__deserialize_modal_components_handles_unknown_top_component_type(self, entity_factory_impl): modal_components = entity_factory_impl._deserialize_modal_components([{"type": 9999}]) diff --git a/tests/hikari/impl/test_special_endpoints.py b/tests/hikari/impl/test_special_endpoints.py index ce4b03b932..c815b21e73 100644 --- a/tests/hikari/impl/test_special_endpoints.py +++ b/tests/hikari/impl/test_special_endpoints.py @@ -1772,6 +1772,7 @@ def test_build(self): min_values=5, max_values=23, is_disabled=True, + is_required=False, ) payload, attachments = menu.build() @@ -1782,6 +1783,7 @@ def test_build(self): "custom_id": "45234fsdf", "placeholder": "meep", "disabled": True, + "required": False, "min_values": 5, "max_values": 23, } @@ -1797,6 +1799,7 @@ def test_build_without_optional_fields(self): "type": components.ComponentType.ROLE_SELECT_MENU, "custom_id": "o2o2o2", "disabled": False, + "required": True, "min_values": 0, "max_values": 1, } @@ -1850,6 +1853,7 @@ def test_build(self): min_values=22, max_values=53, is_disabled=True, + is_required=False, options=[special_endpoints.SelectOptionBuilder("meow", "vault")], ) @@ -1863,6 +1867,7 @@ def test_build(self): "min_values": 22, "max_values": 53, "disabled": True, + "required": False, "options": [{"label": "meow", "value": "vault", "default": False}], } @@ -1879,6 +1884,7 @@ def test_build_without_optional_fields(self): "min_values": 0, "max_values": 1, "disabled": False, + "required": True, "options": [], } @@ -1905,6 +1911,7 @@ def test_build(self): min_values=22, max_values=53, is_disabled=True, + is_required=False, channel_types=[channels.ChannelType.GUILD_CATEGORY], ) @@ -1918,6 +1925,7 @@ def test_build(self): "min_values": 22, "max_values": 53, "disabled": True, + "required": False, "channel_types": [channels.ChannelType.GUILD_CATEGORY], } @@ -1934,6 +1942,7 @@ def test_build_without_optional_fields(self): "min_values": 0, "max_values": 1, "disabled": False, + "required": True, "channel_types": [], } @@ -1989,7 +1998,7 @@ def test_build_partial(self): "type": components.ComponentType.TEXT_INPUT, "style": 1, "custom_id": "o2o2o2", - "label": "label", + # "label": "label", # FIXME: This needs to be changed, as the action row still needs this value. "required": True, "min_length": 0, "max_length": 4000, @@ -2016,7 +2025,7 @@ def test_build(self): "type": components.ComponentType.TEXT_INPUT, "style": 1, "custom_id": "o2o2o2", - "label": "label", + # "label": "label", "placeholder": "placeholder", "value": "value", "required": False, @@ -2614,6 +2623,217 @@ def test_build_without_undefined_fields(self): assert attachments == [] +class TestLabelBuilder: + def test_type_property(self): + label = special_endpoints.LabelComponentBuilder(label="test", component=mock.Mock()) + + assert label.type is components.ComponentType.LABEL + + def test_set_component(self): + component = mock.Mock() + component_2 = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_component(component_2) + + assert label.component == component_2 + + def test_set_text_input(self): + component = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_text_input( + "a-cool-id", + "another label???", + style=components.TextInputStyle.PARAGRAPH, + placeholder="a fancy placeholder", + value="no", + required=True, + min_length=3, + max_length=49, + id=2983457, + ) + + assert isinstance(label.component, special_endpoints.TextInputBuilder) + + assert label.component.custom_id == "a-cool-id" + assert label.component.label == "another label???" + assert label.component.style == components.TextInputStyle.PARAGRAPH + assert label.component.placeholder == "a fancy placeholder" + assert label.component.value == "no" + assert label.component.is_required == True + assert label.component.min_length == 3 + assert label.component.max_length == 49 + assert label.component.id == 2983457 + + def test_set_select_menu(self): + component = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_select_menu( + components.ComponentType.ROLE_SELECT_MENU, + "role-select", + placeholder="a fancy placeholder", + min_values=3, + max_values=49, + is_disabled=True, + is_required=False, + id=482, + ) + + assert isinstance(label.component, special_endpoints.SelectMenuBuilder) + + assert label.component.custom_id == "role-select" + assert label.component.placeholder == "a fancy placeholder" + assert label.component.min_values == 3 + assert label.component.max_values == 49 + assert label.component.is_disabled is True + assert label.component.is_required is False + assert label.component.id == 482 + + def test_set_text_menu(self): + component = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_text_menu( + "text-select", + placeholder="a fancy placeholder", + min_values=3, + max_values=49, + is_disabled=True, + is_required=False, + id=482, + ) + + assert isinstance(label.component, special_endpoints.TextSelectMenuBuilder) + + assert label.component.custom_id == "text-select" # pyright: ignore[reportUnknownMemberType] + assert label.component.placeholder == "a fancy placeholder" # pyright: ignore[reportUnknownMemberType] + assert label.component.min_values == 3 # pyright: ignore[reportUnknownMemberType] + assert label.component.max_values == 49 # pyright: ignore[reportUnknownMemberType] + assert label.component.is_disabled is True # pyright: ignore[reportUnknownMemberType] + assert label.component.is_required is False # pyright: ignore[reportUnknownMemberType] + assert label.component.id == 482 # pyright: ignore[reportUnknownMemberType] + assert label.component.options == [] # pyright: ignore[reportUnknownMemberType] + + def test_set_channel_menu(self): + component = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_channel_menu( + "role-select", + channel_types=[channels.ChannelType.GUILD_TEXT, channels.ChannelType.GUILD_VOICE], + placeholder="a fancy placeholder", + min_values=3, + max_values=49, + is_disabled=True, + is_required=False, + id=482, + ) + + assert isinstance(label.component, special_endpoints.ChannelSelectMenuBuilder) + + assert label.component.custom_id == "role-select" + assert label.component.channel_types == [channels.ChannelType.GUILD_TEXT, channels.ChannelType.GUILD_VOICE] + assert label.component.placeholder == "a fancy placeholder" + assert label.component.min_values == 3 + assert label.component.max_values == 49 + assert label.component.is_disabled is True + assert label.component.is_required is False + assert label.component.id == 482 + + def test_set_file_upload(self): + component = mock.Mock() + + label = special_endpoints.LabelComponentBuilder(label="test", component=component) + + assert label.component == component + + label.set_file_upload("file-upload", min_values=3, max_values=49, is_required=False, id=482) + + assert isinstance(label.component, special_endpoints.FileUploadComponentBuilder) + + assert label.component.custom_id == "file-upload" + assert label.component.min_values == 3 + assert label.component.max_values == 49 + assert label.component.is_required is False + assert label.component.id == 482 + + def test_build(self): + component = mock.Mock(build=mock.Mock(return_value=({}, []))) + + label = special_endpoints.LabelComponentBuilder( + id=2983472, label="a cool label", description="an even cooler description", component=component + ) + + payload, attachments = label.build() + + assert payload == { + "type": components.ComponentType.LABEL, + "id": 2983472, + "label": "a cool label", + "description": "an even cooler description", + "component": component.build()[0], + } + + assert attachments == [] + + def test_build_without_optional_fields(self): + component = mock.Mock(build=mock.Mock(return_value=({}, []))) + + label = special_endpoints.LabelComponentBuilder(label="a cool label", component=component) + + payload, attachments = label.build() + + assert payload == { + "type": components.ComponentType.LABEL, + "label": "a cool label", + "component": component.build()[0], + } + + assert attachments == [] + + +class TestFileUploadBuilder: + def test_type_property(self): + file_upload = special_endpoints.FileUploadComponentBuilder(custom_id="file-upload-id") + + assert file_upload.type is components.ComponentType.FILE_UPLOAD + + def test_build(self): + file_upload = special_endpoints.FileUploadComponentBuilder( + id=1234, custom_id="file-upload-id", min_values=482, max_values=500, is_required=False + ) + + payload, attachments = file_upload.build() + + assert payload == { + "type": components.ComponentType.FILE_UPLOAD, + "custom_id": "file-upload-id", + "id": 1234, + "min_values": 482, + "max_values": 500, + "required": False, + } + + assert attachments == [] + + class TestModalActionRow: def test_type_property(self): row = special_endpoints.ModalActionRowBuilder()