Skip to content
Draft
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/1390.feature.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a :class:`ui.FileUpload` component for modals. This allows you to receive up to 10 files from users in a modal.
66 changes: 65 additions & 1 deletion disnake/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
ComponentType as ComponentTypeLiteral,
ContainerComponent as ContainerComponentPayload,
FileComponent as FileComponentPayload,
FileUploadComponent as FileUploadComponentPayload,
LabelComponent as LabelComponentPayload,
MediaGalleryComponent as MediaGalleryComponentPayload,
MediaGalleryItem as MediaGalleryItemPayload,
Expand Down Expand Up @@ -91,6 +92,7 @@
"Separator",
"Container",
"Label",
"FileUpload",
)

# miscellaneous components-related type aliases
Expand Down Expand Up @@ -140,6 +142,7 @@
# valid `Label.component` types
LabelChildComponent = Union[
"TextInput",
"FileUpload",
"AnySelectMenu",
]

Expand Down Expand Up @@ -195,6 +198,7 @@ class Component:
- :class:`Separator`
- :class:`Container`
- :class:`Label`
- :class:`FileUpload`

This class is abstract and cannot be instantiated.

Expand Down Expand Up @@ -1504,7 +1508,7 @@ class Label(Component):
The label text.
description: Optional[:class:`str`]
The description text for the label.
component: Union[:class:`TextInput`, :class:`StringSelectMenu`]
component: Union[:class:`TextInput`, :class:`FileUpload`, :class:`StringSelectMenu`]
The component within the label.
id: :class:`int`
The numeric identifier for the component.
Expand Down Expand Up @@ -1544,6 +1548,65 @@ def to_dict(self) -> LabelComponentPayload:
return payload


class FileUpload(Component):
"""Represents a file upload component from the Discord Bot UI Kit.

This allows you to receive files from users, and can only be used in modals.

.. note::
The user constructible and usable type to create a
file upload is :class:`disnake.ui.FileUpload`.

.. versionadded:: |vnext|

Attributes
----------
custom_id: :class:`str`
The ID of the file upload that gets received during an interaction.
min_values: :class:`int`
The minimum number of files that must be uploaded.
Defaults to 1 and must be between 0 and 10.
max_values: :class:`int`
The maximum number of files that must be uploaded.
Defaults to 1 and must be between 1 and 10.
required: :class:`bool`
Whether the file upload is required.
Defaults to ``True``.
id: :class:`int`
The numeric identifier for the component.
This is always present in components received from the API,
and unique within a message.
"""

__slots__: Tuple[str, ...] = (
"custom_id",
"min_values",
"max_values",
"required",
)

__repr_attributes__: ClassVar[Tuple[str, ...]] = __slots__

def __init__(self, data: FileUploadComponentPayload) -> None:
self.type: Literal[ComponentType.file_upload] = ComponentType.file_upload
self.id = data.get("id", 0)

self.custom_id: str = data["custom_id"]
self.min_values: int = data.get("min_values", 1)
self.max_values: int = data.get("max_values", 1)
self.required: bool = data.get("required", True)

def to_dict(self) -> FileUploadComponentPayload:
return {
"type": self.type.value,
"id": self.id,
"custom_id": self.custom_id,
"min_values": self.min_values,
"max_values": self.max_values,
"required": self.required,
}


# types of components that are allowed in a message's action rows;
# see also `ActionRowMessageComponent` type alias
VALID_ACTION_ROW_MESSAGE_COMPONENT_TYPES: Final = (
Expand Down Expand Up @@ -1592,6 +1655,7 @@ def handle_media_item_input(value: MediaItemInput) -> UnfurledMediaItem:
ComponentType.separator.value: Separator,
ComponentType.container.value: Container,
ComponentType.label.value: Label,
ComponentType.file_upload.value: FileUpload,
}


Expand Down
5 changes: 5 additions & 0 deletions disnake/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -1278,6 +1278,11 @@ class ComponentType(Enum):

.. versionadded:: 2.11
"""
file_upload = 19
"""Represents a file upload component.

.. versionadded:: |vnext|
"""

def __int__(self) -> int:
return self.value
Expand Down
2 changes: 1 addition & 1 deletion disnake/interactions/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -2133,7 +2133,7 @@ def get_with_type(
if data_type is OptionType.role or data_type is ComponentType.role_select:
return self.roles.get(int(key), default)

if data_type is OptionType.attachment:
if data_type is OptionType.attachment or data_type is ComponentType.file_upload:
return self.attachments.get(int(key), default)

return default
Expand Down
17 changes: 12 additions & 5 deletions disnake/interactions/modal.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@

from ..components import _SELECT_COMPONENT_TYPE_VALUES
from ..enums import ComponentType
from ..message import Message
from ..message import Attachment, Message
from ..utils import cached_slot_property
from .base import ClientT, Interaction, InteractionDataResolved

Expand All @@ -40,7 +40,7 @@

T = TypeVar("T")

# {custom_id: text_input_value | select_values}
# {custom_id: text_input_value | select_values | attachments}
ResolvedValues = Dict[str, Union[str, Sequence[T]]]


Expand Down Expand Up @@ -194,7 +194,10 @@ def _resolve_values(
value = component.get("value")
elif component["type"] == ComponentType.string_select.value:
value = component.get("values")
elif component["type"] in _SELECT_COMPONENT_TYPE_VALUES:
elif (
component["type"] in _SELECT_COMPONENT_TYPE_VALUES
or component["type"] == ComponentType.file_upload.value
):
# auto-populated selects
component_type = ComponentType(component["type"])
value = [resolve(v, component_type) for v in component.get("values") or []]
Expand All @@ -220,15 +223,19 @@ def values(self) -> ResolvedValues[str]:
return self._resolve_values(lambda id, type: str(id))

@cached_slot_property("_cs_resolved_values")
def resolved_values(self) -> ResolvedValues[Union[str, Member, User, Role, AnyChannel]]:
"""Dict[:class:`str`, Union[:class:`str`, Sequence[:class:`str`, :class:`Member`, :class:`User`, :class:`Role`, Union[:class:`abc.GuildChannel`, :class:`Thread`, :class:`PartialMessageable`]]]]: The (resolved) values the user entered in the modal.
def resolved_values(
self,
) -> ResolvedValues[Union[str, Member, User, Role, AnyChannel, Attachment]]:
"""Dict[:class:`str`, Union[:class:`str`, Sequence[:class:`str`, :class:`Member`, :class:`User`, :class:`Role`, Union[:class:`abc.GuildChannel`, :class:`Thread`, :class:`PartialMessageable`], :class:`Attachment`]]]: The (resolved) values the user entered in the modal.
This is a dict of the form ``{custom_id: value}``.

For select menus, the corresponding dict value is a list of the values the user has selected.
For select menus of type :attr:`~ComponentType.string_select`,
this is equivalent to :attr:`values`;
for other select menu types, these are full objects corresponding to the selected entities.

For file uploads, the corresponding dict value is a list of files the user has uploaded.

.. versionadded:: 2.11
"""
resolved_data = self.data.resolved
Expand Down
16 changes: 13 additions & 3 deletions disnake/types/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@

from typing import List, Literal, Optional, TypedDict, Union

from typing_extensions import NotRequired, Required, TypeAlias
from typing_extensions import NotRequired, ReadOnly, Required, TypeAlias

from .channel import ChannelType
from .emoji import PartialEmoji
from .snowflake import Snowflake

ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18]
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 17, 18, 19]
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
TextInputStyle = Literal[1, 2]
SeparatorSpacing = Literal[1, 2]
Expand All @@ -34,6 +34,7 @@
"SeparatorComponent",
"ContainerComponent",
"LabelComponent",
"FileUploadComponent",
]

ActionRowChildComponent = Union[
Expand All @@ -45,6 +46,7 @@
LabelChildComponent = Union[
"TextInput",
"AnySelectMenu",
"FileUploadComponent",
]

# valid message component types (v1/v2)
Expand Down Expand Up @@ -72,7 +74,7 @@


class _BaseComponent(TypedDict):
# type: ComponentType # FIXME: current version of pyright only supports PEP 705 experimentally, this can be re-enabled in 1.1.353+
type: ReadOnly[ComponentType]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Out of scope. Please submit a separate change request.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't worth a separate PR, as it doesn't really fix anything. I can just remove it from this one, if you'd like.

id: int # note: technically optional when sending, we just default to 0 for simplicity, which is equivalent (https://discord.com/developers/docs/components/reference#anatomy-of-a-component)


Expand Down Expand Up @@ -185,6 +187,14 @@ class LabelComponent(_BaseComponent):
component: LabelChildComponent


class FileUploadComponent(_BaseComponent):
type: Literal[19]
custom_id: str
min_values: NotRequired[int]
max_values: NotRequired[int]
required: NotRequired[bool]


# components v2


Expand Down
7 changes: 6 additions & 1 deletion disnake/types/interactions.py
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ class ModalInteractionChannelSelectData(_BaseSnowflakeModalComponentInteractionD
type: Literal[8]


class ModalInteractionFileUploadData(_BaseSnowflakeModalComponentInteractionData):
type: Literal[19]


# top-level modal component data

ModalInteractionActionRowChildData: TypeAlias = ModalInteractionTextInputData
Expand All @@ -282,6 +286,7 @@ class ModalInteractionTextDisplayData(_BaseComponentInteractionData):
ModalInteractionRoleSelectData,
ModalInteractionMentionableSelectData,
ModalInteractionChannelSelectData,
ModalInteractionFileUploadData,
]


Expand All @@ -307,7 +312,7 @@ class ModalInteractionLabelData(_BaseComponentInteractionData):
class ModalInteractionData(TypedDict):
custom_id: str
components: List[ModalInteractionComponentData]
# resolved: NotRequired[InteractionDataResolved] # undocumented
resolved: NotRequired[InteractionDataResolved]


## Interactions
Expand Down
1 change: 1 addition & 0 deletions disnake/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from .button import *
from .container import *
from .file import *
from .file_upload import *
from .item import *
from .label import *
from .media_gallery import *
Expand Down
3 changes: 3 additions & 0 deletions disnake/ui/action_row.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
Component,
Container as ContainerComponent,
FileComponent as FileComponent,
FileUpload as FileUploadComponent,
Label as LabelComponent,
MediaGallery as MediaGalleryComponent,
MentionableSelectMenu as MentionableSelectComponent,
Expand All @@ -57,6 +58,7 @@
from .button import Button
from .container import Container
from .file import File
from .file_upload import FileUpload
from .item import UIComponent, WrappedComponent
from .label import Label
from .media_gallery import MediaGallery
Expand Down Expand Up @@ -1161,6 +1163,7 @@ def components_from_message(message: Message) -> List[MessageTopLevelComponent]:
SeparatorComponent: Separator,
ContainerComponent: Container,
LabelComponent: Label,
FileUploadComponent: FileUpload,
}


Expand Down
Loading
Loading