Skip to content

Commit f32b688

Browse files
committed
feat: FileUpload in Modals
1 parent 9d3b32b commit f32b688

File tree

8 files changed

+285
-13
lines changed

8 files changed

+285
-13
lines changed

discord/components.py

Lines changed: 63 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
from .types.components import TextDisplayComponent as TextDisplayComponentPayload
6161
from .types.components import ThumbnailComponent as ThumbnailComponentPayload
6262
from .types.components import UnfurledMediaItem as UnfurledMediaItemPayload
63+
from .types.components import FileUploadComponent as FileUploadComponentPayload
6364

6465
__all__ = (
6566
"Component",
@@ -754,7 +755,6 @@ def url(self, value: str) -> None:
754755

755756
@classmethod
756757
def from_dict(cls, data: UnfurledMediaItemPayload, state=None) -> UnfurledMediaItem:
757-
758758
r = cls(data.get("url"))
759759
r.proxy_url = data.get("proxy_url")
760760
r.height = data.get("height")
@@ -805,8 +805,8 @@ def __init__(self, data: ThumbnailComponentPayload, state=None):
805805
self.type: ComponentType = try_enum(ComponentType, data["type"])
806806
self.id: int = data.get("id")
807807
self.media: UnfurledMediaItem = (
808-
umi := data.get("media")
809-
) and UnfurledMediaItem.from_dict(umi, state=state)
808+
umi := data.get("media")
809+
) and UnfurledMediaItem.from_dict(umi, state=state)
810810
self.description: str | None = data.get("description")
811811
self.spoiler: bool | None = data.get("spoiler")
812812

@@ -1163,6 +1163,65 @@ def walk_components(self) -> Iterator[Component]:
11631163
yield from [self.component]
11641164

11651165

1166+
class FileUpload(Component):
1167+
"""Represents an File Upload field from the Discord Bot UI Kit.
1168+
This inherits from :class:`Component`.
1169+
1170+
Attributes
1171+
----------
1172+
custom_id: Optional[:class:`str`]
1173+
The custom ID of the file upload field that gets received during an interaction.
1174+
min_values: Optional[:class:`int`]
1175+
The minimum number of files that must be uploaded.
1176+
Defaults to 0.
1177+
max_values: Optional[:class:`int`]
1178+
The maximum number of files that can be uploaded.
1179+
required: Optional[:class:`bool`]
1180+
Whether the file upload field is required or not. Defaults to `True`.
1181+
id: Optional[:class:`int`]
1182+
The file upload's ID.
1183+
"""
1184+
1185+
__slots__: tuple[str, ...] = (
1186+
"type",
1187+
"custom_id",
1188+
"min_values",
1189+
"max_values",
1190+
"required",
1191+
"id",
1192+
)
1193+
1194+
__repr_info__: ClassVar[tuple[str, ...]] = __slots__
1195+
versions: tuple[int, ...] = (1, 2)
1196+
1197+
def __init__(self, data: FileUploadComponentPayload):
1198+
self.type = ComponentType.file_upload
1199+
self.id: int | None = data.get("id")
1200+
self.custom_id = data["custom_id"]
1201+
self.min_values: int | None = data.get("min_values", None)
1202+
self.max_values: int | None = data.get("max_values", None)
1203+
self.required: bool = data.get("required", True)
1204+
1205+
def to_dict(self) -> FileUploadComponentPayload:
1206+
payload = {
1207+
"type": 19,
1208+
"id": self.id,
1209+
}
1210+
if self.custom_id:
1211+
payload["custom_id"] = self.custom_id
1212+
1213+
if self.min_values:
1214+
payload["min_values"] = self.min_values
1215+
1216+
if self.max_values:
1217+
payload["max_values"] = self.max_values
1218+
1219+
if not self.required:
1220+
payload["required"] = self.required
1221+
1222+
return payload # type: ignore
1223+
1224+
11661225
COMPONENT_MAPPINGS = {
11671226
1: ActionRow,
11681227
2: Button,
@@ -1180,6 +1239,7 @@ def walk_components(self) -> Iterator[Component]:
11801239
14: Separator,
11811240
17: Container,
11821241
18: Label,
1242+
19: FileUpload,
11831243
}
11841244

11851245
STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent)

discord/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -733,6 +733,7 @@ class ComponentType(Enum):
733733
separator = 14
734734
content_inventory_entry = 16
735735
container = 17
736+
file_upload = 19
736737

