From 19c6687b55d8944948fdcd03bc713ba7464f7982 Mon Sep 17 00:00:00 2001 From: Josh Date: Sun, 20 Feb 2022 19:57:44 +1000 Subject: [PATCH] Add support for Modal Interactions --- discord/components.py | 82 +++++++++++- discord/enums.py | 15 +++ discord/interactions.py | 36 ++++++ discord/state.py | 7 +- discord/types/components.py | 18 ++- discord/types/interactions.py | 4 +- discord/ui/__init__.py | 2 + discord/ui/item.py | 2 +- discord/ui/modal.py | 196 +++++++++++++++++++++++++++++ discord/ui/select.py | 5 +- discord/ui/text_input.py | 231 ++++++++++++++++++++++++++++++++++ discord/ui/view.py | 44 +++++-- docs/api.rst | 54 ++++++++ 13 files changed, 679 insertions(+), 17 deletions(-) create mode 100644 discord/ui/modal.py create mode 100644 discord/ui/text_input.py diff --git a/discord/components.py b/discord/components.py index 74c7be3d0..c6bd828f7 100644 --- a/discord/components.py +++ b/discord/components.py @@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE. from __future__ import annotations from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union -from .enums import try_enum, ComponentType, ButtonStyle +from .enums import try_enum, ComponentType, ButtonStyle, TextStyle from .utils import get_slots, MISSING from .partial_emoji import PartialEmoji, _EmojiTag @@ -36,6 +36,7 @@ if TYPE_CHECKING: SelectMenu as SelectMenuPayload, SelectOption as SelectOptionPayload, ActionRow as ActionRowPayload, + TextInput as TextInputPayload, ) from .emoji import Emoji @@ -370,6 +371,83 @@ class SelectOption: return payload +class TextInput(Component): + """Represents a text input from the Discord Bot UI Kit. + + .. note:: + The user constructible and usable type to create a text input is + :class:`discord.ui.TextInput` not this one. + + .. versionadded:: 2.0 + + Attributes + ------------ + custom_id: Optional[:class:`str`] + The ID of the text input that gets received during an interaction. + label: :class:`str` + The label to display above the text input. + style: :class:`TextStyle` + The style of the text input. + placeholder: Optional[:class:`str`] + The placeholder text to display when the text input is empty. + default_value: Optional[:class:`str`] + The default value of the text input. + required: :class:`bool` + Whether the text input is required. + min_length: Optional[:class:`int`] + The minimum length of the text input. + max_length: Optional[:class:`int`] + The maximum length of the text input. + """ + + __slots__: Tuple[str, ...] = ( + 'style', + 'label', + 'custom_id', + 'placeholder', + 'default_value', + 'required', + 'min_length', + 'max_length', + ) + + __repr_info__: ClassVar[Tuple[str, ...]] = __slots__ + + def __init__(self, data: TextInputPayload) -> None: + self.type: ComponentType = ComponentType.text_input + self.style: TextStyle = try_enum(TextStyle, data['style']) + self.label: str = data['label'] + self.custom_id: str = data['custom_id'] + self.placeholder: Optional[str] = data.get('placeholder') + self.default_value: Optional[str] = data.get('value') + self.required: bool = data.get('required', True) + self.min_length: Optional[int] = data.get('min_length') + self.max_length: Optional[int] = data.get('max_length') + + def to_dict(self) -> TextInputPayload: + payload: TextInputPayload = { + 'type': self.type.value, + 'style': self.style.value, + 'label': self.label, + 'custom_id': self.custom_id, + 'required': self.required, + } + + if self.placeholder: + payload['placeholder'] = self.placeholder + + if self.default_value: + payload['value'] = self.default_value + + if self.min_length: + payload['min_length'] = self.min_length + + if self.max_length: + payload['max_length'] = self.max_length + + return payload + + def _component_factory(data: ComponentPayload) -> Component: component_type = data['type'] if component_type == 1: @@ -378,6 +456,8 @@ def _component_factory(data: ComponentPayload) -> Component: return Button(data) # type: ignore elif component_type == 3: return SelectMenu(data) # type: ignore + elif component_type == 4: + return TextInput(data) # type: ignore else: as_enum = try_enum(ComponentType, component_type) return Component._raw_construct(type=as_enum) diff --git a/discord/enums.py b/discord/enums.py index 1ee1b2f24..103e05a20 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -51,6 +51,7 @@ __all__ = ( 'VideoQualityMode', 'ComponentType', 'ButtonStyle', + 'TextStyle', 'StagePrivacyLevel', 'InteractionType', 'InteractionResponseType', @@ -530,6 +531,7 @@ class InteractionType(Enum): ping = 1 application_command = 2 component = 3 + modal_submit = 5 class InteractionResponseType(Enum): @@ -540,6 +542,7 @@ class InteractionResponseType(Enum): deferred_channel_message = 5 # (with source) deferred_message_update = 6 # for components message_update = 7 # for components + modal = 9 # for modals class VideoQualityMode(Enum): @@ -554,6 +557,7 @@ class ComponentType(Enum): action_row = 1 button = 2 select = 3 + text_input = 4 def __int__(self): return self.value @@ -578,6 +582,17 @@ class ButtonStyle(Enum): return self.value +class TextStyle(Enum): + short = 1 + paragraph = 2 + + # Aliases + long = 2 + + def __int__(self) -> int: + return self.value + + class StagePrivacyLevel(Enum): public = 1 closed = 2 diff --git a/discord/interactions.py b/discord/interactions.py index d54756f93..db8295436 100644 --- a/discord/interactions.py +++ b/discord/interactions.py @@ -60,6 +60,7 @@ if TYPE_CHECKING: from aiohttp import ClientSession from .embeds import Embed from .ui.view import View + from .ui.modal import Modal from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable from .threads import Thread @@ -628,6 +629,41 @@ class InteractionResponse: self._responded = True + async def send_modal(self, modal: Modal, /): + """|coro| + + Responds to this interaction by sending a modal. + + Parameters + ----------- + modal: :class:`~discord.ui.Modal` + The modal to send. + + Raises + ------- + HTTPException + Sending the modal failed. + InteractionResponded + This interaction has already been responded to before. + """ + if self._responded: + raise InteractionResponded(self._parent) + + parent = self._parent + + adapter = async_context.get() + + params = interaction_response_params(InteractionResponseType.modal.value, modal.to_dict()) + await adapter.create_interaction_response( + parent.id, + parent.token, + session=parent._session, + params=params, + ) + + self._parent._state.store_view(modal) + self._responded = True + class _InteractionMessageState: __slots__ = ('_parent', '_interaction') diff --git a/discord/state.py b/discord/state.py index 85f751be3..c939c7e8d 100644 --- a/discord/state.py +++ b/discord/state.py @@ -688,8 +688,11 @@ class ConnectionState: if data['type'] == 3: # interaction component custom_id = interaction.data['custom_id'] # type: ignore component_type = interaction.data['component_type'] # type: ignore - self._view_store.dispatch(component_type, custom_id, interaction) - + self._view_store.dispatch_view(component_type, custom_id, interaction) + elif data['type'] == 5: # modal submit + custom_id = interaction.data['custom_id'] # type: ignore + components = interaction.data['components'] # type: ignore + self._view_store.dispatch_modal(custom_id, interaction, components) # type: ignore self.dispatch('interaction', interaction) def parse_presence_update(self, data) -> None: diff --git a/discord/types/components.py b/discord/types/components.py index 3d689e8e3..e853df318 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -29,6 +29,7 @@ from .emoji import PartialEmoji ComponentType = Literal[1, 2, 3] ButtonStyle = Literal[1, 2, 3, 4, 5] +TextStyle = Literal[1, 2] class ActionRow(TypedDict): @@ -73,4 +74,19 @@ class SelectMenu(_SelectMenuOptional): options: List[SelectOption] -Component = Union[ActionRow, ButtonComponent, SelectMenu] +class _TextInputOptional(TypedDict, total=False): + placeholder: str + value: str + required: bool + min_length: int + max_length: int + + +class TextInput(_TextInputOptional): + type: Literal[4] + custom_id: str + style: TextStyle + label: str + + +Component = Union[ActionRow, ButtonComponent, SelectMenu, TextInput] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 672c4940c..b4d033ea1 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -163,13 +163,13 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData] -class ModalSubmitInputTextInteractionData(TypedDict): +class ModalSubmitTextInputInteractionData(TypedDict): type: Literal[4] custom_id: str value: str -ModalSubmitComponentItemInteractionData = ModalSubmitInputTextInteractionData +ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData class ModalSubmitActionRowInteractionData(TypedDict): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 9f5a22811..0133be6f5 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -10,6 +10,8 @@ Bot UI Kit helper for the Discord API """ from .view import * +from .modal import * from .item import * from .button import * from .select import * +from .text_input import * diff --git a/discord/ui/item.py b/discord/ui/item.py index 46c529707..317327e58 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -73,7 +73,7 @@ class Item(Generic[V]): def refresh_component(self, component: Component) -> None: return None - def refresh_state(self, interaction: Interaction) -> None: + def refresh_state(self, data: Dict[str, Any]) -> None: return None @classmethod diff --git a/discord/ui/modal.py b/discord/ui/modal.py new file mode 100644 index 000000000..2386a0c23 --- /dev/null +++ b/discord/ui/modal.py @@ -0,0 +1,196 @@ +""" +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 + +import asyncio +import logging +import os +import sys +import time +import traceback +from copy import deepcopy +from typing import TYPE_CHECKING, Any, Dict, Optional, Sequence, ClassVar, List + +from ..utils import MISSING, find +from .item import Item +from .view import View + +if TYPE_CHECKING: + from ..interactions import Interaction + from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload + + +__all__ = ( + 'Modal', +) + + +_log = logging.getLogger(__name__) + + +class Modal(View): + """Represents a UI modal. + + This object must be inherited to create a modal popup window within discord. + + .. versionadded:: 2.0 + + Parameters + ----------- + title: :class:`str` + The title of the modal. + timeout: Optional[:class:`float`] + Timeout in seconds from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + custom_id: :class:`str` + The ID of the modal that gets received during an interaction. + If not given then one is generated for you. + + Attributes + ------------ + timeout: Optional[:class:`float`] + Timeout from last interaction with the UI before no longer accepting input. + If ``None`` then there is no timeout. + title: :class:`str` + The title of the modal. + children: List[:class:`Item`] + The list of children attached to this view. + custom_id: :class:`str` + The ID of the modal that gets received during an interaction. + """ + + if TYPE_CHECKING: + title: str + + __discord_ui_modal__ = True + __modal_children_items__: ClassVar[Dict[str, Item]] = {} + + def __init_subclass__(cls, *, title: str = MISSING) -> None: + if title is not MISSING: + cls.title = title + + children = {} + for base in reversed(cls.__mro__): + for name, member in base.__dict__.items(): + if isinstance(member, Item): + children[name] = member + + cls.__modal_children_items__ = children + + def _init_children(self) -> List[Item]: + children = [] + for name, item in self.__modal_children_items__.items(): + item = deepcopy(item) + setattr(self, name, item) + item._view = self + children.append(item) + return children + + def __init__( + self, + *, + title: str = MISSING, + timeout: Optional[float] = None, + custom_id: str = MISSING, + ) -> None: + if title is MISSING and getattr(self, 'title', MISSING) is MISSING: + raise ValueError('Modal must have a title') + elif title is not MISSING: + self.title = title + self.custom_id: str = os.urandom(16).hex() if custom_id is MISSING else custom_id + + super().__init__(timeout=timeout) + + async def on_submit(self, interaction: Interaction): + """|coro| + + Called when the modal is submitted. + + Parameters + ----------- + interaction: :class:`.Interaction` + The interaction that submitted this modal. + """ + pass + + async def on_error(self, error: Exception, interaction: Interaction) -> None: + """|coro| + + A callback that is called when :meth:`on_submit` + fails with an error. + + The default implementation prints the traceback to stderr. + + Parameters + ----------- + error: :class:`Exception` + The exception that was raised. + interaction: :class:`~discord.Interaction` + The interaction that led to the failure. + """ + print(f'Ignoring exception in modal {self}:', file=sys.stderr) + traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) + + def refresh(self, components: Sequence[ModalSubmitComponentInteractionDataPayload]): + for component in components: + if component['type'] == 1: + self.refresh(component['components']) + else: + item = find(lambda i: i.custom_id == component['custom_id'], self.children) # type: ignore + if item is None: + _log.debug("Modal interaction referencing unknown item custom_id %s. Discarding", component['custom_id']) + continue + item.refresh_state(component) # type: ignore + + async def _scheduled_task(self, interaction: Interaction): + try: + if self.timeout: + self.__timeout_expiry = time.monotonic() + self.timeout + + allow = await self.interaction_check(interaction) + if not allow: + return + + await self.on_submit(interaction) + if not interaction.response._responded: + await interaction.response.defer() + except Exception as e: + return await self.on_error(e, interaction) + else: + # No error, so assume this will always happen + # In the future, maybe this will require checking if we set an error response. + self.stop() + + def _dispatch_submit(self, interaction: Interaction) -> None: + asyncio.create_task(self._scheduled_task(interaction), name=f'discord-ui-modal-dispatch-{self.id}') + + def to_dict(self) -> Dict[str, Any]: + payload = { + 'custom_id': self.custom_id, + 'title': self.title, + 'components': self.to_components(), + } + + return payload diff --git a/discord/ui/select.py b/discord/ui/select.py index 8479ca157..3fe57c734 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -47,7 +47,7 @@ if TYPE_CHECKING: from .view import View from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ( - ComponentInteractionData, + MessageComponentInteractionData, ) S = TypeVar('S', bound='Select') @@ -270,8 +270,7 @@ class Select(Item[V]): def refresh_component(self, component: SelectMenu) -> None: self._underlying = component - def refresh_state(self, interaction: Interaction) -> None: - data: ComponentInteractionData = interaction.data # type: ignore + def refresh_state(self, data: MessageComponentInteractionData) -> None: self._selected_values = data.get('values', []) @classmethod diff --git a/discord/ui/text_input.py b/discord/ui/text_input.py new file mode 100644 index 000000000..43fdbbe77 --- /dev/null +++ b/discord/ui/text_input.py @@ -0,0 +1,231 @@ +""" +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 + +import os +from typing import TYPE_CHECKING, Optional, Tuple, TypeVar + +from ..components import TextInput as TextInputComponent +from ..enums import ComponentType, TextStyle +from ..utils import MISSING +from .item import Item + +if TYPE_CHECKING: + from typing_extensions import Self + + from ..types.components import TextInput as TextInputPayload + from ..types.interactions import ModalSubmitTextInputInteractionData as ModalSubmitTextInputInteractionDataPayload + from .view import View + + +__all__ = ( + 'TextInput', +) + + +V = TypeVar('V', bound='View', covariant=True) + + +class TextInput(Item[V]): + """Represents a UI text input. + + .. versionadded:: 2.0 + + Parameters + ------------ + label: :class:`str` + The label to display above the text input. + custom_id: :class:`str` + The ID of the text input that gets recieved during an interaction. + If not given then one is generated for you. + style: :class:`discord.TextStyle` + The style of the text input. + placeholder: Optional[:class:`str`] + The placeholder text to display when the text input is empty. + default_value: Optional[:class:`str`] + The default value of the text input. + required: :class:`bool` + Whether the text input is required. + min_length: Optional[:class:`int`] + The minimum length of the text input. + max_length: Optional[:class:`int`] + The maximum length of the text input. + row: Optional[:class:`int`] + The relative row this text input belongs to. A Discord component 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', + 'placeholder', + 'required', + ) + + def __init__( + self, + *, + label: str, + style: TextStyle = TextStyle.short, + custom_id: str = MISSING, + placeholder: Optional[str] = None, + default_value: Optional[str] = None, + required: bool = True, + min_length: Optional[int] = None, + max_length: Optional[int] = None, + row: Optional[int] = None, + ) -> None: + super().__init__() + self._value: Optional[str] = default_value + self._provided_custom_id = custom_id is not MISSING + custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id + self._underlying = TextInputComponent._raw_construct( + type=ComponentType.text_input, + label=label, + style=style, + custom_id=custom_id, + placeholder=placeholder, + default_value=default_value, + required=required, + min_length=min_length, + max_length=max_length, + ) + self.row: Optional[int] = row + + @property + def custom_id(self) -> str: + """:class:`str`: The ID of the select menu 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 None or str') + + self._underlying.custom_id = value + + @property + def width(self) -> int: + return 5 + + @property + def value(self) -> Optional[str]: + """Optional[:class:`str`]: The value of the text input.""" + return self._value + + @property + def label(self) -> str: + """:class:`str`: The label of the text input.""" + return self._underlying.label + + @label.setter + def label(self, value: str) -> None: + self._underlying.label = value + + @property + def placeholder(self) -> Optional[str]: + """:class:`str`: The placeholder text to display when the text input is empty.""" + return self._underlying.placeholder + + @placeholder.setter + def placeholder(self, value: Optional[str]) -> None: + self._underlying.placeholder = value + + @property + def required(self) -> bool: + """:class:`bool`: Whether the text input is required.""" + return self._underlying.required + + @required.setter + def required(self, value: bool) -> None: + self._underlying.required = value + + @property + def min_length(self) -> Optional[int]: + """:class:`int`: The minimum length of the text input.""" + return self._underlying.min_length + + @min_length.setter + def min_length(self, value: Optional[int]) -> None: + self._underlying.min_length = value + + @property + def max_length(self) -> Optional[int]: + """:class:`int`: The maximum length of the text input.""" + return self._underlying.max_length + + @max_length.setter + def max_length(self, value: Optional[int]) -> None: + self._underlying.max_length = value + + @property + def style(self) -> TextStyle: + """:class:`discord.TextStyle`: The style of the text input.""" + return self._underlying.style + + @style.setter + def style(self, value: TextStyle) -> None: + self._underlying.style = value + + @property + def default_value(self) -> Optional[str]: + """:class:`str`: The default value of the text input.""" + return self._underlying.default_value + + @default_value.setter + def default_value(self, value: Optional[str]) -> None: + self._underlying.default_value = value + + def to_component_dict(self) -> TextInputPayload: + return self._underlying.to_dict() + + def refresh_component(self, component: TextInputComponent) -> None: + self._underlying = component + + def refresh_state(self, data: ModalSubmitTextInputInteractionDataPayload) -> None: + self._value = data.get('value', None) + + @classmethod + def from_component(cls, component: TextInput) -> Self: + return cls( + label=component.label, + style=component.style, + custom_id=component.custom_id, + placeholder=component.placeholder, + default_value=component.default_value, + required=component.required, + min_length=component.min_length, + max_length=component.max_length, + row=None, + ) + + @property + def type(self) -> ComponentType: + return ComponentType.text_input + + def is_dispatchable(self) -> bool: + return False diff --git a/discord/ui/view.py b/discord/ui/view.py index 27aa54f42..0879a3399 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -29,6 +29,7 @@ from itertools import groupby import traceback import asyncio +import logging import sys import time import os @@ -40,6 +41,7 @@ from ..components import ( Button as ButtonComponent, SelectMenu as SelectComponent, ) +from ..utils import MISSING __all__ = ( 'View', @@ -50,7 +52,12 @@ if TYPE_CHECKING: from ..interactions import Interaction from ..message import Message from ..types.components import Component as ComponentPayload + from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload from ..state import ConnectionState + from .modal import Modal + + +_log = logging.getLogger(__name__) def _walk_all_components(components: List[Component]) -> Iterator[Component]: @@ -138,6 +145,7 @@ class View: """ __discord_ui_view__: ClassVar[bool] = True + __discord_ui_modal__: ClassVar[bool] = False __view_children_items__: ClassVar[List[ItemCallbackType]] = [] def __init_subclass__(cls) -> None: @@ -152,16 +160,19 @@ class View: cls.__view_children_items__ = children - def __init__(self, *, timeout: Optional[float] = 180.0): - self.timeout = timeout - self.children: List[Item] = [] + def _init_children(self) -> List[Item]: + children = [] for func in self.__view_children_items__: item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__) - item.callback = partial(func, self, item) + item.callback = partial(func, self, item) # type: ignore item._view = self setattr(self, func.__name__, item) - self.children.append(item) + children.append(item) + return children + def __init__(self, *, timeout: Optional[float] = 180.0): + self.timeout = timeout + self.children: List[Item] = self._init_children() self.__weights = _ViewWeights(self.children) loop = asyncio.get_running_loop() self.id: str = os.urandom(16).hex() @@ -464,6 +475,8 @@ class ViewStore: self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {} # message_id: View self._synced_message_views: Dict[int, View] = {} + # custom_id: Modal + self._modals: Dict[str, Modal] = {} self._state: ConnectionState = state @property @@ -487,6 +500,10 @@ class ViewStore: del self._views[k] def add_view(self, view: View, message_id: Optional[int] = None): + if view.__discord_ui_modal__: + self._modals[view.custom_id] = view # type: ignore + return + self.__verify_integrity() view._start_listening_from_store(self) @@ -498,6 +515,10 @@ class ViewStore: self._synced_message_views[message_id] = view def remove_view(self, view: View): + if view.__discord_ui_modal__: + self._modals.pop(view.custom_id, None) # type: ignore + return + for item in view.children: if item.is_dispatchable(): self._views.pop((item.type.value, item.custom_id), None) # type: ignore @@ -507,7 +528,7 @@ class ViewStore: del self._synced_message_views[key] break - def dispatch(self, component_type: int, custom_id: str, interaction: Interaction): + def dispatch_view(self, component_type: int, custom_id: str, interaction: Interaction): self.__verify_integrity() message_id: Optional[int] = interaction.message and interaction.message.id key = (component_type, message_id, custom_id) @@ -518,9 +539,18 @@ class ViewStore: return view, item = value - item.refresh_state(interaction) + item.refresh_state(interaction.data) # type: ignore view._dispatch_item(item, interaction) + def dispatch_modal(self, custom_id: str, interaction: Interaction, components: List[ModalSubmitComponentInteractionDataPayload]): + modal = self._modals.get(custom_id) + if modal is None: + _log.debug("Modal interaction referencing unknown custom_id %s. Discarding", custom_id) + return + + modal.refresh(components) + modal._dispatch_submit(interaction) + def is_message_tracked(self, message_id: int): return message_id in self._synced_message_views diff --git a/docs/api.rst b/docs/api.rst index 88b71bc2d..b6285fc52 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -1418,6 +1418,9 @@ of :class:`enum.Enum`. .. attribute:: component Represents a component based interaction, i.e. using the Discord Bot UI Kit. + .. attribute:: modal_submit + + Represents submission of a modal interaction. .. class:: InteractionResponseType @@ -1451,6 +1454,11 @@ of :class:`enum.Enum`. Responds to the interaction by editing the message. See also :meth:`InteractionResponse.edit_message` + .. attribute:: modal + + Responds to the interaction with a modal. + + See also :meth:`InteractionResponse.send_modal` .. class:: ComponentType @@ -1467,6 +1475,10 @@ of :class:`enum.Enum`. .. attribute:: select Represents a select component. + + .. attribute:: text_input + + Represents a text box component. .. class:: ButtonStyle @@ -1510,6 +1522,22 @@ of :class:`enum.Enum`. An alias for :attr:`link`. +.. class:: TextStyle + + Represents the style of the text box component. + + .. versionadded:: 2.0 + + .. attribute:: short + + Represents a short text box. + .. attribute:: paragraph + + Represents a long form text box. + .. attribute:: long + + An alias for :attr:`paragraph`. + .. class:: VoiceRegion Specifies the region a voice server belongs to. @@ -3398,6 +3426,16 @@ SelectMenu :inherited-members: +TextInput +~~~~~~~~~~ + +.. attributetable:: TextInput + +.. autoclass:: TextInput() + :members: + :inherited-members: + + DeletedReferencedMessage ~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -4061,6 +4099,14 @@ View .. autoclass:: discord.ui.View :members: +Modal +~~~~~~ + +.. attributetable:: discord.ui.Modal + +.. autoclass:: discord.ui.Modal + :members: + Item ~~~~~~~ @@ -4091,6 +4137,14 @@ Select .. autofunction:: discord.ui.select +TextInput +~~~~~~~~ + +.. attributetable:: discord.ui.TextInput + +.. autoclass:: discord.ui.TextInput + :members: + :inherited-members: Exceptions ------------