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