diff --git a/CHANGELOG.md b/CHANGELOG.md index 3098be39c9..39e90fbcd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ These changes are available on the `master` branch, but have not yet been releas - Adds pre-typed and pre-constructed with select_type `ui.Select` aliases for the different select types: `ui.StringSelect`, `ui.UserSelect`, `ui.RoleSelect`, `ui.MentionableSelect`, and `ui.ChannelSelect`. +- Added `ui.FileUpload` for modals and the `FileUpload` component. + ([#2938](https://github.com/Pycord-Development/pycord/pull/2938)) ### Changed @@ -45,6 +47,11 @@ These changes are available on the `master` branch, but have not yet been releas ([#2924](https://github.com/Pycord-Development/pycord/pull/2924)) - Fixed OPUS Decode Error when recording audio. ([#2925](https://github.com/Pycord-Development/pycord/pull/2925)) +- Fixed modal input values being misordered when using the `row` parameter and inserting + items out of row order. + ([#2938](https://github.com/Pycord-Development/pycord/pull/2938)) +- Fixed a KeyError when a text input is left blank in a modal. + ([#2938](https://github.com/Pycord-Development/pycord/pull/2938)) ### Removed diff --git a/discord/components.py b/discord/components.py index 9f9e76aa8e..8a10a6dac4 100644 --- a/discord/components.py +++ b/discord/components.py @@ -50,6 +50,7 @@ from .types.components import Component as ComponentPayload from .types.components import ContainerComponent as ContainerComponentPayload from .types.components import FileComponent as FileComponentPayload + from .types.components import FileUploadComponent as FileUploadComponentPayload from .types.components import InputText as InputTextComponentPayload from .types.components import LabelComponent as LabelComponentPayload from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload @@ -81,6 +82,7 @@ "Container", "Label", "SelectDefaultValue", + "FileUpload", ) C = TypeVar("C", bound="Component") @@ -938,7 +940,6 @@ def url(self, value: str) -> None: @classmethod def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem: - r = cls(data.get("url")) r.proxy_url = data.get("proxy_url") r.height = data.get("height") @@ -1347,6 +1348,72 @@ def walk_components(self) -> Iterator[Component]: yield from [self.component] +class FileUpload(Component): + """Represents an File Upload field from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + This class is not useable by end-users; see :class:`discord.ui.FileUpload` instead. + + .. versionadded:: 2.7 + + Attributes + ---------- + custom_id: Optional[:class:`str`] + The custom ID of the file upload field that gets received during an interaction. + min_values: Optional[:class:`int`] + The minimum number of files that must be uploaded. + Defaults to 0. + max_values: Optional[:class:`int`] + The maximum number of files that can be uploaded. + required: Optional[:class:`bool`] + Whether the file upload field is required or not. Defaults to `True`. + id: Optional[:class:`int`] + The file upload's ID. + """ + + __slots__: tuple[str, ...] = ( + "type", + "custom_id", + "min_values", + "max_values", + "required", + "id", + ) + + __repr_info__: ClassVar[tuple[str, ...]] = __slots__ + versions: tuple[int, ...] = (1, 2) + + def __init__(self, data: FileUploadComponentPayload): + self.type = ComponentType.file_upload + self.id: int | None = data.get("id") + self.custom_id = data["custom_id"] + self.min_values: int | None = data.get("min_values", None) + self.max_values: int | None = data.get("max_values", None) + self.required: bool = data.get("required", True) + + def to_dict(self) -> FileUploadComponentPayload: + payload = { + "type": 19, + "custom_id": self.custom_id, + } + if self.id is not None: + payload["id"] = self.id + + if self.min_values: + payload["min_values"] = self.min_values + + if self.max_values: + payload["max_values"] = self.max_values + + if not self.required: + payload["required"] = self.required + + return payload # type: ignore + + COMPONENT_MAPPINGS = { 1: ActionRow, 2: Button, @@ -1364,6 +1431,7 @@ def walk_components(self) -> Iterator[Component]: 14: Separator, 17: Container, 18: Label, + 19: FileUpload, } STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent) diff --git a/discord/enums.py b/discord/enums.py index cc2429a84c..63557c853b 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -734,6 +734,8 @@ class ComponentType(Enum): separator = 14 content_inventory_entry = 16 container = 17 + label = 18 + file_upload = 19 def __int__(self): return self.value diff --git a/discord/types/components.py b/discord/types/components.py index 5c90bec604..f78d3c78a1 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -33,7 +33,7 @@ from .emoji import PartialEmoji from .snowflake import Snowflake -ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18] +ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] SeparatorSpacingSize = Literal[1, 2] @@ -163,10 +163,20 @@ class LabelComponent(BaseComponent): type: Literal[18] label: str description: NotRequired[str] - component: SelectMenu | InputText + component: SelectMenu | InputText | FileUploadComponent -Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText] +class FileUploadComponent(BaseComponent): + type: Literal[19] + custom_id: str + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] + + +Component = Union[ + ActionRow, ButtonComponent, SelectMenu, InputText, FileUploadComponent +] AllowedContainerComponents = Union[ diff --git a/discord/types/message.py b/discord/types/message.py index d9bc4f2f9d..c6a48881c7 100644 --- a/discord/types/message.py +++ b/discord/types/message.py @@ -81,6 +81,7 @@ class Attachment(TypedDict): waveform: NotRequired[str] flags: NotRequired[int] title: NotRequired[str] + ephemeral: NotRequired[bool] MessageActivityType = Literal[1, 2, 3, 5] diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 473ac45563..cd2b701ebb 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -11,6 +11,7 @@ from .button import * from .container import * from .file import * +from .file_upload import * from .input_text import * from .item import * from .media_gallery import * diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py new file mode 100644 index 0000000000..377d9dc2be --- /dev/null +++ b/discord/ui/file_upload.py @@ -0,0 +1,188 @@ +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +from ..components import FileUpload as FileUploadComponent +from ..enums import ComponentType +from ..message import Attachment + +__all__ = ("FileUpload",) + +if TYPE_CHECKING: + from ..interactions import Interaction + from ..types.components import FileUploadComponent as FileUploadComponentPayload + + +class FileUpload: + """Represents a UI file upload field. + + .. versionadded:: 2.7 + + Parameters + ---------- + label: :class:`str` + The label for the file upload field. + Must be 45 characters or fewer. + custom_id: Optional[:class:`str`] + The ID of the input text field that gets received during an interaction. + description: Optional[:class:`str`] + The description for the file upload field. + Must be 100 characters or fewer. + min_values: Optional[:class:`int`] + The minimum number of files that must be uploaded. + Defaults to 0 and must be between 0 and 10, inclusive. + max_values: Optional[:class:`int`] + The maximum number of files that can be uploaded. + Must be between 1 and 10, inclusive. + required: Optional[:class:`bool`] + Whether the file upload field is required or not. Defaults to ``True``. + row: Optional[:class:`int`] + The relative row this file upload field belongs to. A modal dialog can only have 5 + rows. By default, items are arranged automatically into those 5 rows. If you'd + like to control the relative positioning of the row then passing an index is advised. + For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic + ordering. The row number must be between 0 and 4 (i.e. zero indexed). + """ + + __item_repr_attributes__: tuple[str, ...] = ( + "label", + "required", + "min_values", + "max_values", + "custom_id", + "id", + "description", + ) + + def __init__( + self, + *, + label: str, + custom_id: str | None = None, + min_values: int | None = None, + max_values: int | None = None, + required: bool | None = True, + row: int | None = None, + id: int | None = None, + description: str | None = None, + ): + super().__init__() + if len(str(label)) > 45: + raise ValueError("label must be 45 characters or fewer") + if description and len(description) > 100: + raise ValueError("description must be 100 characters or fewer") + if min_values and (min_values < 0 or min_values > 10): + raise ValueError("min_values must be between 0 and 10") + if max_values and (max_values < 1 or max_values > 10): + raise ValueError("max_length must be between 1 and 10") + if custom_id is not None and not isinstance(custom_id, str): + raise TypeError( + f"expected custom_id to be str, not {custom_id.__class__.__name__}" + ) + custom_id = os.urandom(16).hex() if custom_id is None else custom_id + self.label: str = str(label) + self.description: str | None = description + + self._underlying: FileUploadComponent = FileUploadComponent._raw_construct( + type=ComponentType.file_upload, + custom_id=custom_id, + min_values=min_values, + max_values=max_values, + required=required, + id=id, + ) + self._attachments: list[Attachment] | None = None + self.row = row + self._rendered_row: int | None = None + + def __repr__(self) -> str: + attrs = " ".join( + f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__ + ) + return f"<{self.__class__.__name__} {attrs}>" + + @property + def type(self) -> ComponentType: + return self._underlying.type + + @property + def id(self) -> int | None: + """The file upload's ID. If not provided by the user, it is set sequentially by Discord.""" + return self._underlying.id + + @property + def custom_id(self) -> str: + """The ID of the file upload field that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str): + if not isinstance(value, str): + raise TypeError( + f"custom_id must be None or str not {value.__class__.__name__}" + ) + self._underlying.custom_id = value + + @property + def min_values(self) -> int | None: + """The minimum number of files that must be uploaded. Defaults to 0.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int | None): + if value and not isinstance(value, int): + raise TypeError(f"min_values must be None or int not {value.__class__.__name__}") # type: ignore + if value and (value < 0 or value > 10): + raise ValueError("min_values must be between 0 and 10") + self._underlying.min_values = value + + @property + def max_values(self) -> int | None: + """The maximum number of files that can be uploaded.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int | None): + if value and not isinstance(value, int): + raise TypeError(f"max_values must be None or int not {value.__class__.__name__}") # type: ignore + if value and (value < 1 or value > 10): + raise ValueError("max_values must be between 1 and 10") + self._underlying.max_values = value + + @property + def required(self) -> bool | None: + """Whether the input file upload is required or not. Defaults to ``True``.""" + return self._underlying.required + + @required.setter + def required(self, value: bool | None): + if not isinstance(value, bool): + raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore + self._underlying.required = bool(value) + + @property + def values(self) -> list[Attachment] | None: + """The files that were uploaded to the field.""" + return self._attachments + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> FileUploadComponentPayload: + return self._underlying.to_dict() + + def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: + values = data.get("values", []) + self._attachments = [ + Attachment( + state=interaction._state, + data=interaction.data["resolved"]["attachments"][attachment_id], + ) + for attachment_id in values + ] + + @staticmethod + def uses_label() -> bool: + return True diff --git a/discord/ui/input_text.py b/discord/ui/input_text.py index 4f7828ce97..0f49bd19ec 100644 --- a/discord/ui/input_text.py +++ b/discord/ui/input_text.py @@ -246,7 +246,7 @@ def to_component_dict(self) -> InputTextComponentPayload: return self._underlying.to_dict() def refresh_state(self, data) -> None: - self._input_value = data["value"] + self._input_value = data.get("value", None) def refresh_from_modal(self, interaction: Interaction, data: dict) -> None: return self.refresh_state(data) diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 32bf5853bc..9cc76ffc86 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -10,6 +10,7 @@ from ..enums import ComponentType from ..utils import find +from .file_upload import FileUpload from .input_text import InputText from .item import Item from .select import Select @@ -29,7 +30,7 @@ M = TypeVar("M", bound="Modal", covariant=True) -ModalItem = Union[InputText, Item[M]] +ModalItem = Union[InputText, FileUpload, Item[M]] class Modal: @@ -249,10 +250,14 @@ def add_item(self, item: ModalItem) -> Self: if len(self._children) > 5: raise ValueError("You can only have up to 5 items in a modal dialog.") - if not isinstance(item, (InputText, Item)): - raise TypeError(f"expected InputText or Item, not {item.__class__!r}") - if isinstance(item, (InputText, Select)) and not item.label: - raise ValueError("InputTexts and Selects must have a label set") + if not isinstance(item, (InputText, FileUpload, Item)): + raise TypeError( + f"expected InputText, FileUpload, or Item, not {item.__class__!r}" + ) + if isinstance(item, (InputText, FileUpload, Select)) and not item.label: + raise ValueError( + "InputTexts, FileUploads, and Selects must have a label set" + ) self._weights.add_item(item) self._children.append(item) @@ -410,8 +415,11 @@ async def dispatch(self, user_id: int, custom_id: str, interaction: Interaction) ) ) ] - for component, child in zip(components, value.children): - child.refresh_from_modal(interaction, component) + # match component by id + for component in components: + item = value.get_item(component.get("custom_id") or component.get("id")) + if item is not None: + item.refresh_from_modal(interaction, component) await value.callback(interaction) self.remove_modal(value, user_id) except Exception as e: diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 20692fce21..2e4f28a644 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -505,6 +505,36 @@ of :class:`enum.Enum`. .. attribute:: channel_select Represents a channel select component. + .. attribute:: section + + Represents a section component. + .. attribute:: text_display + + Represents a text display component. + .. attribute:: thumbnail + + Represents a thumbnail component. + .. attribute:: media_gallery + + Represents a media gallery component. + .. attribute:: file + + Represents a file component. + .. attribute:: separator + + Represents a separator component. + .. attribute:: content_inventory_entry + + Represents a content inventory entry component. + .. attribute:: container + + Represents a container component. + .. attribute:: label + + Represents a label component. + .. attribute:: file_upload + + Represents a file upload component. .. class:: ButtonStyle diff --git a/docs/api/models.rst b/docs/api/models.rst index 5075d7084b..2a82c04aac 100644 --- a/docs/api/models.rst +++ b/docs/api/models.rst @@ -378,7 +378,7 @@ Interactions .. autoclass:: InteractionCallback() :members: -Message Components +UI Components ------------------ .. attributetable:: Component @@ -445,6 +445,12 @@ Message Components :members: :inherited-members: +.. attributetable:: FileUpload + +.. autoclass:: FileUpload() + :members: + :inherited-members: + Emoji ----- diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index 8714f59b85..8b77a2422e 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -129,3 +129,9 @@ Objects .. autoclass:: discord.ui.InputText :members: :inherited-members: + +.. attributetable:: discord.ui.FileUpload + +.. autoclass:: discord.ui.FileUpload + :members: + :inherited-members: diff --git a/examples/modal_dialogs.py b/examples/modal_dialogs.py index 4d10ddab0c..14ca8ac09e 100644 --- a/examples/modal_dialogs.py +++ b/examples/modal_dialogs.py @@ -36,11 +36,18 @@ def __init__(self, *args, **kwargs) -> None: description="If it is not listed, skip this question.", required=False, ), + discord.ui.FileUpload( + label="What's your favorite picture?", + max_values=1, + description="You may only pick one! Chose wisely!", + required=False, + ), *args, **kwargs, ) async def callback(self, interaction: discord.Interaction): + await interaction.response.defer() embed = discord.Embed( title="Your Modal Results", fields=[ @@ -50,10 +57,21 @@ async def callback(self, interaction: discord.Interaction): discord.EmbedField( name="Second Input", value=self.children[1].value, inline=False ), + discord.EmbedField( + name="Favorite Color", + value=self.children[3].values[0], + inline=False, + ), ], color=discord.Color.random(), ) - await interaction.response.send_message(embeds=[embed]) + attachment = self.children[4].values[0] if self.children[4].values else None + if attachment: + embed.set_image(url=f"attachment://{attachment.filename}") + await interaction.followup.send( + embeds=[embed], + files=[await attachment.to_file()] if attachment else [], + ) @bot.slash_command(name="modaltest")