diff --git a/discord/components.py b/discord/components.py index 08ae4f277..c6b5974de 100644 --- a/discord/components.py +++ b/discord/components.py @@ -72,6 +72,7 @@ if TYPE_CHECKING: ContainerComponent as ContainerComponentPayload, UnfurledMediaItem as UnfurledMediaItemPayload, LabelComponent as LabelComponentPayload, + FileUploadComponent as FileUploadComponentPayload, ) from .emoji import Emoji @@ -112,6 +113,7 @@ __all__ = ( 'TextDisplay', 'SeparatorComponent', 'LabelComponent', + 'FileUploadComponent', ) @@ -131,6 +133,7 @@ class Component: - :class:`FileComponent` - :class:`SeparatorComponent` - :class:`Container` + - :class:`FileUploadComponent` This class is abstract and cannot be instantiated. @@ -1384,6 +1387,71 @@ class LabelComponent(Component): 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 +1477,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 172f736a9..653236592 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/types/components.py b/discord/types/components.py index bb75a918f..5522da38a 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -30,7 +30,7 @@ from typing_extensions import NotRequired 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 @@ ContainerChildComponent = Union[ 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 dfdb9a0dd..6e6d9ef39 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -217,7 +217,15 @@ class ModalSubmitSelectInteractionData(ComponentBase): values: List[str] -ModalSubmitComponentItemInteractionData = Union[ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData] +class ModalSubmitFileUploadInteractionData(ComponentBase): + type: Literal[19] + custom_id: str + values: List[str] + + +ModalSubmitComponentItemInteractionData = Union[ + ModalSubmitSelectInteractionData, ModalSubmitTextInputInteractionData, ModalSubmitFileUploadInteractionData +] class ModalSubmitActionRowInteractionData(TypedDict): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 2ce3655ed..061c1ef60 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -25,3 +25,4 @@ from .text_display import * 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 000000000..a2b889a44 --- /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/docs/interactions/api.rst b/docs/interactions/api.rst index b2098128b..107e4e2e4 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 000000000..9e027a8c1 --- /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')