Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
d8e1eef
fix(state): ensure _messages is not None before updating message list
plun1331 Sep 12, 2025
9d3b32b
chore: update changelog
plun1331 Sep 12, 2025
f32b688
feat: FileUpload in Modals
plun1331 Sep 18, 2025
9af88cf
Merge remote-tracking branch 'origin/master' into feat/model3
plun1331 Sep 18, 2025
b025df5
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 18, 2025
590a7a2
changelog & docs
plun1331 Sep 18, 2025
9c42603
ahh! nobody look! my shitty debug code!
plun1331 Sep 18, 2025
498c28b
add fileupload to __all__
plun1331 Sep 18, 2025
033f684
more changelog!!
plun1331 Sep 18, 2025
14665b6
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 18, 2025
7ba0276
docs n enums n stuff
plun1331 Sep 18, 2025
a00d79e
Merge remote-tracking branch 'origin/feat/model3' into feat/model3
plun1331 Sep 18, 2025
a903005
oh god more debug code
plun1331 Sep 18, 2025
6e36204
that aint right
plun1331 Sep 18, 2025
7a66cb7
that *still* aint right
plun1331 Sep 18, 2025
7bff68a
Apply suggestion from @Soheab
plun1331 Sep 18, 2025
b356c75
suggested review changes
plun1331 Sep 18, 2025
4478fcb
Merge remote-tracking branch 'origin/feat/model3' into feat/model3
plun1331 Sep 18, 2025
9a08c32
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Sep 18, 2025
a7fa66a
suggested review changes
plun1331 Sep 18, 2025
b9283b6
Merge remote-tracking branch 'origin/feat/model3' into feat/model3
plun1331 Sep 18, 2025
cfc2e5e
they can go in more than messages
plun1331 Sep 18, 2025
0b55aba
this is becoming a very large example
plun1331 Sep 18, 2025
99417f9
Update examples/modal_dialogs.py
plun1331 Sep 18, 2025
c0af721
Update discord/types/components.py (thanks copilot)
plun1331 Sep 26, 2025
46030b3
Apply suggestions from code review
plun1331 Oct 2, 2025
f3df97f
style(pre-commit): auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 2, 2025
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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
65 changes: 64 additions & 1 deletion discord/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -81,6 +82,7 @@
"Container",
"Label",
"SelectDefaultValue",
"FileUpload",
)

C = TypeVar("C", bound="Component")
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -1347,6 +1348,67 @@ 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`.

.. 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,
"id": self.id,
}
if self.custom_id:
payload["custom_id"] = self.custom_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,
Expand All @@ -1364,6 +1426,7 @@ def walk_components(self) -> Iterator[Component]:
14: Separator,
17: Container,
18: Label,
19: FileUpload,
}

STATE_COMPONENTS = (Section, Container, Thumbnail, MediaGallery, FileComponent)
Expand Down
2 changes: 2 additions & 0 deletions discord/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 12 additions & 2 deletions discord/types/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -166,7 +166,17 @@ class LabelComponent(BaseComponent):
component: SelectMenu | InputText


Component = Union[ActionRow, ButtonComponent, SelectMenu, InputText]
class FileUploadComponent(BaseComponent):
type: Literal[19]
custom_id: str
max_values: NotRequired[int]
max_values: NotRequired[int]
required: NotRequired[bool]


Component = Union[
ActionRow, ButtonComponent, SelectMenu, InputText, FileUploadComponent
]


AllowedContainerComponents = Union[
Expand Down
1 change: 1 addition & 0 deletions discord/types/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
1 change: 1 addition & 0 deletions discord/ui/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 *
Expand Down
193 changes: 193 additions & 0 deletions discord/ui/file_upload.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
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
----------
custom_id: Optional[:class:`str`]
The ID of the input text field that gets received during an interaction.
label: :class:`str`
The label for the file upload field.
Must be 45 characters or fewer.
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,
*,
custom_id: str | None = None,
label: str,
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 not isinstance(custom_id, str) and custom_id is not None:
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._interaction: Interaction | None = None
self._values: list[str] | 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."""
if self._interaction is None:
return None
Copy link
Contributor

@Soheab Soheab Sep 18, 2025

Choose a reason for hiding this comment

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

From a user's POV, wouldn't it be better for this to always be a list?

Also missing return type in doc.

Copy link
Member Author

Choose a reason for hiding this comment

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

I think it's better to represent it as None when the modal hasn't been interacted yet, since an empty list would imply that the modal has been interacted with, but nothing was uploaded to it

Copy link
Member

Choose a reason for hiding this comment

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

I think it's better to represent it as None

I agree

attachments = []
for attachment_id in self._values:
attachment_data = self._interaction.data["resolved"]["attachments"][
attachment_id
]
attachments.append(
Attachment(state=self._interaction._state, data=attachment_data)
)
return 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:
self._interaction = interaction
self._values = data.get("values", [])

@staticmethod
def uses_label() -> bool:
return True
2 changes: 1 addition & 1 deletion discord/ui/input_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading