""" The MIT License (MIT) Copyright (c) 2021-present Disnake Development 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 import asyncio import os import sys import traceback from typing import TYPE_CHECKING, Dict, List, Optional, Tuple, Union from ..enums import TextInputStyle from ..utils import MISSING from .action_row import ActionRow, components_to_rows from .text_input import TextInput if TYPE_CHECKING: from ..interactions.modal import ModalInteraction from ..state import ConnectionState from ..types.components import Modal as ModalPayload from .action_row import Components __all__ = ("Modal",) class Modal: """Represents a UI Modal. .. versionadded:: 2.4 Parameters ---------- title: :class:`str` The title of the modal. components: |components_type| The components to display in the modal. Up to 5 action rows. custom_id: :class:`str` The custom ID of the modal. timeout: :class:`float` The time to wait until the modal is removed from cache, if no interaction is made. Modals without timeouts are not supported, since there's no event for when a modal is closed. Defaults to 600 seconds. """ __slots__ = ("title", "custom_id", "components", "timeout") def __init__( self, *, title: str, components: Components, custom_id: str = MISSING, timeout: float = 600, ) -> None: if timeout is None: raise ValueError("Timeout may not be None") rows = components_to_rows(components) if len(rows) > 5: raise ValueError("Maximum number of components exceeded.") self.title: str = title self.custom_id: str = os.urandom(16).hex() if custom_id is MISSING else custom_id self.components: List[ActionRow] = rows self.timeout: float = timeout def __repr__(self) -> str: return ( f"" ) def append_component(self, component: Union[TextInput, List[TextInput]]) -> None: """Adds one or multiple component(s) to the modal. Parameters ---------- component: Union[:class:`~.ui.TextInput`, List[:class:`~.ui.TextInput`]] The component(s) to add to the modal. This can be a single component or a list of components. Raises ------ ValueError Maximum number of components (5) exceeded. TypeError An object of type :class:`TextInput` was not passed. """ if len(self.components) >= 5: raise ValueError("Maximum number of components exceeded.") if not isinstance(component, list): component = [component] for c in component: if not isinstance(c, TextInput): raise TypeError( f"component must be of type 'TextInput' or a list of 'TextInput' objects, not {type(c).__name__}." ) try: self.components[-1].append_item(c) except (ValueError, IndexError): self.components.append(ActionRow(c)) def add_text_input( self, *, label: str, custom_id: str, style: TextInputStyle = TextInputStyle.short, placeholder: Optional[str] = None, value: Optional[str] = None, required: bool = True, min_length: Optional[int] = None, max_length: Optional[int] = None, ) -> None: """Creates and adds a text input component to the modal. To append a pre-existing instance of :class:`~disnake.ui.TextInput` use the :meth:`append_component` method. Parameters ---------- label: :class:`str` The label of the text input. custom_id: :class:`str` The ID of the text input that gets received during an interaction. style: :class:`.TextInputStyle` The style of the text input. placeholder: Optional[:class:`str`] The placeholder text that is shown if nothing is entered. value: Optional[:class:`str`] The pre-filled value of the text input. required: :class:`bool` Whether the text input is required. Defaults to ``True``. min_length: Optional[:class:`int`] The minimum length of the text input. max_length: Optional[:class:`int`] The maximum length of the text input. Raises ------ ValueError Maximum number of components (5) exceeded. """ self.append_component( TextInput( label=label, custom_id=custom_id, style=style, placeholder=placeholder, value=value, required=required, min_length=min_length, max_length=max_length, ) ) async def callback(self, interaction: ModalInteraction, /) -> None: """|coro| The callback associated with this modal. This can be overriden by subclasses. Parameters ---------- interaction: :class:`.ModalInteraction` The interaction that triggered this modal. """ pass async def on_error(self, error: Exception, interaction: ModalInteraction) -> None: """|coro| A callback that is called when an error occurs. The default implementation prints the traceback to stderr. Parameters ---------- error: :class:`Exception` The exception that was raised. interaction: :class:`.ModalInteraction` The interaction that triggered this modal. """ traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) async def on_timeout(self) -> None: """|coro| A callback that is called when the modal is removed from the cache without an interaction being made. """ pass def to_components(self) -> ModalPayload: payload: ModalPayload = { "title": self.title, "custom_id": self.custom_id, "components": [component.to_component_dict() for component in self.components], } return payload async def _scheduled_task(self, interaction: ModalInteraction) -> None: try: await self.callback(interaction) except Exception as e: await self.on_error(e, interaction) finally: # if the interaction was responded to (no matter if in the callback or error handler), # the modal closed for the user and therefore can be removed from the store if interaction.response._responded: interaction._state._modal_store.remove_modal( interaction.author.id, interaction.custom_id ) def dispatch(self, interaction: ModalInteraction) -> None: asyncio.create_task( self._scheduled_task(interaction), name=f"disnake-ui-modal-dispatch-{self.custom_id}" ) class ModalStore: def __init__(self, state: ConnectionState) -> None: self._state = state # (user_id, Modal.custom_id): Modal self._modals: Dict[Tuple[int, str], Modal] = {} def add_modal(self, user_id: int, modal: Modal) -> None: loop = asyncio.get_event_loop() self._modals[(user_id, modal.custom_id)] = modal loop.create_task(self.handle_timeout(user_id, modal.custom_id, modal.timeout)) def remove_modal(self, user_id: int, modal_custom_id: str) -> Modal: return self._modals.pop((user_id, modal_custom_id)) async def handle_timeout(self, user_id: int, modal_custom_id: str, timeout: float) -> None: # Waits for the timeout and then removes the modal from cache, this is done just in case # the user closed the modal, as there isn't an event for that. await asyncio.sleep(timeout) try: modal = self.remove_modal(user_id, modal_custom_id) except KeyError: # The modal has already been removed. pass else: await modal.on_timeout() def dispatch(self, interaction: ModalInteraction) -> None: key = (interaction.author.id, interaction.custom_id) modal = self._modals.get(key) if modal is not None: modal.dispatch(interaction)