737738
def __int__(self):
738739
return self.value

discord/types/components.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
from .emoji import PartialEmoji
3434
from .snowflake import Snowflake
3535

36-
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18]
36+
ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 18, 19]
3737
ButtonStyle = Literal[1, 2, 3, 4, 5, 6]
3838
InputTextStyle = Literal[1, 2]
3939
SeparatorSpacingSize = Literal[1, 2]
@@ -159,7 +159,15 @@ class LabelComponent(BaseComponent):
159159
component: SelectMenu | InputText
160160

161161

162-
Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText]
162+
class FileUploadComponent(BaseComponent):
163+
type: Literal[19]
164+
custom_id: str
165+
max_values: NotRequired[int]
166+
max_values: NotRequired[int]
167+
required: NotRequired[bool]
168+
169+
170+
Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText, FileUploadComponent]
163171

164172

165173
AllowedContainerComponents = Union[

discord/types/message.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ class Attachment(TypedDict):
8181
waveform: NotRequired[str]
8282
flags: NotRequired[int]
8383
title: NotRequired[str]
84+
ephemeral: NotRequired[bool]
8485

8586

8687
MessageActivityType = Literal[1, 2, 3, 5]

discord/ui/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .button import *
1212
from .container import *
1313
from .file import *
14+
from .file_upload import *
1415
from .input_text import *
1516
from .item import *
1617
from .media_gallery import *

discord/ui/file_upload.py

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from typing import TYPE_CHECKING
5+
6+
from ..message import Attachment
7+
from ..components import FileUpload as FileUploadComponent
8+
from ..enums import ComponentType
9+
10+
__all__ = ("FileUpload",)
11+
12+
if TYPE_CHECKING:
13+
from ..interactions import Interaction
14+
from ..types.components import FileUploadComponent as FileUploadComponentPayload
15+
16+
17+
class FileUpload:
18+
"""Represents a UI file upload field.
19+
20+
.. versionadded:: 2.7
21+
22+
Parameters
23+
----------
24+
custom_id: Optional[:class:`str`]
25+
The ID of the input text field that gets received during an interaction.
26+
label: :class:`str`
27+
The label for the file upload field.
28+
Must be 45 characters or fewer.
29+
description: Optional[:class:`str`]
30+
The description for the file upload field.
31+
Must be 100 characters or fewer.
32+
min_values: Optional[:class:`int`]
33+
The minimum number of files that must be uploaded.
34+
Defaults to 0 and must be between 0 and 10, inclusive.
35+
max_values: Optional[:class:`int`]
36+
The maximum number of files that can be uploaded.
37+
Must be between 1 and 10, inclusive.
38+
required: Optional[:class:`bool`]
39+
Whether the file upload field is required or not. Defaults to ``True``.
40+
row: Optional[:class:`int`]
41+
The relative row this file upload field belongs to. A modal dialog can only have 5
42+
rows. By default, items are arranged automatically into those 5 rows. If you'd
43+
like to control the relative positioning of the row then passing an index is advised.
44+
For example, row=1 will show up before row=2. Defaults to ``None``, which is automatic
45+
ordering. The row number must be between 0 and 4 (i.e. zero indexed).
46+
"""
47+
48+
__item_repr_attributes__: tuple[str, ...] = (
49+
"label",
50+
"required",
51+
"min_values",
52+
"max_values",
53+
"custom_id",
54+
"id",
55+
"description",
56+
)
57+
58+
def __init__(
59+
self,
60+
*,
61+
custom_id: str | None = None,
62+
label: str,
63+
min_values: int | None = None,
64+
max_values: int | None = None,
65+
required: bool | None = True,
66+
row: int | None = None,
67+
id: int | None = None,
68+
description: str | None = None,
69+
):
70+
super().__init__()
71+
if len(str(label)) > 45:
72+
raise ValueError("label must be 45 characters or fewer")
73+
if description and len(description) > 100:
74+
raise ValueError("description must be 100 characters or fewer")
75+
if min_values and (min_values < 0 or min_values > 10):
76+
raise ValueError("min_values must be between 0 and 10")
77+
if max_values and (max_values < 1 or max_values > 10):
78+
raise ValueError("max_length must be between 1 and 10")
79+
if not isinstance(custom_id, str) and custom_id is not None:
80+
raise TypeError(
81+
f"expected custom_id to be str, not {custom_id.__class__.__name__}"
82+
)
83+
custom_id = os.urandom(16).hex() if custom_id is None else custom_id
84+
self.label: str = str(label)
85+
self.description: str | None = description
86+
87+
self._underlying: FileUploadComponent = FileUploadComponent._raw_construct(
88+
type=ComponentType.file_upload,
89+
custom_id=custom_id,
90+
label=label,
91+
min_values=min_values,
92+
max_values=max_values,
93+
required=required,
94+
id=id,
95+
)
96+
self._interaction: Interaction | None = None
97+
self._values: list[str] | None = None
98+
self.row = row
99+
self._rendered_row: int | None = None
100+
101+
def __repr__(self) -> str:
102+
attrs = " ".join(
103+
f"{key}={getattr(self, key)!r}" for key in self.__item_repr_attributes__
104+
)
105+
return f"<{self.__class__.__name__} {attrs}>"
106+
107+
@property
108+
def type(self) -> ComponentType:
109+
return self._underlying.type
110+
111+
@property
112+
def id(self) -> int | None:
113+
"""The file upload's ID. If not provided by the user, it is set sequentially by Discord."""
114+
return self._underlying.id
115+
116+
@property
117+
def custom_id(self) -> str:
118+
"""The ID of the file upload field that gets received during an interaction."""
119+
return self._underlying.custom_id
120+
121+
@custom_id.setter
122+
def custom_id(self, value: str):
123+
if not isinstance(value, str):
124+
raise TypeError(
125+
f"custom_id must be None or str not {value.__class__.__name__}"
126+
)
127+
self._underlying.custom_id = value
128+
129+
@property
130+
def min_values(self) -> int | None:
131+
"""The minimum number of files that must be uploaded. Defaults to 0."""
132+
return self._underlying.min_values
133+
134+
@min_values.setter
135+
def min_values(self, value: int | None):
136+
if value and not isinstance(value, int):
137+
raise TypeError(f"min_values must be None or int not {value.__class__.__name__}") # type: ignore
138+
if value and (value < 0 or value) > 4000:
139+
raise ValueError("min_values must be between 0 and 10")
140+
self._underlying.min_values = value
141+
142+
@property
143+
def max_values(self) -> int | None:
144+
"""The maximum number of files that can be uploaded."""
145+
return self._underlying.max_values
146+
147+
@max_values.setter
148+
def max_values(self, value: int | None):
149+
if value and not isinstance(value, int):
150+
raise TypeError(f"max_values must be None or int not {value.__class__.__name__}") # type: ignore
151+
if value and (value <= 0 or value > 4000):
152+
raise ValueError("max_values must be between 1 and 10")
153+
self._underlying.max_values = value
154+
155+
@property
156+
def required(self) -> bool | None:
157+
"""Whether the input file upload is required or not. Defaults to ``True``."""
158+
return self._underlying.required
159+
160+
@required.setter
161+
def required(self, value: bool | None):
162+
if not isinstance(value, bool):
163+
raise TypeError(f"required must be bool not {value.__class__.__name__}") # type: ignore
164+
self._underlying.required = bool(value)
165+
166+
@property
167+
def values(self) -> list[Attachment] | None:
168+
"""The files that were uploaded to the field."""
169+
if self._interaction is None:
170+
return None
171+
attachments = []
172+
for attachment_id in self._values:
173+
print(attachment_id)
174+
attachment_data = self._interaction.data["resolved"]["attachments"][attachment_id]
175+
attachments.append(Attachment(state=self._interaction._state, data=attachment_data))
176+
return attachments
177+
178+
@property
179+
def width(self) -> int:
180+
return 5
181+
182+
def to_component_dict(self) -> FileUploadComponentPayload:
183+
return self._underlying.to_dict()
184+
185+
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
186+
print(data)
187+
self._interaction = interaction
188+
self._values = data.get("values", [])
189+
190+
@staticmethod
191+
def uses_label() -> bool:
192+
return True
193+

discord/ui/input_text.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,7 +246,7 @@ def to_component_dict(self) -> InputTextComponentPayload:
246246
return self._underlying.to_dict()
247247

248248
def refresh_state(self, data) -> None:
249-
self._input_value = data["value"]
249+
self._input_value = data.get("value", None)
250250

251251
def refresh_from_modal(self, interaction: Interaction, data: dict) -> None:
252252
return self.refresh_state(data)

0 commit comments

Comments
 (0)