diff --git a/discord/components.py b/discord/components.py index 08ae4f2773e6..06caf24f2f4a 100644 --- a/discord/components.py +++ b/discord/components.py @@ -72,6 +72,7 @@ ContainerComponent as ContainerComponentPayload, UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, + FileUploadComponent as FileUploadComponentPayload, ) from .emoji import Emoji @@ -112,6 +113,7 @@ 'TextDisplay', 'SeparatorComponent', 'LabelComponent', + 'FileUploadComponent', ) @@ -131,6 +133,8 @@ class Component: - :class:`FileComponent` - :class:`SeparatorComponent` - :class:`Container` + - :class:`LabelComponent` + - :class:`FileUploadComponent` This class is abstract and cannot be instantiated. @@ -1384,6 +1388,71 @@ def to_dict(self) -> LabelComponentPayload: return payload +class FileUploadComponent(Component): + """Represents a file upload component from the Discord Bot UI Kit. + + This inherits from :class:`Component`. + + .. note:: + + The user constructible and usable type for creating a file upload is + :class:`discord.ui.FileUpload` not this one. + + .. versionadded:: 2.7 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the component that gets received during an interaction. + min_values: :class:`int` + The minimum number of files that must be uploaded for this component. + Defaults to 1 and must be between 0 and 10. + max_values: :class:`int` + The maximum number of files that must be uploaded for this component. + Defaults to 1 and must be between 1 and 10. + id: Optional[:class:`int`] + The ID of this component. + required: :class:`bool` + Whether the component is required. + Defaults to ``True``. + """ + + __slots__: Tuple[str, ...] = ( + 'custom_id', + 'min_values', + 'max_values', + 'required', + 'id', + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: FileUploadComponentPayload, /) -> None: + 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) + self.id: Optional[int] = data.get('id') + + @property + def type(self) -> Literal[ComponentType.file_upload]: + """:class:`ComponentType`: The type of component.""" + return ComponentType.file_upload + + def to_dict(self) -> FileUploadComponentPayload: + payload: FileUploadComponentPayload = { + 'type': self.type.value, + 'custom_id': self.custom_id, + 'min_values': self.min_values, + 'max_values': self.max_values, + 'required': self.required, + } + if self.id is not None: + payload['id'] = self.id + + return payload + + def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] = None) -> Optional[Component]: if data['type'] == 1: return ActionRow(data) @@ -1409,3 +1478,5 @@ def _component_factory(data: ComponentPayload, state: Optional[ConnectionState] return Container(data, state) elif data['type'] == 18: return LabelComponent(data, state) + elif data['type'] == 19: + return FileUploadComponent(data) diff --git a/discord/enums.py b/discord/enums.py index 172f736a9adc..653236592942 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -681,6 +681,7 @@ class ComponentType(Enum): separator = 14 container = 17 label = 18 + file_upload = 19 def __int__(self) -> int: return self.value diff --git a/discord/state.py b/discord/state.py index 74922907da33..7ef3bbd15a36 100644 --- a/discord/state.py +++ b/discord/state.py @@ -828,7 +828,8 @@ def parse_interaction_create(self, data: gw.InteractionCreateEvent) -> None: inner_data = data['data'] custom_id = inner_data['custom_id'] components = inner_data['components'] - self._view_store.dispatch_modal(custom_id, interaction, components) + resolved = inner_data.get('resolved', {}) + self._view_store.dispatch_modal(custom_id, interaction, components, resolved) self.dispatch('interaction', interaction) def parse_presence_update(self, data: gw.PresenceUpdateEvent) -> None: diff --git a/discord/types/components.py b/discord/types/components.py index bb75a918f3a5..5522da38af42 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from .emoji import PartialEmoji from .channel import ChannelType -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] TextStyle = Literal[1, 2] DefaultValueType = Literal['user', 'role', 'channel'] @@ -192,7 +192,15 @@ class LabelComponent(ComponentBase): type: Literal[18] label: str description: NotRequired[str] - component: Union[StringSelectComponent, TextInput] + component: Union[SelectMenu, TextInput, FileUploadComponent] + + +class FileUploadComponent(ComponentBase): + type: Literal[19] + custom_id: str + max_values: NotRequired[int] + min_values: NotRequired[int] + required: NotRequired[bool] ActionRowChildComponent = Union[ButtonComponent, SelectMenu, TextInput] @@ -207,4 +215,4 @@ class LabelComponent(ComponentBase): SeparatorComponent, ThumbnailComponent, ] -Component = Union[ActionRowChildComponent, LabelComponent, ContainerChildComponent] +Component = Union[ActionRowChildComponent, LabelComponent, FileUploadComponent, ContainerChildComponent] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index f34166959754..6e6d9ef39a77 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -36,6 +36,7 @@ from .snowflake import Snowflake from .user import User from .guild import GuildFeature +from .components import ComponentBase if TYPE_CHECKING: from .message import Message @@ -204,19 +205,27 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData] -class ModalSubmitTextInputInteractionData(TypedDict): +class ModalSubmitTextInputInteractionData(ComponentBase): type: Literal[4] custom_id: str value: str -class ModalSubmitStringSelectInteractionData(TypedDict): - type: Literal[3] +class ModalSubmitSelectInteractionData(ComponentBase): + type: Literal[3, 5, 6, 7, 8] + custom_id: str + values: List[str] + + +class ModalSubmitFileUploadInteractionData(ComponentBase): + type: Literal[19] custom_id: str values: List[str] -ModalSubmitComponentItemInteractionData = Union[ModalSubmitTextInputInteractionData, ModalSubmitStringSelectInteractionData] +ModalSubmitComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData, ModalSubmitFileUploadInteractionData +] class ModalSubmitActionRowInteractionData(TypedDict): @@ -224,19 +233,27 @@ class ModalSubmitActionRowInteractionData(TypedDict): components: List[ModalSubmitComponentItemInteractionData] -class ModalSubmitLabelInteractionData(TypedDict): +class ModalSubmitTextDisplayInteractionData(ComponentBase): + type: Literal[10] + content: str + + +class ModalSubmitLabelInteractionData(ComponentBase): type: Literal[18] component: ModalSubmitComponentItemInteractionData ModalSubmitComponentInteractionData = Union[ - ModalSubmitLabelInteractionData, ModalSubmitActionRowInteractionData, ModalSubmitComponentItemInteractionData + ModalSubmitActionRowInteractionData, + ModalSubmitTextDisplayInteractionData, + ModalSubmitLabelInteractionData, ] class ModalSubmitInteractionData(TypedDict): custom_id: str components: List[ModalSubmitComponentInteractionData] + resolved: NotRequired[ResolvedData] InteractionData = Union[ diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 2ce3655edfba..061c1ef609f3 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -25,3 +25,4 @@ from .thumbnail import * from .action_row import * from .label import * +from .file_upload import * diff --git a/discord/ui/file_upload.py b/discord/ui/file_upload.py new file mode 100644 index 000000000000..a2b889a44803 --- /dev/null +++ b/discord/ui/file_upload.py @@ -0,0 +1,199 @@ +""" +The MIT License (MIT) + +Copyright (c) 2015-present Rapptz + +Permission is hereby granted, free of charge, to any person obtaining a +copy of this software and associated documentation files (the "Software"), +to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +""" + +from __future__ import annotations +from typing import TYPE_CHECKING, Any, List, Literal, Optional, Tuple, TypeVar, Dict + +import os + +from ..utils import MISSING +from ..components import FileUploadComponent +from ..enums import ComponentType +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..message import Attachment + from ..interactions import Interaction + from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitFileUploadInteractionDataPayload + from ..types.components import FileUploadComponent as FileUploadComponentPayload + from .view import BaseView + from ..app_commands.namespace import ResolveKey + + +# fmt: off +__all__ = ( + 'FileUpload', +) +# fmt: on + +V = TypeVar('V', bound='BaseView', covariant=True) + + +class FileUpload(Item[V]): + """Represents a file upload component within a modal. + + .. versionadded:: 2.7 + + Parameters + ------------ + id: Optional[:class:`int`] + The ID of the component. This must be unique across the view. + custom_id: Optional[:class:`str`] + The custom ID of the file upload component. + max_values: Optional[:class:`int`] + The maximum number of files that can be uploaded in this component. + Must be between 1 and 10. Defaults to 1. + min_values: Optional[:class:`int`] + The minimum number of files that must be uploaded in this component. + Must be between 0 and 10. Defaults to 0. + required: :class:`bool` + Whether this component is required to be filled before submitting the modal. + Defaults to ``True``. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'id', + 'custom_id', + 'max_values', + 'min_values', + 'required', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + required: bool = True, + min_values: Optional[int] = None, + max_values: Optional[int] = None, + id: Optional[int] = None, + ) -> None: + super().__init__() + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + if not isinstance(custom_id, str): + raise TypeError(f'expected custom_id to be str not {custom_id.__class__.__name__}') + + self._underlying: FileUploadComponent = FileUploadComponent._raw_construct( + id=id, + custom_id=custom_id, + max_values=max_values, + min_values=min_values, + required=required, + ) + self.id = id + self._values: List[Attachment] = [] + + @property + def id(self) -> Optional[int]: + """Optional[:class:`int`]: The ID of this component.""" + return self._underlying.id + + @id.setter + def id(self, value: Optional[int]) -> None: + self._underlying.id = value + + @property + def values(self) -> List[Attachment]: + """List[:class:`discord.Attachment`]: The list of attachments uploaded by the user. + + You can call :meth:`~discord.Attachment.to_file` on each attachment + to get a :class:`~discord.File` for sending. + """ + return self._values + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the component that gets received during an interaction.""" + return self._underlying.custom_id + + @custom_id.setter + def custom_id(self, value: str) -> None: + if not isinstance(value, str): + raise TypeError('custom_id must be a str') + + self._underlying.custom_id = value + self._provided_custom_id = True + + @property + def min_values(self) -> int: + """:class:`int`: The minimum number of files that must be user upload before submitting the modal.""" + return self._underlying.min_values + + @min_values.setter + def min_values(self, value: int) -> None: + self._underlying.min_values = int(value) + + @property + def max_values(self) -> int: + """:class:`int`: The maximum number of files that the user must upload before submitting the modal.""" + return self._underlying.max_values + + @max_values.setter + def max_values(self, value: int) -> None: + self._underlying.max_values = int(value) + + @property + def required(self) -> bool: + """:class:`bool`: Whether the component is required or not.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = bool(value) + + @property + def width(self) -> int: + return 5 + + def to_component_dict(self) -> FileUploadComponentPayload: + return self._underlying.to_dict() + + def _refresh_component(self, component: FileUploadComponent) -> None: + self._underlying = component + + def _handle_submit( + self, interaction: Interaction, data: ModalSubmitFileUploadInteractionDataPayload, resolved: Dict[ResolveKey, Any] + ) -> None: + self._values = [v for k, v in resolved.items() if k.id in data.get('values', [])] + + @classmethod + def from_component(cls, component: FileUploadComponent) -> Self: + self = cls( + id=component.id, + custom_id=component.custom_id, + max_values=component.max_values, + min_values=component.min_values, + required=component.required, + ) + return self + + @property + def type(self) -> Literal[ComponentType.file_upload]: + return self._underlying.type + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/item.py b/discord/ui/item.py index 5498dc20faca..8f716559c941 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -45,6 +45,7 @@ from .action_row import ActionRow from .container import Container from .dynamic import DynamicItem + from ..app_commands.namespace import ResolveKey I = TypeVar('I', bound='Item[Any]') V = TypeVar('V', bound='BaseView', covariant=True) @@ -97,6 +98,9 @@ def to_component_dict(self) -> Dict[str, Any]: def _refresh_component(self, component: Component) -> None: return None + def _handle_submit(self, interaction: Interaction, data: Dict[str, Any], resolved: Dict[ResolveKey, Any]) -> None: + return self._refresh_state(interaction, data) + def _refresh_state(self, interaction: Interaction, data: Dict[str, Any]) -> None: return None diff --git a/discord/ui/label.py b/discord/ui/label.py index 7a2d496a6071..cb93cd0d1be1 100644 --- a/discord/ui/label.py +++ b/discord/ui/label.py @@ -50,6 +50,8 @@ class Label(Item[V]): """Represents a UI label within a modal. + This is a top-level layout component that can only be used on :class:`Modal`. + .. versionadded:: 2.6 Parameters @@ -60,7 +62,7 @@ class Label(Item[V]): description: Optional[:class:`str`] The description text to display right below the label text. Can only be up to 100 characters. - component: Union[:class:`discord.ui.TextInput`, :class:`discord.ui.Select`] + component: :class:`Item` The component to display below the label. id: Optional[:class:`int`] The ID of the component. This must be unique across the view. @@ -74,8 +76,7 @@ class Label(Item[V]): The description text to display right below the label text. Can only be up to 100 characters. component: :class:`Item` - The component to display below the label. Currently only - supports :class:`TextInput` and :class:`Select`. + The component to display below the label. """ __item_repr_attributes__: Tuple[str, ...] = ( diff --git a/discord/ui/modal.py b/discord/ui/modal.py index 86c09da3086d..db8bf524138a 100644 --- a/discord/ui/modal.py +++ b/discord/ui/modal.py @@ -36,12 +36,17 @@ from .view import BaseView from .select import BaseSelect from .text_input import TextInput +from ..interactions import Namespace if TYPE_CHECKING: from typing_extensions import Self from ..interactions import Interaction - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) + from ..app_commands.namespace import ResolveKey # fmt: off @@ -168,23 +173,41 @@ async def on_error(self, interaction: Interaction[ClientT], error: Exception, /) """ _log.error('Ignoring exception in modal %r:', self, exc_info=error) - def _refresh(self, interaction: Interaction, components: Sequence[ModalSubmitComponentInteractionDataPayload]) -> None: + def _refresh( + self, + interaction: Interaction, + components: Sequence[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ) -> None: for component in components: if component['type'] == 1: - self._refresh(interaction, component['components']) + self._refresh(interaction, component['components'], resolved) # type: ignore elif component['type'] == 18: - self._refresh(interaction, [component['component']]) + self._refresh(interaction, [component['component']], resolved) # type: ignore else: - item = find(lambda i: getattr(i, 'custom_id', None) == component['custom_id'], self.walk_children()) # type: ignore + custom_id = component.get('custom_id') + if custom_id is None: + continue + + item = find( + lambda i: getattr(i, 'custom_id', None) == custom_id, + self.walk_children(), + ) if item is None: - _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', component['custom_id']) + _log.debug('Modal interaction referencing unknown item custom_id %s. Discarding', custom_id) continue - item._refresh_state(interaction, component) # type: ignore - async def _scheduled_task(self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]): + item._handle_submit(interaction, component, resolved) # type: ignore + + async def _scheduled_task( + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: Dict[ResolveKey, Any], + ): try: self._refresh_timeout() - self._refresh(interaction, components) + self._refresh(interaction, components, resolved) allow = await self.interaction_check(interaction) if not allow: @@ -221,10 +244,18 @@ def key(item: Item) -> int: return components def _dispatch_submit( - self, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload] + self, + interaction: Interaction, + components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> asyncio.Task[None]: + try: + namespace = Namespace._get_resolved_items(interaction, resolved) + except KeyError: + namespace = {} + return asyncio.create_task( - self._scheduled_task(interaction, components), name=f'discord-ui-modal-dispatch-{self.id}' + self._scheduled_task(interaction, components, namespace), name=f'discord-ui-modal-dispatch-{self.id}' ) def to_dict(self) -> Dict[str, Any]: diff --git a/discord/ui/select.py b/discord/ui/select.py index a181357b73bf..7668619c69ce 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -78,6 +78,7 @@ from ..types.interactions import SelectMessageComponentInteractionData from ..app_commands import AppCommandChannel, AppCommandThread from ..interactions import Interaction + from ..app_commands.namespace import ResolveKey ValidSelectType: TypeAlias = Literal[ ComponentType.string_select, @@ -356,7 +357,24 @@ def to_component_dict(self) -> SelectMenuPayload: def _refresh_component(self, component: SelectMenu) -> None: self._underlying = component - def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentInteractionData) -> None: + def _handle_submit( + self, interaction: Interaction, data: SelectMessageComponentInteractionData, resolved: Dict[ResolveKey, Any] + ) -> None: + payload: List[PossibleValue] + values = selected_values.get({}) + string_values = data.get('values', []) + payload = [v for k, v in resolved.items() if k.id in string_values] + if not payload: + payload = list(string_values) + + self._values = values[self.custom_id] = payload + selected_values.set(values) + + def _refresh_state( + self, + interaction: Interaction, + data: SelectMessageComponentInteractionData, + ) -> None: values = selected_values.get({}) payload: List[PossibleValue] try: @@ -366,7 +384,7 @@ def _refresh_state(self, interaction: Interaction, data: SelectMessageComponentI ) payload = list(resolved.values()) except KeyError: - payload = data.get('values', []) # type: ignore + payload = list(data.get('values', [])) self._values = values[self.custom_id] = payload selected_values.set(values) @@ -580,6 +598,10 @@ class UserSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -611,6 +633,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -622,6 +645,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -682,6 +706,10 @@ class RoleSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the roles that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -713,6 +741,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -724,6 +753,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -779,6 +809,10 @@ class MentionableSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the users/roles that should be selected by default. if :class:`.Object` is passed, then the type must be specified in the constructor. @@ -811,6 +845,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -822,6 +857,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, default_values=_handle_select_defaults(default_values, self.type), id=id, @@ -884,6 +920,10 @@ class ChannelSelect(BaseSelect[V]): Defaults to 1 and must be between 1 and 25. disabled: :class:`bool` Whether the select is disabled or not. + required: :class:`bool` + Whether the select is required. Only applicable within modals. + + .. versionadded:: 2.6 default_values: Sequence[:class:`~discord.abc.Snowflake`] A list of objects representing the channels that should be selected by default. Number of items must be in range of ``min_values`` and ``max_values``. @@ -919,6 +959,7 @@ def __init__( min_values: int = 1, max_values: int = 1, disabled: bool = False, + required: bool = False, row: Optional[int] = None, default_values: Sequence[ValidDefaultValues] = MISSING, id: Optional[int] = None, @@ -930,6 +971,7 @@ def __init__( min_values=min_values, max_values=max_values, disabled=disabled, + required=required, row=row, channel_types=channel_types, default_values=_handle_select_defaults(default_values, self.type), diff --git a/discord/ui/text_display.py b/discord/ui/text_display.py index b6f908748c55..4abff1a1854a 100644 --- a/discord/ui/text_display.py +++ b/discord/ui/text_display.py @@ -43,7 +43,8 @@ class TextDisplay(Item[V]): """Represents a UI text display. - This is a top-level layout component that can only be used on :class:`LayoutView` or :class:`Section`. + This is a top-level layout component that can only be used on :class:`LayoutView`, + :class:`Section`, :class:`Container`, or :class:`Modal`. .. versionadded:: 2.6 diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py index de0c8e079364..0647b29cf5da 100644 --- a/discord/ui/text_input.py +++ b/discord/ui/text_input.py @@ -53,6 +53,8 @@ class TextInput(Item[V]): """Represents a UI text input. + This a top-level layout component that can only be used in :class:`Label`. + .. container:: operations .. describe:: str(x) diff --git a/discord/ui/view.py b/discord/ui/view.py index 9c7547e60760..252a21dbb981 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -85,7 +85,10 @@ from ..interactions import Interaction from ..message import Message from ..types.components import ComponentBase as ComponentBasePayload - from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + from ..types.interactions import ( + ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload, + ResolvedData as ResolvedDataPayload, + ) from ..state import ConnectionState from .modal import Modal @@ -1041,13 +1044,14 @@ def dispatch_modal( custom_id: str, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload], + resolved: ResolvedDataPayload, ) -> None: modal = self._modals.get(custom_id) if modal is None: _log.debug('Modal interaction referencing unknown custom_id %s. Discarding', custom_id) return - self.add_task(modal._dispatch_submit(interaction, components)) + self.add_task(modal._dispatch_submit(interaction, components, resolved)) def remove_interaction_mapping(self, interaction_id: int) -> None: # This is called before re-adding the view diff --git a/docs/interactions/api.rst b/docs/interactions/api.rst index b2098128bd60..107e4e2e4233 100644 --- a/docs/interactions/api.rst +++ b/docs/interactions/api.rst @@ -193,6 +193,16 @@ Container :inherited-members: +FileUploadComponent +~~~~~~~~~~~~~~~~~~~~ + +.. attributetable:: FileUploadComponent + +.. autoclass:: FileUploadComponent() + :members: + :inherited-members: + + AppCommand ~~~~~~~~~~~ @@ -479,6 +489,12 @@ Enumerations .. versionadded:: 2.6 + .. attribute:: file_upload + + Represents a file upload component, usually in a modal. + + .. versionadded:: 2.7 + .. class:: ButtonStyle Represents the style of the button component. @@ -855,6 +871,17 @@ ActionRow :inherited-members: :exclude-members: callback + +FileUpload +~~~~~~~~~~~ + +.. attributetable:: discord.ui.FileUpload + +.. autoclass:: discord.ui.FileUpload + :members: + :inherited-members: + :exclude-members: callback, interaction_check + .. _discord_app_commands: Application Commands diff --git a/examples/modals/report.py b/examples/modals/report.py new file mode 100644 index 000000000000..9e027a8c15d8 --- /dev/null +++ b/examples/modals/report.py @@ -0,0 +1,143 @@ +import discord +from discord import app_commands + +import traceback + +# The guild in which this slash command will be registered. +# It is recommended to have a test guild to separate from your "production" bot +TEST_GUILD = discord.Object(0) +# The ID of the channel where reports will be sent to +REPORTS_CHANNEL_ID = 0 + + +class MyClient(discord.Client): + # Suppress error on the User attribute being None since it fills up later + user: discord.ClientUser + + def __init__(self) -> None: + # Just default intents and a `discord.Client` instance + # We don't need a `commands.Bot` instance because we are not + # creating text-based commands. + intents = discord.Intents.default() + super().__init__(intents=intents) + + # We need an `discord.app_commands.CommandTree` instance + # to register application commands (slash commands in this case) + self.tree = app_commands.CommandTree(self) + + async def on_ready(self): + print(f'Logged in as {self.user} (ID: {self.user.id})') + print('------') + + async def setup_hook(self) -> None: + await self.tree.sync(guild=TEST_GUILD) + + +# Define a modal dialog for reporting issues or feedback +class ReportModal(discord.ui.Modal, title='Your Report'): + topic = discord.ui.Label( + text='Topic', + description='Select the topic of the report.', + component=discord.ui.Select( + placeholder='Choose a topic...', + options=[ + discord.SelectOption(label='Bug', description='Report a bug in the bot'), + discord.SelectOption(label='Feedback', description='Provide feedback or suggestions'), + discord.SelectOption(label='Feature Request', description='Request a new feature'), + discord.SelectOption(label='Performance', description='Report performance issues'), + discord.SelectOption(label='UI/UX', description='Report user interface or experience issues'), + discord.SelectOption(label='Security', description='Report security vulnerabilities'), + discord.SelectOption(label='Other', description='Other types of reports'), + ], + ), + ) + report_title = discord.ui.Label( + text='Title', + description='A short title for the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.short, + placeholder='The bot does not respond to commands', + max_length=120, + ), + ) + description = discord.ui.Label( + text='Description', + description='A detailed description of the report.', + component=discord.ui.TextInput( + style=discord.TextStyle.paragraph, + placeholder='When I use /ping, the bot does not respond at all. There are no error messages.', + max_length=2000, + ), + ) + images = discord.ui.Label( + text='Images', + description='Upload any relevant images for your report (optional).', + component=discord.ui.FileUpload( + max_values=10, + custom_id='report_images', + required=False, + ), + ) + footer = discord.ui.TextDisplay( + 'Please ensure your report follows the server rules. Any kind of abuse will result in a ban.' + ) + + def to_view(self, interaction: discord.Interaction) -> discord.ui.LayoutView: + # Tell the type checker what our components are... + assert isinstance(self.topic.component, discord.ui.Select) + assert isinstance(self.description.component, discord.ui.TextInput) + assert isinstance(self.report_title.component, discord.ui.TextInput) + assert isinstance(self.images.component, discord.ui.FileUpload) + + topic = self.topic.component.values[0] + title = self.report_title.component.value + description = self.description.component.value + files = self.images.component.values + + view = discord.ui.LayoutView() + container = discord.ui.Container() + view.add_item(container) + + container.add_item(discord.ui.TextDisplay(f'-# User Report\n## {topic}')) + + timestamp = discord.utils.format_dt(interaction.created_at, 'F') + footer = discord.ui.TextDisplay(f'-# Reported by {interaction.user} (ID: {interaction.user.id}) | {timestamp}') + + container.add_item(discord.ui.TextDisplay(f'### {title}')) + container.add_item(discord.ui.TextDisplay(f'>>> {description}')) + + if files: + gallery = discord.ui.MediaGallery() + gallery.items = [discord.MediaGalleryItem(media=attachment.url) for attachment in files] + container.add_item(gallery) + + container.add_item(footer) + return view + + async def on_submit(self, interaction: discord.Interaction[MyClient]): + view = self.to_view(interaction) + + # Send the report to the designated channel + reports_channel = interaction.client.get_partial_messageable(REPORTS_CHANNEL_ID) + await reports_channel.send(view=view) + await interaction.response.send_message('Thank you for your report! We will look into it shortly.', ephemeral=True) + + async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: + await interaction.response.send_message('Oops! Something went wrong.', ephemeral=True) + + # Make sure we know what the error actually is + traceback.print_exception(type(error), error, error.__traceback__) + + +client = MyClient() + + +@client.tree.command(guild=TEST_GUILD, description='Report an issue or provide feedback.') +async def report(interaction: discord.Interaction): + # Send the modal with an instance of our `ReportModal` class + # Since modals require an interaction, they cannot be done as a response to a text command. + # They can only be done as a response to either an application command or a button press. + await interaction.response.send_modal(ReportModal()) + + +client.run('token')