mirror of
https://github.com/Rapptz/discord.py.git
synced 2025-06-07 20:28:38 +00:00
Add support for Modal Interactions
This commit is contained in:
parent
964ca0a6f1
commit
19c6687b55
@ -25,7 +25,7 @@ DEALINGS IN THE SOFTWARE.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, ClassVar, Dict, List, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union
|
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 .utils import get_slots, MISSING
|
||||||
from .partial_emoji import PartialEmoji, _EmojiTag
|
from .partial_emoji import PartialEmoji, _EmojiTag
|
||||||
|
|
||||||
@ -36,6 +36,7 @@ if TYPE_CHECKING:
|
|||||||
SelectMenu as SelectMenuPayload,
|
SelectMenu as SelectMenuPayload,
|
||||||
SelectOption as SelectOptionPayload,
|
SelectOption as SelectOptionPayload,
|
||||||
ActionRow as ActionRowPayload,
|
ActionRow as ActionRowPayload,
|
||||||
|
TextInput as TextInputPayload,
|
||||||
)
|
)
|
||||||
from .emoji import Emoji
|
from .emoji import Emoji
|
||||||
|
|
||||||
@ -370,6 +371,83 @@ class SelectOption:
|
|||||||
return payload
|
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:
|
def _component_factory(data: ComponentPayload) -> Component:
|
||||||
component_type = data['type']
|
component_type = data['type']
|
||||||
if component_type == 1:
|
if component_type == 1:
|
||||||
@ -378,6 +456,8 @@ def _component_factory(data: ComponentPayload) -> Component:
|
|||||||
return Button(data) # type: ignore
|
return Button(data) # type: ignore
|
||||||
elif component_type == 3:
|
elif component_type == 3:
|
||||||
return SelectMenu(data) # type: ignore
|
return SelectMenu(data) # type: ignore
|
||||||
|
elif component_type == 4:
|
||||||
|
return TextInput(data) # type: ignore
|
||||||
else:
|
else:
|
||||||
as_enum = try_enum(ComponentType, component_type)
|
as_enum = try_enum(ComponentType, component_type)
|
||||||
return Component._raw_construct(type=as_enum)
|
return Component._raw_construct(type=as_enum)
|
||||||
|
@ -51,6 +51,7 @@ __all__ = (
|
|||||||
'VideoQualityMode',
|
'VideoQualityMode',
|
||||||
'ComponentType',
|
'ComponentType',
|
||||||
'ButtonStyle',
|
'ButtonStyle',
|
||||||
|
'TextStyle',
|
||||||
'StagePrivacyLevel',
|
'StagePrivacyLevel',
|
||||||
'InteractionType',
|
'InteractionType',
|
||||||
'InteractionResponseType',
|
'InteractionResponseType',
|
||||||
@ -530,6 +531,7 @@ class InteractionType(Enum):
|
|||||||
ping = 1
|
ping = 1
|
||||||
application_command = 2
|
application_command = 2
|
||||||
component = 3
|
component = 3
|
||||||
|
modal_submit = 5
|
||||||
|
|
||||||
|
|
||||||
class InteractionResponseType(Enum):
|
class InteractionResponseType(Enum):
|
||||||
@ -540,6 +542,7 @@ class InteractionResponseType(Enum):
|
|||||||
deferred_channel_message = 5 # (with source)
|
deferred_channel_message = 5 # (with source)
|
||||||
deferred_message_update = 6 # for components
|
deferred_message_update = 6 # for components
|
||||||
message_update = 7 # for components
|
message_update = 7 # for components
|
||||||
|
modal = 9 # for modals
|
||||||
|
|
||||||
|
|
||||||
class VideoQualityMode(Enum):
|
class VideoQualityMode(Enum):
|
||||||
@ -554,6 +557,7 @@ class ComponentType(Enum):
|
|||||||
action_row = 1
|
action_row = 1
|
||||||
button = 2
|
button = 2
|
||||||
select = 3
|
select = 3
|
||||||
|
text_input = 4
|
||||||
|
|
||||||
def __int__(self):
|
def __int__(self):
|
||||||
return self.value
|
return self.value
|
||||||
@ -578,6 +582,17 @@ class ButtonStyle(Enum):
|
|||||||
return self.value
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
|
class TextStyle(Enum):
|
||||||
|
short = 1
|
||||||
|
paragraph = 2
|
||||||
|
|
||||||
|
# Aliases
|
||||||
|
long = 2
|
||||||
|
|
||||||
|
def __int__(self) -> int:
|
||||||
|
return self.value
|
||||||
|
|
||||||
|
|
||||||
class StagePrivacyLevel(Enum):
|
class StagePrivacyLevel(Enum):
|
||||||
public = 1
|
public = 1
|
||||||
closed = 2
|
closed = 2
|
||||||
|
@ -60,6 +60,7 @@ if TYPE_CHECKING:
|
|||||||
from aiohttp import ClientSession
|
from aiohttp import ClientSession
|
||||||
from .embeds import Embed
|
from .embeds import Embed
|
||||||
from .ui.view import View
|
from .ui.view import View
|
||||||
|
from .ui.modal import Modal
|
||||||
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable
|
from .channel import VoiceChannel, StageChannel, TextChannel, CategoryChannel, StoreChannel, PartialMessageable
|
||||||
from .threads import Thread
|
from .threads import Thread
|
||||||
|
|
||||||
@ -628,6 +629,41 @@ class InteractionResponse:
|
|||||||
|
|
||||||
self._responded = True
|
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:
|
class _InteractionMessageState:
|
||||||
__slots__ = ('_parent', '_interaction')
|
__slots__ = ('_parent', '_interaction')
|
||||||
|
@ -688,8 +688,11 @@ class ConnectionState:
|
|||||||
if data['type'] == 3: # interaction component
|
if data['type'] == 3: # interaction component
|
||||||
custom_id = interaction.data['custom_id'] # type: ignore
|
custom_id = interaction.data['custom_id'] # type: ignore
|
||||||
component_type = interaction.data['component_type'] # 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)
|
self.dispatch('interaction', interaction)
|
||||||
|
|
||||||
def parse_presence_update(self, data) -> None:
|
def parse_presence_update(self, data) -> None:
|
||||||
|
@ -29,6 +29,7 @@ from .emoji import PartialEmoji
|
|||||||
|
|
||||||
ComponentType = Literal[1, 2, 3]
|
ComponentType = Literal[1, 2, 3]
|
||||||
ButtonStyle = Literal[1, 2, 3, 4, 5]
|
ButtonStyle = Literal[1, 2, 3, 4, 5]
|
||||||
|
TextStyle = Literal[1, 2]
|
||||||
|
|
||||||
|
|
||||||
class ActionRow(TypedDict):
|
class ActionRow(TypedDict):
|
||||||
@ -73,4 +74,19 @@ class SelectMenu(_SelectMenuOptional):
|
|||||||
options: List[SelectOption]
|
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]
|
||||||
|
@ -163,13 +163,13 @@ class SelectMessageComponentInteractionData(_BaseMessageComponentInteractionData
|
|||||||
MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData]
|
MessageComponentInteractionData = Union[ButtonMessageComponentInteractionData, SelectMessageComponentInteractionData]
|
||||||
|
|
||||||
|
|
||||||
class ModalSubmitInputTextInteractionData(TypedDict):
|
class ModalSubmitTextInputInteractionData(TypedDict):
|
||||||
type: Literal[4]
|
type: Literal[4]
|
||||||
custom_id: str
|
custom_id: str
|
||||||
value: str
|
value: str
|
||||||
|
|
||||||
|
|
||||||
ModalSubmitComponentItemInteractionData = ModalSubmitInputTextInteractionData
|
ModalSubmitComponentItemInteractionData = ModalSubmitTextInputInteractionData
|
||||||
|
|
||||||
|
|
||||||
class ModalSubmitActionRowInteractionData(TypedDict):
|
class ModalSubmitActionRowInteractionData(TypedDict):
|
||||||
|
@ -10,6 +10,8 @@ Bot UI Kit helper for the Discord API
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from .view import *
|
from .view import *
|
||||||
|
from .modal import *
|
||||||
from .item import *
|
from .item import *
|
||||||
from .button import *
|
from .button import *
|
||||||
from .select import *
|
from .select import *
|
||||||
|
from .text_input import *
|
||||||
|
@ -73,7 +73,7 @@ class Item(Generic[V]):
|
|||||||
def refresh_component(self, component: Component) -> None:
|
def refresh_component(self, component: Component) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def refresh_state(self, interaction: Interaction) -> None:
|
def refresh_state(self, data: Dict[str, Any]) -> None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@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 .view import View
|
||||||
from ..types.components import SelectMenu as SelectMenuPayload
|
from ..types.components import SelectMenu as SelectMenuPayload
|
||||||
from ..types.interactions import (
|
from ..types.interactions import (
|
||||||
ComponentInteractionData,
|
MessageComponentInteractionData,
|
||||||
)
|
)
|
||||||
|
|
||||||
S = TypeVar('S', bound='Select')
|
S = TypeVar('S', bound='Select')
|
||||||
@ -270,8 +270,7 @@ class Select(Item[V]):
|
|||||||
def refresh_component(self, component: SelectMenu) -> None:
|
def refresh_component(self, component: SelectMenu) -> None:
|
||||||
self._underlying = component
|
self._underlying = component
|
||||||
|
|
||||||
def refresh_state(self, interaction: Interaction) -> None:
|
def refresh_state(self, data: MessageComponentInteractionData) -> None:
|
||||||
data: ComponentInteractionData = interaction.data # type: ignore
|
|
||||||
self._selected_values = data.get('values', [])
|
self._selected_values = data.get('values', [])
|
||||||
|
|
||||||
@classmethod
|
@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 traceback
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import logging
|
||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
@ -40,6 +41,7 @@ from ..components import (
|
|||||||
Button as ButtonComponent,
|
Button as ButtonComponent,
|
||||||
SelectMenu as SelectComponent,
|
SelectMenu as SelectComponent,
|
||||||
)
|
)
|
||||||
|
from ..utils import MISSING
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'View',
|
'View',
|
||||||
@ -50,7 +52,12 @@ if TYPE_CHECKING:
|
|||||||
from ..interactions import Interaction
|
from ..interactions import Interaction
|
||||||
from ..message import Message
|
from ..message import Message
|
||||||
from ..types.components import Component as ComponentPayload
|
from ..types.components import Component as ComponentPayload
|
||||||
|
from ..types.interactions import ModalSubmitComponentInteractionData as ModalSubmitComponentInteractionDataPayload
|
||||||
from ..state import ConnectionState
|
from ..state import ConnectionState
|
||||||
|
from .modal import Modal
|
||||||
|
|
||||||
|
|
||||||
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def _walk_all_components(components: List[Component]) -> Iterator[Component]:
|
def _walk_all_components(components: List[Component]) -> Iterator[Component]:
|
||||||
@ -138,6 +145,7 @@ class View:
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
__discord_ui_view__: ClassVar[bool] = True
|
__discord_ui_view__: ClassVar[bool] = True
|
||||||
|
__discord_ui_modal__: ClassVar[bool] = False
|
||||||
__view_children_items__: ClassVar[List[ItemCallbackType]] = []
|
__view_children_items__: ClassVar[List[ItemCallbackType]] = []
|
||||||
|
|
||||||
def __init_subclass__(cls) -> None:
|
def __init_subclass__(cls) -> None:
|
||||||
@ -152,16 +160,19 @@ class View:
|
|||||||
|
|
||||||
cls.__view_children_items__ = children
|
cls.__view_children_items__ = children
|
||||||
|
|
||||||
def __init__(self, *, timeout: Optional[float] = 180.0):
|
def _init_children(self) -> List[Item]:
|
||||||
self.timeout = timeout
|
children = []
|
||||||
self.children: List[Item] = []
|
|
||||||
for func in self.__view_children_items__:
|
for func in self.__view_children_items__:
|
||||||
item: Item = func.__discord_ui_model_type__(**func.__discord_ui_model_kwargs__)
|
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
|
item._view = self
|
||||||
setattr(self, func.__name__, item)
|
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)
|
self.__weights = _ViewWeights(self.children)
|
||||||
loop = asyncio.get_running_loop()
|
loop = asyncio.get_running_loop()
|
||||||
self.id: str = os.urandom(16).hex()
|
self.id: str = os.urandom(16).hex()
|
||||||
@ -464,6 +475,8 @@ class ViewStore:
|
|||||||
self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {}
|
self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {}
|
||||||
# message_id: View
|
# message_id: View
|
||||||
self._synced_message_views: Dict[int, View] = {}
|
self._synced_message_views: Dict[int, View] = {}
|
||||||
|
# custom_id: Modal
|
||||||
|
self._modals: Dict[str, Modal] = {}
|
||||||
self._state: ConnectionState = state
|
self._state: ConnectionState = state
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@ -487,6 +500,10 @@ class ViewStore:
|
|||||||
del self._views[k]
|
del self._views[k]
|
||||||
|
|
||||||
def add_view(self, view: View, message_id: Optional[int] = None):
|
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()
|
self.__verify_integrity()
|
||||||
|
|
||||||
view._start_listening_from_store(self)
|
view._start_listening_from_store(self)
|
||||||
@ -498,6 +515,10 @@ class ViewStore:
|
|||||||
self._synced_message_views[message_id] = view
|
self._synced_message_views[message_id] = view
|
||||||
|
|
||||||
def remove_view(self, view: 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:
|
for item in view.children:
|
||||||
if item.is_dispatchable():
|
if item.is_dispatchable():
|
||||||
self._views.pop((item.type.value, item.custom_id), None) # type: ignore
|
self._views.pop((item.type.value, item.custom_id), None) # type: ignore
|
||||||
@ -507,7 +528,7 @@ class ViewStore:
|
|||||||
del self._synced_message_views[key]
|
del self._synced_message_views[key]
|
||||||
break
|
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()
|
self.__verify_integrity()
|
||||||
message_id: Optional[int] = interaction.message and interaction.message.id
|
message_id: Optional[int] = interaction.message and interaction.message.id
|
||||||
key = (component_type, message_id, custom_id)
|
key = (component_type, message_id, custom_id)
|
||||||
@ -518,9 +539,18 @@ class ViewStore:
|
|||||||
return
|
return
|
||||||
|
|
||||||
view, item = value
|
view, item = value
|
||||||
item.refresh_state(interaction)
|
item.refresh_state(interaction.data) # type: ignore
|
||||||
view._dispatch_item(item, interaction)
|
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):
|
def is_message_tracked(self, message_id: int):
|
||||||
return message_id in self._synced_message_views
|
return message_id in self._synced_message_views
|
||||||
|
|
||||||
|
54
docs/api.rst
54
docs/api.rst
@ -1418,6 +1418,9 @@ of :class:`enum.Enum`.
|
|||||||
.. attribute:: component
|
.. attribute:: component
|
||||||
|
|
||||||
Represents a component based interaction, i.e. using the Discord Bot UI Kit.
|
Represents a component based interaction, i.e. using the Discord Bot UI Kit.
|
||||||
|
.. attribute:: modal_submit
|
||||||
|
|
||||||
|
Represents submission of a modal interaction.
|
||||||
|
|
||||||
.. class:: InteractionResponseType
|
.. class:: InteractionResponseType
|
||||||
|
|
||||||
@ -1451,6 +1454,11 @@ of :class:`enum.Enum`.
|
|||||||
Responds to the interaction by editing the message.
|
Responds to the interaction by editing the message.
|
||||||
|
|
||||||
See also :meth:`InteractionResponse.edit_message`
|
See also :meth:`InteractionResponse.edit_message`
|
||||||
|
.. attribute:: modal
|
||||||
|
|
||||||
|
Responds to the interaction with a modal.
|
||||||
|
|
||||||
|
See also :meth:`InteractionResponse.send_modal`
|
||||||
|
|
||||||
.. class:: ComponentType
|
.. class:: ComponentType
|
||||||
|
|
||||||
@ -1468,6 +1476,10 @@ of :class:`enum.Enum`.
|
|||||||
|
|
||||||
Represents a select component.
|
Represents a select component.
|
||||||
|
|
||||||
|
.. attribute:: text_input
|
||||||
|
|
||||||
|
Represents a text box component.
|
||||||
|
|
||||||
|
|
||||||
.. class:: ButtonStyle
|
.. class:: ButtonStyle
|
||||||
|
|
||||||
@ -1510,6 +1522,22 @@ of :class:`enum.Enum`.
|
|||||||
|
|
||||||
An alias for :attr:`link`.
|
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
|
.. class:: VoiceRegion
|
||||||
|
|
||||||
Specifies the region a voice server belongs to.
|
Specifies the region a voice server belongs to.
|
||||||
@ -3398,6 +3426,16 @@ SelectMenu
|
|||||||
:inherited-members:
|
:inherited-members:
|
||||||
|
|
||||||
|
|
||||||
|
TextInput
|
||||||
|
~~~~~~~~~~
|
||||||
|
|
||||||
|
.. attributetable:: TextInput
|
||||||
|
|
||||||
|
.. autoclass:: TextInput()
|
||||||
|
:members:
|
||||||
|
:inherited-members:
|
||||||
|
|
||||||
|
|
||||||
DeletedReferencedMessage
|
DeletedReferencedMessage
|
||||||
~~~~~~~~~~~~~~~~~~~~~~~~~
|
~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
@ -4061,6 +4099,14 @@ View
|
|||||||
.. autoclass:: discord.ui.View
|
.. autoclass:: discord.ui.View
|
||||||
:members:
|
:members:
|
||||||
|
|
||||||
|
Modal
|
||||||
|
~~~~~~
|
||||||
|
|
||||||
|
.. attributetable:: discord.ui.Modal
|
||||||
|
|
||||||
|
.. autoclass:: discord.ui.Modal
|
||||||
|
:members:
|
||||||
|
|
||||||
Item
|
Item
|
||||||
~~~~~~~
|
~~~~~~~
|
||||||
|
|
||||||
@ -4091,6 +4137,14 @@ Select
|
|||||||
|
|
||||||
.. autofunction:: discord.ui.select
|
.. autofunction:: discord.ui.select
|
||||||
|
|
||||||
|
TextInput
|
||||||
|
~~~~~~~~
|
||||||
|
|
||||||
|
.. attributetable:: discord.ui.TextInput
|
||||||
|
|
||||||
|
.. autoclass:: discord.ui.TextInput
|
||||||
|
:members:
|
||||||
|
:inherited-members:
|
||||||
|
|
||||||
Exceptions
|
Exceptions
|
||||||
------------
|
------------
|
||||||
|
Loading…
x
Reference in New Issue
Block a user