mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-09-07 10:22:59 +00:00
Add support for Modal Interactions
This commit is contained in:
@ -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 *
|
||||
|
@ -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
|
||||
|
196
discord/ui/modal.py
Normal file
196
discord/ui/modal.py
Normal file
@ -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
|
@ -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
|
||||
|
231
discord/ui/text_input.py
Normal file
231
discord/ui/text_input.py
Normal file
@ -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
|
@ -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
|
||||
|
||||
|
Reference in New Issue
Block a user