committed by
GitHub
13 changed files with 679 additions and 17 deletions
@ -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 |
@ -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 |
Loading…
Reference in new issue