23 changed files with 135 additions and 1379 deletions
@ -1,15 +0,0 @@ |
|||||
""" |
|
||||
discord.ui |
|
||||
~~~~~~~~~~~ |
|
||||
|
|
||||
Bot UI Kit helper for the Discord API |
|
||||
|
|
||||
:copyright: (c) 2015-present Rapptz |
|
||||
:license: MIT, see LICENSE for more details. |
|
||||
|
|
||||
""" |
|
||||
|
|
||||
from .view import * |
|
||||
from .item import * |
|
||||
from .button import * |
|
||||
from .select import * |
|
@ -1,290 +0,0 @@ |
|||||
""" |
|
||||
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 |
|
||||
|
|
||||
from typing import Callable, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union |
|
||||
import inspect |
|
||||
import os |
|
||||
|
|
||||
|
|
||||
from .item import Item, ItemCallbackType |
|
||||
from ..enums import ButtonStyle, ComponentType |
|
||||
from ..partial_emoji import PartialEmoji, _EmojiTag |
|
||||
from ..components import Button as ButtonComponent |
|
||||
|
|
||||
__all__ = ( |
|
||||
'Button', |
|
||||
'button', |
|
||||
) |
|
||||
|
|
||||
if TYPE_CHECKING: |
|
||||
from .view import View |
|
||||
from ..emoji import Emoji |
|
||||
|
|
||||
B = TypeVar('B', bound='Button') |
|
||||
V = TypeVar('V', bound='View', covariant=True) |
|
||||
|
|
||||
|
|
||||
class Button(Item[V]): |
|
||||
"""Represents a UI button. |
|
||||
|
|
||||
.. versionadded:: 2.0 |
|
||||
|
|
||||
Parameters |
|
||||
------------ |
|
||||
style: :class:`discord.ButtonStyle` |
|
||||
The style of the button. |
|
||||
custom_id: Optional[:class:`str`] |
|
||||
The ID of the button that gets received during an interaction. |
|
||||
If this button is for a URL, it does not have a custom ID. |
|
||||
url: Optional[:class:`str`] |
|
||||
The URL this button sends you to. |
|
||||
disabled: :class:`bool` |
|
||||
Whether the button is disabled or not. |
|
||||
label: Optional[:class:`str`] |
|
||||
The label of the button, if any. |
|
||||
emoji: Optional[Union[:class:`.PartialEmoji`, :class:`.Emoji`, :class:`str`]] |
|
||||
The emoji of the button, if available. |
|
||||
row: Optional[:class:`int`] |
|
||||
The relative row this button 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, ...] = ( |
|
||||
'style', |
|
||||
'url', |
|
||||
'disabled', |
|
||||
'label', |
|
||||
'emoji', |
|
||||
'row', |
|
||||
) |
|
||||
|
|
||||
def __init__( |
|
||||
self, |
|
||||
*, |
|
||||
style: ButtonStyle = ButtonStyle.secondary, |
|
||||
label: Optional[str] = None, |
|
||||
disabled: bool = False, |
|
||||
custom_id: Optional[str] = None, |
|
||||
url: Optional[str] = None, |
|
||||
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, |
|
||||
row: Optional[int] = None, |
|
||||
): |
|
||||
super().__init__() |
|
||||
if custom_id is not None and url is not None: |
|
||||
raise TypeError('cannot mix both url and custom_id with Button') |
|
||||
|
|
||||
self._provided_custom_id = custom_id is not None |
|
||||
if url is None and custom_id is None: |
|
||||
custom_id = os.urandom(16).hex() |
|
||||
|
|
||||
if url is not None: |
|
||||
style = ButtonStyle.link |
|
||||
|
|
||||
if emoji is not None: |
|
||||
if isinstance(emoji, str): |
|
||||
emoji = PartialEmoji.from_str(emoji) |
|
||||
elif isinstance(emoji, _EmojiTag): |
|
||||
emoji = emoji._to_partial() |
|
||||
else: |
|
||||
raise TypeError(f'expected emoji to be str, Emoji, or PartialEmoji not {emoji.__class__}') |
|
||||
|
|
||||
self._underlying = ButtonComponent._raw_construct( |
|
||||
type=ComponentType.button, |
|
||||
custom_id=custom_id, |
|
||||
url=url, |
|
||||
disabled=disabled, |
|
||||
label=label, |
|
||||
style=style, |
|
||||
emoji=emoji, |
|
||||
) |
|
||||
self.row = row |
|
||||
|
|
||||
@property |
|
||||
def style(self) -> ButtonStyle: |
|
||||
""":class:`discord.ButtonStyle`: The style of the button.""" |
|
||||
return self._underlying.style |
|
||||
|
|
||||
@style.setter |
|
||||
def style(self, value: ButtonStyle): |
|
||||
self._underlying.style = value |
|
||||
|
|
||||
@property |
|
||||
def custom_id(self) -> Optional[str]: |
|
||||
"""Optional[:class:`str`]: The ID of the button that gets received during an interaction. |
|
||||
|
|
||||
If this button is for a URL, it does not have a custom ID. |
|
||||
""" |
|
||||
return self._underlying.custom_id |
|
||||
|
|
||||
@custom_id.setter |
|
||||
def custom_id(self, value: Optional[str]): |
|
||||
if value is not None and not isinstance(value, str): |
|
||||
raise TypeError('custom_id must be None or str') |
|
||||
|
|
||||
self._underlying.custom_id = value |
|
||||
|
|
||||
@property |
|
||||
def url(self) -> Optional[str]: |
|
||||
"""Optional[:class:`str`]: The URL this button sends you to.""" |
|
||||
return self._underlying.url |
|
||||
|
|
||||
@url.setter |
|
||||
def url(self, value: Optional[str]): |
|
||||
if value is not None and not isinstance(value, str): |
|
||||
raise TypeError('url must be None or str') |
|
||||
self._underlying.url = value |
|
||||
|
|
||||
@property |
|
||||
def disabled(self) -> bool: |
|
||||
""":class:`bool`: Whether the button is disabled or not.""" |
|
||||
return self._underlying.disabled |
|
||||
|
|
||||
@disabled.setter |
|
||||
def disabled(self, value: bool): |
|
||||
self._underlying.disabled = bool(value) |
|
||||
|
|
||||
@property |
|
||||
def label(self) -> Optional[str]: |
|
||||
"""Optional[:class:`str`]: The label of the button, if available.""" |
|
||||
return self._underlying.label |
|
||||
|
|
||||
@label.setter |
|
||||
def label(self, value: Optional[str]): |
|
||||
self._underlying.label = str(value) if value is not None else value |
|
||||
|
|
||||
@property |
|
||||
def emoji(self) -> Optional[PartialEmoji]: |
|
||||
"""Optional[:class:`.PartialEmoji`]: The emoji of the button, if available.""" |
|
||||
return self._underlying.emoji |
|
||||
|
|
||||
@emoji.setter |
|
||||
def emoji(self, value: Optional[Union[str, Emoji, PartialEmoji]]): # type: ignore |
|
||||
if value is not None: |
|
||||
if isinstance(value, str): |
|
||||
self._underlying.emoji = PartialEmoji.from_str(value) |
|
||||
elif isinstance(value, _EmojiTag): |
|
||||
self._underlying.emoji = value._to_partial() |
|
||||
else: |
|
||||
raise TypeError(f'expected str, Emoji, or PartialEmoji, received {value.__class__} instead') |
|
||||
else: |
|
||||
self._underlying.emoji = None |
|
||||
|
|
||||
@classmethod |
|
||||
def from_component(cls: Type[B], button: ButtonComponent) -> B: |
|
||||
return cls( |
|
||||
style=button.style, |
|
||||
label=button.label, |
|
||||
disabled=button.disabled, |
|
||||
custom_id=button.custom_id, |
|
||||
url=button.url, |
|
||||
emoji=button.emoji, |
|
||||
row=None, |
|
||||
) |
|
||||
|
|
||||
@property |
|
||||
def type(self) -> ComponentType: |
|
||||
return self._underlying.type |
|
||||
|
|
||||
def to_component_dict(self): |
|
||||
return self._underlying.to_dict() |
|
||||
|
|
||||
def is_dispatchable(self) -> bool: |
|
||||
return self.custom_id is not None |
|
||||
|
|
||||
def is_persistent(self) -> bool: |
|
||||
if self.style is ButtonStyle.link: |
|
||||
return self.url is not None |
|
||||
return super().is_persistent() |
|
||||
|
|
||||
def refresh_component(self, button: ButtonComponent) -> None: |
|
||||
self._underlying = button |
|
||||
|
|
||||
|
|
||||
def button( |
|
||||
*, |
|
||||
label: Optional[str] = None, |
|
||||
custom_id: Optional[str] = None, |
|
||||
disabled: bool = False, |
|
||||
style: ButtonStyle = ButtonStyle.secondary, |
|
||||
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, |
|
||||
row: Optional[int] = None, |
|
||||
) -> Callable[[ItemCallbackType], ItemCallbackType]: |
|
||||
"""A decorator that attaches a button to a component. |
|
||||
|
|
||||
The function being decorated should have three parameters, ``self`` representing |
|
||||
the :class:`discord.ui.View`, the :class:`discord.ui.Button` being pressed and |
|
||||
the :class:`discord.Interaction` you receive. |
|
||||
|
|
||||
.. note:: |
|
||||
|
|
||||
Buttons with a URL cannot be created with this function. |
|
||||
Consider creating a :class:`Button` manually instead. |
|
||||
This is because buttons with a URL do not have a callback |
|
||||
associated with them since Discord does not do any processing |
|
||||
with it. |
|
||||
|
|
||||
Parameters |
|
||||
------------ |
|
||||
label: Optional[:class:`str`] |
|
||||
The label of the button, if any. |
|
||||
custom_id: Optional[:class:`str`] |
|
||||
The ID of the button that gets received during an interaction. |
|
||||
It is recommended not to set this parameter to prevent conflicts. |
|
||||
style: :class:`.ButtonStyle` |
|
||||
The style of the button. Defaults to :attr:`.ButtonStyle.grey`. |
|
||||
disabled: :class:`bool` |
|
||||
Whether the button is disabled or not. Defaults to ``False``. |
|
||||
emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] |
|
||||
The emoji of the button. This can be in string form or a :class:`.PartialEmoji` |
|
||||
or a full :class:`.Emoji`. |
|
||||
row: Optional[:class:`int`] |
|
||||
The relative row this button 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). |
|
||||
""" |
|
||||
|
|
||||
def decorator(func: ItemCallbackType) -> ItemCallbackType: |
|
||||
if not inspect.iscoroutinefunction(func): |
|
||||
raise TypeError('button function must be a coroutine function') |
|
||||
|
|
||||
func.__discord_ui_model_type__ = Button |
|
||||
func.__discord_ui_model_kwargs__ = { |
|
||||
'style': style, |
|
||||
'custom_id': custom_id, |
|
||||
'url': None, |
|
||||
'disabled': disabled, |
|
||||
'label': label, |
|
||||
'emoji': emoji, |
|
||||
'row': row, |
|
||||
} |
|
||||
return func |
|
||||
|
|
||||
return decorator |
|
@ -1,131 +0,0 @@ |
|||||
""" |
|
||||
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 |
|
||||
|
|
||||
from typing import Any, Callable, Coroutine, Dict, Generic, Optional, TYPE_CHECKING, Tuple, Type, TypeVar |
|
||||
|
|
||||
from ..interactions import Interaction |
|
||||
|
|
||||
__all__ = ( |
|
||||
'Item', |
|
||||
) |
|
||||
|
|
||||
if TYPE_CHECKING: |
|
||||
from ..enums import ComponentType |
|
||||
from .view import View |
|
||||
from ..components import Component |
|
||||
|
|
||||
I = TypeVar('I', bound='Item') |
|
||||
V = TypeVar('V', bound='View', covariant=True) |
|
||||
ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]] |
|
||||
|
|
||||
|
|
||||
class Item(Generic[V]): |
|
||||
"""Represents the base UI item that all UI components inherit from. |
|
||||
|
|
||||
The current UI items supported are: |
|
||||
|
|
||||
- :class:`discord.ui.Button` |
|
||||
- :class:`discord.ui.Select` |
|
||||
|
|
||||
.. versionadded:: 2.0 |
|
||||
""" |
|
||||
|
|
||||
__item_repr_attributes__: Tuple[str, ...] = ('row',) |
|
||||
|
|
||||
def __init__(self): |
|
||||
self._view: Optional[V] = None |
|
||||
self._row: Optional[int] = None |
|
||||
self._rendered_row: Optional[int] = None |
|
||||
# This works mostly well but there is a gotcha with |
|
||||
# the interaction with from_component, since that technically provides |
|
||||
# a custom_id most dispatchable items would get this set to True even though |
|
||||
# it might not be provided by the library user. However, this edge case doesn't |
|
||||
# actually affect the intended purpose of this check because from_component is |
|
||||
# only called upon edit and we're mainly interested during initial creation time. |
|
||||
self._provided_custom_id: bool = False |
|
||||
|
|
||||
def to_component_dict(self) -> Dict[str, Any]: |
|
||||
raise NotImplementedError |
|
||||
|
|
||||
def refresh_component(self, component: Component) -> None: |
|
||||
return None |
|
||||
|
|
||||
def refresh_state(self, interaction: Interaction) -> None: |
|
||||
return None |
|
||||
|
|
||||
@classmethod |
|
||||
def from_component(cls: Type[I], component: Component) -> I: |
|
||||
return cls() |
|
||||
|
|
||||
@property |
|
||||
def type(self) -> ComponentType: |
|
||||
raise NotImplementedError |
|
||||
|
|
||||
def is_dispatchable(self) -> bool: |
|
||||
return False |
|
||||
|
|
||||
def is_persistent(self) -> bool: |
|
||||
return self._provided_custom_id |
|
||||
|
|
||||
def __repr__(self) -> str: |
|
||||
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__item_repr_attributes__) |
|
||||
return f'<{self.__class__.__name__} {attrs}>' |
|
||||
|
|
||||
@property |
|
||||
def row(self) -> Optional[int]: |
|
||||
return self._row |
|
||||
|
|
||||
@row.setter |
|
||||
def row(self, value: Optional[int]): |
|
||||
if value is None: |
|
||||
self._row = None |
|
||||
elif 5 > value >= 0: |
|
||||
self._row = value |
|
||||
else: |
|
||||
raise ValueError('row cannot be negative or greater than or equal to 5') |
|
||||
|
|
||||
@property |
|
||||
def width(self) -> int: |
|
||||
return 1 |
|
||||
|
|
||||
@property |
|
||||
def view(self) -> Optional[V]: |
|
||||
"""Optional[:class:`View`]: The underlying view for this item.""" |
|
||||
return self._view |
|
||||
|
|
||||
async def callback(self, interaction: Interaction): |
|
||||
"""|coro| |
|
||||
|
|
||||
The callback associated with this UI item. |
|
||||
|
|
||||
This can be overriden by subclasses. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
interaction: :class:`.Interaction` |
|
||||
The interaction that triggered this UI item. |
|
||||
""" |
|
||||
pass |
|
@ -1,357 +0,0 @@ |
|||||
""" |
|
||||
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 |
|
||||
from typing import List, Optional, TYPE_CHECKING, Tuple, TypeVar, Type, Callable, Union |
|
||||
import inspect |
|
||||
import os |
|
||||
|
|
||||
from .item import Item, ItemCallbackType |
|
||||
from ..enums import ComponentType |
|
||||
from ..partial_emoji import PartialEmoji |
|
||||
from ..emoji import Emoji |
|
||||
from ..interactions import Interaction |
|
||||
from ..utils import MISSING |
|
||||
from ..components import ( |
|
||||
SelectOption, |
|
||||
SelectMenu, |
|
||||
) |
|
||||
|
|
||||
__all__ = ( |
|
||||
'Select', |
|
||||
'select', |
|
||||
) |
|
||||
|
|
||||
if TYPE_CHECKING: |
|
||||
from .view import View |
|
||||
from ..types.components import SelectMenu as SelectMenuPayload |
|
||||
from ..types.interactions import ( |
|
||||
ComponentInteractionData, |
|
||||
) |
|
||||
|
|
||||
S = TypeVar('S', bound='Select') |
|
||||
V = TypeVar('V', bound='View', covariant=True) |
|
||||
|
|
||||
|
|
||||
class Select(Item[V]): |
|
||||
"""Represents a UI select menu. |
|
||||
|
|
||||
This is usually represented as a drop down menu. |
|
||||
|
|
||||
In order to get the selected items that the user has chosen, use :attr:`Select.values`. |
|
||||
|
|
||||
.. versionadded:: 2.0 |
|
||||
|
|
||||
Parameters |
|
||||
------------ |
|
||||
custom_id: :class:`str` |
|
||||
The ID of the select menu that gets received during an interaction. |
|
||||
If not given then one is generated for you. |
|
||||
placeholder: Optional[:class:`str`] |
|
||||
The placeholder text that is shown if nothing is selected, if any. |
|
||||
min_values: :class:`int` |
|
||||
The minimum number of items that must be chosen for this select menu. |
|
||||
Defaults to 1 and must be between 1 and 25. |
|
||||
max_values: :class:`int` |
|
||||
The maximum number of items that must be chosen for this select menu. |
|
||||
Defaults to 1 and must be between 1 and 25. |
|
||||
options: List[:class:`discord.SelectOption`] |
|
||||
A list of options that can be selected in this menu. |
|
||||
disabled: :class:`bool` |
|
||||
Whether the select is disabled or not. |
|
||||
row: Optional[:class:`int`] |
|
||||
The relative row this select menu 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, ...] = ( |
|
||||
'placeholder', |
|
||||
'min_values', |
|
||||
'max_values', |
|
||||
'options', |
|
||||
'disabled', |
|
||||
) |
|
||||
|
|
||||
def __init__( |
|
||||
self, |
|
||||
*, |
|
||||
custom_id: str = MISSING, |
|
||||
placeholder: Optional[str] = None, |
|
||||
min_values: int = 1, |
|
||||
max_values: int = 1, |
|
||||
options: List[SelectOption] = MISSING, |
|
||||
disabled: bool = False, |
|
||||
row: Optional[int] = None, |
|
||||
) -> None: |
|
||||
super().__init__() |
|
||||
self._selected_values: List[str] = [] |
|
||||
self._provided_custom_id = custom_id is not MISSING |
|
||||
custom_id = os.urandom(16).hex() if custom_id is MISSING else custom_id |
|
||||
options = [] if options is MISSING else options |
|
||||
self._underlying = SelectMenu._raw_construct( |
|
||||
custom_id=custom_id, |
|
||||
type=ComponentType.select, |
|
||||
placeholder=placeholder, |
|
||||
min_values=min_values, |
|
||||
max_values=max_values, |
|
||||
options=options, |
|
||||
disabled=disabled, |
|
||||
) |
|
||||
self.row = 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): |
|
||||
if not isinstance(value, str): |
|
||||
raise TypeError('custom_id must be None or str') |
|
||||
|
|
||||
self._underlying.custom_id = value |
|
||||
|
|
||||
@property |
|
||||
def placeholder(self) -> Optional[str]: |
|
||||
"""Optional[:class:`str`]: The placeholder text that is shown if nothing is selected, if any.""" |
|
||||
return self._underlying.placeholder |
|
||||
|
|
||||
@placeholder.setter |
|
||||
def placeholder(self, value: Optional[str]): |
|
||||
if value is not None and not isinstance(value, str): |
|
||||
raise TypeError('placeholder must be None or str') |
|
||||
|
|
||||
self._underlying.placeholder = value |
|
||||
|
|
||||
@property |
|
||||
def min_values(self) -> int: |
|
||||
""":class:`int`: The minimum number of items that must be chosen for this select menu.""" |
|
||||
return self._underlying.min_values |
|
||||
|
|
||||
@min_values.setter |
|
||||
def min_values(self, value: int): |
|
||||
self._underlying.min_values = int(value) |
|
||||
|
|
||||
@property |
|
||||
def max_values(self) -> int: |
|
||||
""":class:`int`: The maximum number of items that must be chosen for this select menu.""" |
|
||||
return self._underlying.max_values |
|
||||
|
|
||||
@max_values.setter |
|
||||
def max_values(self, value: int): |
|
||||
self._underlying.max_values = int(value) |
|
||||
|
|
||||
@property |
|
||||
def options(self) -> List[SelectOption]: |
|
||||
"""List[:class:`discord.SelectOption`]: A list of options that can be selected in this menu.""" |
|
||||
return self._underlying.options |
|
||||
|
|
||||
@options.setter |
|
||||
def options(self, value: List[SelectOption]): |
|
||||
if not isinstance(value, list): |
|
||||
raise TypeError('options must be a list of SelectOption') |
|
||||
if not all(isinstance(obj, SelectOption) for obj in value): |
|
||||
raise TypeError('all list items must subclass SelectOption') |
|
||||
|
|
||||
self._underlying.options = value |
|
||||
|
|
||||
def add_option( |
|
||||
self, |
|
||||
*, |
|
||||
label: str, |
|
||||
value: str = MISSING, |
|
||||
description: Optional[str] = None, |
|
||||
emoji: Optional[Union[str, Emoji, PartialEmoji]] = None, |
|
||||
default: bool = False, |
|
||||
): |
|
||||
"""Adds an option to the select menu. |
|
||||
|
|
||||
To append a pre-existing :class:`discord.SelectOption` use the |
|
||||
:meth:`append_option` method instead. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
label: :class:`str` |
|
||||
The label of the option. This is displayed to users. |
|
||||
Can only be up to 100 characters. |
|
||||
value: :class:`str` |
|
||||
The value of the option. This is not displayed to users. |
|
||||
If not given, defaults to the label. Can only be up to 100 characters. |
|
||||
description: Optional[:class:`str`] |
|
||||
An additional description of the option, if any. |
|
||||
Can only be up to 100 characters. |
|
||||
emoji: Optional[Union[:class:`str`, :class:`.Emoji`, :class:`.PartialEmoji`]] |
|
||||
The emoji of the option, if available. This can either be a string representing |
|
||||
the custom or unicode emoji or an instance of :class:`.PartialEmoji` or :class:`.Emoji`. |
|
||||
default: :class:`bool` |
|
||||
Whether this option is selected by default. |
|
||||
|
|
||||
Raises |
|
||||
------- |
|
||||
ValueError |
|
||||
The number of options exceeds 25. |
|
||||
""" |
|
||||
|
|
||||
option = SelectOption( |
|
||||
label=label, |
|
||||
value=value, |
|
||||
description=description, |
|
||||
emoji=emoji, |
|
||||
default=default, |
|
||||
) |
|
||||
|
|
||||
|
|
||||
self.append_option(option) |
|
||||
|
|
||||
def append_option(self, option: SelectOption): |
|
||||
"""Appends an option to the select menu. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
option: :class:`discord.SelectOption` |
|
||||
The option to append to the select menu. |
|
||||
|
|
||||
Raises |
|
||||
------- |
|
||||
ValueError |
|
||||
The number of options exceeds 25. |
|
||||
""" |
|
||||
|
|
||||
if len(self._underlying.options) > 25: |
|
||||
raise ValueError('maximum number of options already provided') |
|
||||
|
|
||||
self._underlying.options.append(option) |
|
||||
|
|
||||
@property |
|
||||
def disabled(self) -> bool: |
|
||||
""":class:`bool`: Whether the select is disabled or not.""" |
|
||||
return self._underlying.disabled |
|
||||
|
|
||||
@disabled.setter |
|
||||
def disabled(self, value: bool): |
|
||||
self._underlying.disabled = bool(value) |
|
||||
|
|
||||
@property |
|
||||
def values(self) -> List[str]: |
|
||||
"""List[:class:`str`]: A list of values that have been selected by the user.""" |
|
||||
return self._selected_values |
|
||||
|
|
||||
@property |
|
||||
def width(self) -> int: |
|
||||
return 5 |
|
||||
|
|
||||
def to_component_dict(self) -> SelectMenuPayload: |
|
||||
return self._underlying.to_dict() |
|
||||
|
|
||||
def refresh_component(self, component: SelectMenu) -> None: |
|
||||
self._underlying = component |
|
||||
|
|
||||
def refresh_state(self, interaction: Interaction) -> None: |
|
||||
data: ComponentInteractionData = interaction.data # type: ignore |
|
||||
self._selected_values = data.get('values', []) |
|
||||
|
|
||||
@classmethod |
|
||||
def from_component(cls: Type[S], component: SelectMenu) -> S: |
|
||||
return cls( |
|
||||
custom_id=component.custom_id, |
|
||||
placeholder=component.placeholder, |
|
||||
min_values=component.min_values, |
|
||||
max_values=component.max_values, |
|
||||
options=component.options, |
|
||||
disabled=component.disabled, |
|
||||
row=None, |
|
||||
) |
|
||||
|
|
||||
@property |
|
||||
def type(self) -> ComponentType: |
|
||||
return self._underlying.type |
|
||||
|
|
||||
def is_dispatchable(self) -> bool: |
|
||||
return True |
|
||||
|
|
||||
|
|
||||
def select( |
|
||||
*, |
|
||||
placeholder: Optional[str] = None, |
|
||||
custom_id: str = MISSING, |
|
||||
min_values: int = 1, |
|
||||
max_values: int = 1, |
|
||||
options: List[SelectOption] = MISSING, |
|
||||
disabled: bool = False, |
|
||||
row: Optional[int] = None, |
|
||||
) -> Callable[[ItemCallbackType], ItemCallbackType]: |
|
||||
"""A decorator that attaches a select menu to a component. |
|
||||
|
|
||||
The function being decorated should have three parameters, ``self`` representing |
|
||||
the :class:`discord.ui.View`, the :class:`discord.ui.Select` being pressed and |
|
||||
the :class:`discord.Interaction` you receive. |
|
||||
|
|
||||
In order to get the selected items that the user has chosen within the callback |
|
||||
use :attr:`Select.values`. |
|
||||
|
|
||||
Parameters |
|
||||
------------ |
|
||||
placeholder: Optional[:class:`str`] |
|
||||
The placeholder text that is shown if nothing is selected, if any. |
|
||||
custom_id: :class:`str` |
|
||||
The ID of the select menu that gets received during an interaction. |
|
||||
It is recommended not to set this parameter to prevent conflicts. |
|
||||
row: Optional[:class:`int`] |
|
||||
The relative row this select menu 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). |
|
||||
min_values: :class:`int` |
|
||||
The minimum number of items that must be chosen for this select menu. |
|
||||
Defaults to 1 and must be between 1 and 25. |
|
||||
max_values: :class:`int` |
|
||||
The maximum number of items that must be chosen for this select menu. |
|
||||
Defaults to 1 and must be between 1 and 25. |
|
||||
options: List[:class:`discord.SelectOption`] |
|
||||
A list of options that can be selected in this menu. |
|
||||
disabled: :class:`bool` |
|
||||
Whether the select is disabled or not. Defaults to ``False``. |
|
||||
""" |
|
||||
|
|
||||
def decorator(func: ItemCallbackType) -> ItemCallbackType: |
|
||||
if not inspect.iscoroutinefunction(func): |
|
||||
raise TypeError('select function must be a coroutine function') |
|
||||
|
|
||||
func.__discord_ui_model_type__ = Select |
|
||||
func.__discord_ui_model_kwargs__ = { |
|
||||
'placeholder': placeholder, |
|
||||
'custom_id': custom_id, |
|
||||
'row': row, |
|
||||
'min_values': min_values, |
|
||||
'max_values': max_values, |
|
||||
'options': options, |
|
||||
'disabled': disabled, |
|
||||
} |
|
||||
return func |
|
||||
|
|
||||
return decorator |
|
@ -1,529 +0,0 @@ |
|||||
""" |
|
||||
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 |
|
||||
from typing import Any, Callable, ClassVar, Dict, Iterator, List, Optional, Sequence, TYPE_CHECKING, Tuple |
|
||||
from functools import partial |
|
||||
from itertools import groupby |
|
||||
|
|
||||
import traceback |
|
||||
import asyncio |
|
||||
import sys |
|
||||
import time |
|
||||
import os |
|
||||
from .item import Item, ItemCallbackType |
|
||||
from ..components import ( |
|
||||
Component, |
|
||||
ActionRow as ActionRowComponent, |
|
||||
_component_factory, |
|
||||
Button as ButtonComponent, |
|
||||
SelectMenu as SelectComponent, |
|
||||
) |
|
||||
|
|
||||
__all__ = ( |
|
||||
'View', |
|
||||
) |
|
||||
|
|
||||
|
|
||||
if TYPE_CHECKING: |
|
||||
from ..interactions import Interaction |
|
||||
from ..message import Message |
|
||||
from ..types.components import Component as ComponentPayload |
|
||||
from ..state import ConnectionState |
|
||||
|
|
||||
|
|
||||
def _walk_all_components(components: List[Component]) -> Iterator[Component]: |
|
||||
for item in components: |
|
||||
if isinstance(item, ActionRowComponent): |
|
||||
yield from item.children |
|
||||
else: |
|
||||
yield item |
|
||||
|
|
||||
|
|
||||
def _component_to_item(component: Component) -> Item: |
|
||||
if isinstance(component, ButtonComponent): |
|
||||
from .button import Button |
|
||||
|
|
||||
return Button.from_component(component) |
|
||||
if isinstance(component, SelectComponent): |
|
||||
from .select import Select |
|
||||
|
|
||||
return Select.from_component(component) |
|
||||
return Item.from_component(component) |
|
||||
|
|
||||
|
|
||||
class _ViewWeights: |
|
||||
__slots__ = ( |
|
||||
'weights', |
|
||||
) |
|
||||
|
|
||||
def __init__(self, children: List[Item]): |
|
||||
self.weights: List[int] = [0, 0, 0, 0, 0] |
|
||||
|
|
||||
key = lambda i: sys.maxsize if i.row is None else i.row |
|
||||
children = sorted(children, key=key) |
|
||||
for row, group in groupby(children, key=key): |
|
||||
for item in group: |
|
||||
self.add_item(item) |
|
||||
|
|
||||
def find_open_space(self, item: Item) -> int: |
|
||||
for index, weight in enumerate(self.weights): |
|
||||
if weight + item.width <= 5: |
|
||||
return index |
|
||||
|
|
||||
raise ValueError('could not find open space for item') |
|
||||
|
|
||||
def add_item(self, item: Item) -> None: |
|
||||
if item.row is not None: |
|
||||
total = self.weights[item.row] + item.width |
|
||||
if total > 5: |
|
||||
raise ValueError(f'item would not fit at row {item.row} ({total} > 5 width)') |
|
||||
self.weights[item.row] = total |
|
||||
item._rendered_row = item.row |
|
||||
else: |
|
||||
index = self.find_open_space(item) |
|
||||
self.weights[index] += item.width |
|
||||
item._rendered_row = index |
|
||||
|
|
||||
def remove_item(self, item: Item) -> None: |
|
||||
if item._rendered_row is not None: |
|
||||
self.weights[item._rendered_row] -= item.width |
|
||||
item._rendered_row = None |
|
||||
|
|
||||
def clear(self) -> None: |
|
||||
self.weights = [0, 0, 0, 0, 0] |
|
||||
|
|
||||
|
|
||||
class View: |
|
||||
"""Represents a UI view. |
|
||||
|
|
||||
This object must be inherited to create a UI within Discord. |
|
||||
|
|
||||
.. versionadded:: 2.0 |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
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. |
|
||||
|
|
||||
Attributes |
|
||||
------------ |
|
||||
timeout: Optional[:class:`float`] |
|
||||
Timeout from last interaction with the UI before no longer accepting input. |
|
||||
If ``None`` then there is no timeout. |
|
||||
children: List[:class:`Item`] |
|
||||
The list of children attached to this view. |
|
||||
""" |
|
||||
|
|
||||
__discord_ui_view__: ClassVar[bool] = True |
|
||||
__view_children_items__: ClassVar[List[ItemCallbackType]] = [] |
|
||||
|
|
||||
def __init_subclass__(cls) -> None: |
|
||||
children: List[ItemCallbackType] = [] |
|
||||
for base in reversed(cls.__mro__): |
|
||||
for member in base.__dict__.values(): |
|
||||
if hasattr(member, '__discord_ui_model_type__'): |
|
||||
children.append(member) |
|
||||
|
|
||||
if len(children) > 25: |
|
||||
raise TypeError('View cannot have more than 25 children') |
|
||||
|
|
||||
cls.__view_children_items__ = children |
|
||||
|
|
||||
def __init__(self, *, timeout: Optional[float] = 180.0): |
|
||||
self.timeout = timeout |
|
||||
self.children: List[Item] = [] |
|
||||
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._view = self |
|
||||
setattr(self, func.__name__, item) |
|
||||
self.children.append(item) |
|
||||
|
|
||||
self.__weights = _ViewWeights(self.children) |
|
||||
loop = asyncio.get_running_loop() |
|
||||
self.id: str = os.urandom(16).hex() |
|
||||
self.__cancel_callback: Optional[Callable[[View], None]] = None |
|
||||
self.__timeout_expiry: Optional[float] = None |
|
||||
self.__timeout_task: Optional[asyncio.Task[None]] = None |
|
||||
self.__stopped: asyncio.Future[bool] = loop.create_future() |
|
||||
|
|
||||
def __repr__(self) -> str: |
|
||||
return f'<{self.__class__.__name__} timeout={self.timeout} children={len(self.children)}>' |
|
||||
|
|
||||
async def __timeout_task_impl(self) -> None: |
|
||||
while True: |
|
||||
# Guard just in case someone changes the value of the timeout at runtime |
|
||||
if self.timeout is None: |
|
||||
return |
|
||||
|
|
||||
if self.__timeout_expiry is None: |
|
||||
return self._dispatch_timeout() |
|
||||
|
|
||||
# Check if we've elapsed our currently set timeout |
|
||||
now = time.monotonic() |
|
||||
if now >= self.__timeout_expiry: |
|
||||
return self._dispatch_timeout() |
|
||||
|
|
||||
# Wait N seconds to see if timeout data has been refreshed |
|
||||
await asyncio.sleep(self.__timeout_expiry - now) |
|
||||
|
|
||||
def to_components(self) -> List[Dict[str, Any]]: |
|
||||
def key(item: Item) -> int: |
|
||||
return item._rendered_row or 0 |
|
||||
|
|
||||
children = sorted(self.children, key=key) |
|
||||
components: List[Dict[str, Any]] = [] |
|
||||
for _, group in groupby(children, key=key): |
|
||||
children = [item.to_component_dict() for item in group] |
|
||||
if not children: |
|
||||
continue |
|
||||
|
|
||||
components.append( |
|
||||
{ |
|
||||
'type': 1, |
|
||||
'components': children, |
|
||||
} |
|
||||
) |
|
||||
|
|
||||
return components |
|
||||
|
|
||||
@classmethod |
|
||||
def from_message(cls, message: Message, /, *, timeout: Optional[float] = 180.0) -> View: |
|
||||
"""Converts a message's components into a :class:`View`. |
|
||||
|
|
||||
The :attr:`.Message.components` of a message are read-only |
|
||||
and separate types from those in the ``discord.ui`` namespace. |
|
||||
In order to modify and edit message components they must be |
|
||||
converted into a :class:`View` first. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
message: :class:`discord.Message` |
|
||||
The message with components to convert into a view. |
|
||||
timeout: Optional[:class:`float`] |
|
||||
The timeout of the converted view. |
|
||||
|
|
||||
Returns |
|
||||
-------- |
|
||||
:class:`View` |
|
||||
The converted view. This always returns a :class:`View` and not |
|
||||
one of its subclasses. |
|
||||
""" |
|
||||
view = View(timeout=timeout) |
|
||||
for component in _walk_all_components(message.components): |
|
||||
view.add_item(_component_to_item(component)) |
|
||||
return view |
|
||||
|
|
||||
@property |
|
||||
def _expires_at(self) -> Optional[float]: |
|
||||
if self.timeout: |
|
||||
return time.monotonic() + self.timeout |
|
||||
return None |
|
||||
|
|
||||
def add_item(self, item: Item) -> None: |
|
||||
"""Adds an item to the view. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
item: :class:`Item` |
|
||||
The item to add to the view. |
|
||||
|
|
||||
Raises |
|
||||
-------- |
|
||||
TypeError |
|
||||
An :class:`Item` was not passed. |
|
||||
ValueError |
|
||||
Maximum number of children has been exceeded (25) |
|
||||
or the row the item is trying to be added to is full. |
|
||||
""" |
|
||||
|
|
||||
if len(self.children) > 25: |
|
||||
raise ValueError('maximum number of children exceeded') |
|
||||
|
|
||||
if not isinstance(item, Item): |
|
||||
raise TypeError(f'expected Item not {item.__class__!r}') |
|
||||
|
|
||||
self.__weights.add_item(item) |
|
||||
|
|
||||
item._view = self |
|
||||
self.children.append(item) |
|
||||
|
|
||||
def remove_item(self, item: Item) -> None: |
|
||||
"""Removes an item from the view. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
item: :class:`Item` |
|
||||
The item to remove from the view. |
|
||||
""" |
|
||||
|
|
||||
try: |
|
||||
self.children.remove(item) |
|
||||
except ValueError: |
|
||||
pass |
|
||||
else: |
|
||||
self.__weights.remove_item(item) |
|
||||
|
|
||||
def clear_items(self) -> None: |
|
||||
"""Removes all items from the view.""" |
|
||||
self.children.clear() |
|
||||
self.__weights.clear() |
|
||||
|
|
||||
async def interaction_check(self, interaction: Interaction) -> bool: |
|
||||
"""|coro| |
|
||||
|
|
||||
A callback that is called when an interaction happens within the view |
|
||||
that checks whether the view should process item callbacks for the interaction. |
|
||||
|
|
||||
This is useful to override if, for example, you want to ensure that the |
|
||||
interaction author is a given user. |
|
||||
|
|
||||
The default implementation of this returns ``True``. |
|
||||
|
|
||||
.. note:: |
|
||||
|
|
||||
If an exception occurs within the body then the check |
|
||||
is considered a failure and :meth:`on_error` is called. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
interaction: :class:`~discord.Interaction` |
|
||||
The interaction that occurred. |
|
||||
|
|
||||
Returns |
|
||||
--------- |
|
||||
:class:`bool` |
|
||||
Whether the view children's callbacks should be called. |
|
||||
""" |
|
||||
return True |
|
||||
|
|
||||
async def on_timeout(self) -> None: |
|
||||
"""|coro| |
|
||||
|
|
||||
A callback that is called when a view's timeout elapses without being explicitly stopped. |
|
||||
""" |
|
||||
pass |
|
||||
|
|
||||
async def on_error(self, error: Exception, item: Item, interaction: Interaction) -> None: |
|
||||
"""|coro| |
|
||||
|
|
||||
A callback that is called when an item's callback or :meth:`interaction_check` |
|
||||
fails with an error. |
|
||||
|
|
||||
The default implementation prints the traceback to stderr. |
|
||||
|
|
||||
Parameters |
|
||||
----------- |
|
||||
error: :class:`Exception` |
|
||||
The exception that was raised. |
|
||||
item: :class:`Item` |
|
||||
The item that failed the dispatch. |
|
||||
interaction: :class:`~discord.Interaction` |
|
||||
The interaction that led to the failure. |
|
||||
""" |
|
||||
print(f'Ignoring exception in view {self} for item {item}:', file=sys.stderr) |
|
||||
traceback.print_exception(error.__class__, error, error.__traceback__, file=sys.stderr) |
|
||||
|
|
||||
async def _scheduled_task(self, item: Item, interaction: Interaction): |
|
||||
try: |
|
||||
if self.timeout: |
|
||||
self.__timeout_expiry = time.monotonic() + self.timeout |
|
||||
|
|
||||
allow = await self.interaction_check(interaction) |
|
||||
if not allow: |
|
||||
return |
|
||||
|
|
||||
await item.callback(interaction) |
|
||||
if not interaction.response._responded: |
|
||||
await interaction.response.defer() |
|
||||
except Exception as e: |
|
||||
return await self.on_error(e, item, interaction) |
|
||||
|
|
||||
def _start_listening_from_store(self, store: ViewStore) -> None: |
|
||||
self.__cancel_callback = partial(store.remove_view) |
|
||||
if self.timeout: |
|
||||
loop = asyncio.get_running_loop() |
|
||||
if self.__timeout_task is not None: |
|
||||
self.__timeout_task.cancel() |
|
||||
|
|
||||
self.__timeout_expiry = time.monotonic() + self.timeout |
|
||||
self.__timeout_task = loop.create_task(self.__timeout_task_impl()) |
|
||||
|
|
||||
def _dispatch_timeout(self): |
|
||||
if self.__stopped.done(): |
|
||||
return |
|
||||
|
|
||||
self.__stopped.set_result(True) |
|
||||
asyncio.create_task(self.on_timeout(), name=f'discord-ui-view-timeout-{self.id}') |
|
||||
|
|
||||
def _dispatch_item(self, item: Item, interaction: Interaction): |
|
||||
if self.__stopped.done(): |
|
||||
return |
|
||||
|
|
||||
asyncio.create_task(self._scheduled_task(item, interaction), name=f'discord-ui-view-dispatch-{self.id}') |
|
||||
|
|
||||
def refresh(self, components: List[Component]): |
|
||||
# This is pretty hacky at the moment |
|
||||
# fmt: off |
|
||||
old_state: Dict[Tuple[int, str], Item] = { |
|
||||
(item.type.value, item.custom_id): item # type: ignore |
|
||||
for item in self.children |
|
||||
if item.is_dispatchable() |
|
||||
} |
|
||||
# fmt: on |
|
||||
children: List[Item] = [] |
|
||||
for component in _walk_all_components(components): |
|
||||
try: |
|
||||
older = old_state[(component.type.value, component.custom_id)] # type: ignore |
|
||||
except (KeyError, AttributeError): |
|
||||
children.append(_component_to_item(component)) |
|
||||
else: |
|
||||
older.refresh_component(component) |
|
||||
children.append(older) |
|
||||
|
|
||||
self.children = children |
|
||||
|
|
||||
def stop(self) -> None: |
|
||||
"""Stops listening to interaction events from this view. |
|
||||
|
|
||||
This operation cannot be undone. |
|
||||
""" |
|
||||
if not self.__stopped.done(): |
|
||||
self.__stopped.set_result(False) |
|
||||
|
|
||||
self.__timeout_expiry = None |
|
||||
if self.__timeout_task is not None: |
|
||||
self.__timeout_task.cancel() |
|
||||
self.__timeout_task = None |
|
||||
|
|
||||
if self.__cancel_callback: |
|
||||
self.__cancel_callback(self) |
|
||||
self.__cancel_callback = None |
|
||||
|
|
||||
def is_finished(self) -> bool: |
|
||||
""":class:`bool`: Whether the view has finished interacting.""" |
|
||||
return self.__stopped.done() |
|
||||
|
|
||||
def is_dispatching(self) -> bool: |
|
||||
""":class:`bool`: Whether the view has been added for dispatching purposes.""" |
|
||||
return self.__cancel_callback is not None |
|
||||
|
|
||||
def is_persistent(self) -> bool: |
|
||||
""":class:`bool`: Whether the view is set up as persistent. |
|
||||
|
|
||||
A persistent view has all their components with a set ``custom_id`` and |
|
||||
a :attr:`timeout` set to ``None``. |
|
||||
""" |
|
||||
return self.timeout is None and all(item.is_persistent() for item in self.children) |
|
||||
|
|
||||
async def wait(self) -> bool: |
|
||||
"""Waits until the view has finished interacting. |
|
||||
|
|
||||
A view is considered finished when :meth:`stop` is called |
|
||||
or it times out. |
|
||||
|
|
||||
Returns |
|
||||
-------- |
|
||||
:class:`bool` |
|
||||
If ``True``, then the view timed out. If ``False`` then |
|
||||
the view finished normally. |
|
||||
""" |
|
||||
return await self.__stopped |
|
||||
|
|
||||
|
|
||||
class ViewStore: |
|
||||
def __init__(self, state: ConnectionState): |
|
||||
# (component_type, message_id, custom_id): (View, Item) |
|
||||
self._views: Dict[Tuple[int, Optional[int], str], Tuple[View, Item]] = {} |
|
||||
# message_id: View |
|
||||
self._synced_message_views: Dict[int, View] = {} |
|
||||
self._state: ConnectionState = state |
|
||||
|
|
||||
@property |
|
||||
def persistent_views(self) -> Sequence[View]: |
|
||||
# fmt: off |
|
||||
views = { |
|
||||
view.id: view |
|
||||
for (_, (view, _)) in self._views.items() |
|
||||
if view.is_persistent() |
|
||||
} |
|
||||
# fmt: on |
|
||||
return list(views.values()) |
|
||||
|
|
||||
def __verify_integrity(self): |
|
||||
to_remove: List[Tuple[int, Optional[int], str]] = [] |
|
||||
for (k, (view, _)) in self._views.items(): |
|
||||
if view.is_finished(): |
|
||||
to_remove.append(k) |
|
||||
|
|
||||
for k in to_remove: |
|
||||
del self._views[k] |
|
||||
|
|
||||
def add_view(self, view: View, message_id: Optional[int] = None): |
|
||||
self.__verify_integrity() |
|
||||
|
|
||||
view._start_listening_from_store(self) |
|
||||
for item in view.children: |
|
||||
if item.is_dispatchable(): |
|
||||
self._views[(item.type.value, message_id, item.custom_id)] = (view, item) # type: ignore |
|
||||
|
|
||||
if message_id is not None: |
|
||||
self._synced_message_views[message_id] = view |
|
||||
|
|
||||
def remove_view(self, view: View): |
|
||||
for item in view.children: |
|
||||
if item.is_dispatchable(): |
|
||||
self._views.pop((item.type.value, item.custom_id), None) # type: ignore |
|
||||
|
|
||||
for key, value in self._synced_message_views.items(): |
|
||||
if value.id == view.id: |
|
||||
del self._synced_message_views[key] |
|
||||
break |
|
||||
|
|
||||
def dispatch(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) |
|
||||
# Fallback to None message_id searches in case a persistent view |
|
||||
# was added without an associated message_id |
|
||||
value = self._views.get(key) or self._views.get((component_type, None, custom_id)) |
|
||||
if value is None: |
|
||||
return |
|
||||
|
|
||||
view, item = value |
|
||||
item.refresh_state(interaction) |
|
||||
view._dispatch_item(item, interaction) |
|
||||
|
|
||||
def is_message_tracked(self, message_id: int): |
|
||||
return message_id in self._synced_message_views |
|
||||
|
|
||||
def remove_message_tracking(self, message_id: int) -> Optional[View]: |
|
||||
return self._synced_message_views.pop(message_id, None) |
|
||||
|
|
||||
def update_from_message(self, message_id: int, components: List[ComponentPayload]): |
|
||||
# pre-req: is_message_tracked == true |
|
||||
view = self._synced_message_views[message_id] |
|
||||
view.refresh([_component_factory(d) for d in components]) |
|
Loading…
Reference in new issue