17 changed files with 1195 additions and 14 deletions
@ -0,0 +1,158 @@ |
|||||
|
""" |
||||
|
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, Type, TypeVar |
||||
|
from .enums import try_enum, ComponentType, ButtonStyle |
||||
|
from .partial_emoji import PartialEmoji |
||||
|
|
||||
|
if TYPE_CHECKING: |
||||
|
from .types.components import ( |
||||
|
Component as ComponentPayload, |
||||
|
ButtonComponent as ButtonComponentPayload, |
||||
|
ComponentContainer as ComponentContainerPayload, |
||||
|
) |
||||
|
|
||||
|
|
||||
|
__all__ = ( |
||||
|
'Component', |
||||
|
'Button', |
||||
|
) |
||||
|
|
||||
|
C = TypeVar('C', bound='Component') |
||||
|
|
||||
|
class Component: |
||||
|
"""Represents a Discord Bot UI Kit Component. |
||||
|
|
||||
|
Currently, the only components supported by Discord are buttons and button groups. |
||||
|
|
||||
|
.. versionadded:: 2.0 |
||||
|
|
||||
|
Attributes |
||||
|
------------ |
||||
|
type: :class:`ComponentType` |
||||
|
The type of component. |
||||
|
children: List[:class:`Component`] |
||||
|
The children components that this holds, if any. |
||||
|
""" |
||||
|
|
||||
|
__slots__: Tuple[str, ...] = ( |
||||
|
'type', |
||||
|
'children', |
||||
|
) |
||||
|
|
||||
|
def __init__(self, data: ComponentPayload): |
||||
|
self.type: ComponentType = try_enum(ComponentType, data['type']) |
||||
|
self.children: List[Component] = [_component_factory(d) for d in data.get('components', [])] |
||||
|
|
||||
|
def __repr__(self) -> str: |
||||
|
attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__slots__) |
||||
|
return f'<{self.__class__.__name__} type={self.type!r} {attrs}>' |
||||
|
|
||||
|
def to_dict(self) -> ComponentContainerPayload: |
||||
|
return { |
||||
|
'type': int(self.type), |
||||
|
'components': [child.to_dict() for child in self.children], |
||||
|
} # type: ignore |
||||
|
|
||||
|
|
||||
|
@classmethod |
||||
|
def _raw_construct(cls: Type[C], **kwargs) -> C: |
||||
|
self: C = cls.__new__(cls) |
||||
|
slots = cls.__slots__ |
||||
|
for attr, value in kwargs.items(): |
||||
|
if attr in slots: |
||||
|
setattr(self, attr, value) |
||||
|
return self |
||||
|
|
||||
|
|
||||
|
class Button(Component): |
||||
|
"""Represents a button from the Discord Bot UI Kit. |
||||
|
|
||||
|
This inherits from :class:`Component`. |
||||
|
|
||||
|
.. versionadded:: 2.0 |
||||
|
|
||||
|
Attributes |
||||
|
----------- |
||||
|
style: :class:`ComponentButtonStyle` |
||||
|
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: :class:`str` |
||||
|
The label of the button. |
||||
|
emoji: Optional[:class:`PartialEmoji`] |
||||
|
The emoji of the button, if available. |
||||
|
""" |
||||
|
|
||||
|
__slots__: Tuple[str, ...] = Component.__slots__ + ( |
||||
|
'style', |
||||
|
'custom_id', |
||||
|
'url', |
||||
|
'disabled', |
||||
|
'label', |
||||
|
'emoji', |
||||
|
) |
||||
|
|
||||
|
def __init__(self, data: ButtonComponentPayload): |
||||
|
self.type: ComponentType = try_enum(ComponentType, data['type']) |
||||
|
self.style: ButtonStyle = try_enum(ButtonStyle, data['style']) |
||||
|
self.custom_id: Optional[str] = data.get('custom_id') |
||||
|
self.url: Optional[str] = data.get('url') |
||||
|
self.disabled: bool = data.get('disabled', False) |
||||
|
self.label: str = data['label'] |
||||
|
self.emoji: Optional[PartialEmoji] |
||||
|
try: |
||||
|
self.emoji = PartialEmoji.from_dict(data['emoji']) |
||||
|
except KeyError: |
||||
|
self.emoji = None |
||||
|
|
||||
|
def to_dict(self) -> ButtonComponentPayload: |
||||
|
payload = { |
||||
|
'type': 2, |
||||
|
'style': int(self.style), |
||||
|
'label': self.label, |
||||
|
'disabled': self.disabled, |
||||
|
} |
||||
|
if self.custom_id: |
||||
|
payload['custom_id'] = self.custom_id |
||||
|
if self.url: |
||||
|
payload['url'] = self.url |
||||
|
|
||||
|
return payload # type: ignore |
||||
|
|
||||
|
def _component_factory(data: ComponentPayload) -> Component: |
||||
|
component_type = data['type'] |
||||
|
if component_type == 1: |
||||
|
return Component(data) |
||||
|
elif component_type == 2: |
||||
|
return Button(data) # type: ignore |
||||
|
else: |
||||
|
return Component(data) |
@ -0,0 +1,52 @@ |
|||||
|
""" |
||||
|
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 Literal, TypedDict, Union |
||||
|
from .emoji import PartialEmoji |
||||
|
|
||||
|
ComponentType = Literal[1, 2] |
||||
|
ButtonStyle = Literal[1, 2, 3, 4, 5] |
||||
|
|
||||
|
|
||||
|
class ComponentContainer(TypedDict): |
||||
|
type: Literal[1] |
||||
|
components: Component |
||||
|
|
||||
|
|
||||
|
class _ButtonComponentOptional(TypedDict, total=False): |
||||
|
custom_id: str |
||||
|
url: str |
||||
|
disabled: bool |
||||
|
emoji: PartialEmoji |
||||
|
|
||||
|
|
||||
|
class ButtonComponent(_ButtonComponentOptional): |
||||
|
type: Literal[2] |
||||
|
style: ButtonStyle |
||||
|
label: str |
||||
|
|
||||
|
|
||||
|
Component = Union[ComponentContainer, ButtonComponent] |
@ -0,0 +1,14 @@ |
|||||
|
""" |
||||
|
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 * |
@ -0,0 +1,288 @@ |
|||||
|
""" |
||||
|
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 re |
||||
|
import os |
||||
|
|
||||
|
|
||||
|
from .item import Item, ItemCallbackType |
||||
|
from ..enums import ButtonStyle, ComponentType |
||||
|
from ..partial_emoji import PartialEmoji |
||||
|
from ..components import Button as ButtonComponent |
||||
|
|
||||
|
__all__ = ( |
||||
|
'Button', |
||||
|
'button', |
||||
|
) |
||||
|
|
||||
|
if TYPE_CHECKING: |
||||
|
from ..components import Component |
||||
|
|
||||
|
_custom_emoji = re.compile(r'<?(?P<animated>a)?:?(?P<name>[A-Za-z0-9\_]+):(?P<id>[0-9]{13,20})>?') |
||||
|
|
||||
|
|
||||
|
def _to_partial_emoji(obj: Union[str, PartialEmoji], *, _custom_emoji=_custom_emoji) -> PartialEmoji: |
||||
|
if isinstance(obj, PartialEmoji): |
||||
|
return obj |
||||
|
|
||||
|
obj = str(obj) |
||||
|
match = _custom_emoji.match(obj) |
||||
|
if match is not None: |
||||
|
groups = match.groupdict() |
||||
|
animated = bool(groups['animated']) |
||||
|
emoji_id = int(groups['id']) |
||||
|
name = groups['name'] |
||||
|
return PartialEmoji(name=name, animated=animated, id=emoji_id) |
||||
|
|
||||
|
return PartialEmoji(name=obj, id=None, animated=False) |
||||
|
|
||||
|
|
||||
|
B = TypeVar('B', bound='Button') |
||||
|
|
||||
|
|
||||
|
class Button(Item): |
||||
|
"""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: :class:`str` |
||||
|
The label of the button. |
||||
|
emoji: Optional[:class:`PartialEmoji`] |
||||
|
The emoji of the button, if available. |
||||
|
""" |
||||
|
|
||||
|
__slots__: Tuple[str, ...] = Item.__slots__ + ('_underlying',) |
||||
|
|
||||
|
__item_repr_attributes__: Tuple[str, ...] = ( |
||||
|
'style', |
||||
|
'url', |
||||
|
'disabled', |
||||
|
'label', |
||||
|
'emoji', |
||||
|
'group_id', |
||||
|
) |
||||
|
|
||||
|
def __init__( |
||||
|
self, |
||||
|
*, |
||||
|
style: ButtonStyle, |
||||
|
label: str, |
||||
|
disabled: bool = False, |
||||
|
custom_id: Optional[str] = None, |
||||
|
url: Optional[str] = None, |
||||
|
emoji: Optional[Union[str, PartialEmoji]] = None, |
||||
|
group: 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') |
||||
|
|
||||
|
if url is None and custom_id is None: |
||||
|
custom_id = os.urandom(16).hex() |
||||
|
|
||||
|
self._underlying = ButtonComponent._raw_construct( |
||||
|
type=ComponentType.button, |
||||
|
custom_id=custom_id, |
||||
|
url=url, |
||||
|
disabled=disabled, |
||||
|
label=label, |
||||
|
style=style, |
||||
|
emoji=None if emoji is None else _to_partial_emoji(emoji), |
||||
|
) |
||||
|
self.group_id = group |
||||
|
|
||||
|
@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) -> str: |
||||
|
""":class:`str`: The label of the button.""" |
||||
|
return self._underlying.label |
||||
|
|
||||
|
@label.setter |
||||
|
def label(self, value: str): |
||||
|
self._underlying.label = str(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, PartialEmoji]]): # type: ignore |
||||
|
if value is not None: |
||||
|
self._underlying.emoji = _to_partial_emoji(value) |
||||
|
else: |
||||
|
self._underlying.emoji = None |
||||
|
|
||||
|
def copy(self: B) -> B: |
||||
|
button = self.__class__( |
||||
|
style=self.style, |
||||
|
label=self.label, |
||||
|
disabled=self.disabled, |
||||
|
custom_id=self.custom_id, |
||||
|
url=self.url, |
||||
|
emoji=self.emoji, |
||||
|
group=self.group_id, |
||||
|
) |
||||
|
button.callback = self.callback |
||||
|
return button |
||||
|
|
||||
|
@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, |
||||
|
group=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 True |
||||
|
|
||||
|
def refresh_state(self, button: ButtonComponent) -> None: |
||||
|
self._underlying = button |
||||
|
|
||||
|
|
||||
|
def button( |
||||
|
label: str, |
||||
|
*, |
||||
|
custom_id: Optional[str] = None, |
||||
|
disabled: bool = False, |
||||
|
style: ButtonStyle = ButtonStyle.grey, |
||||
|
emoji: Optional[Union[str, PartialEmoji]] = None, |
||||
|
group: Optional[int] = None, |
||||
|
) -> Callable[[ItemCallbackType], Button]: |
||||
|
"""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: :class:`str` |
||||
|
The label of the button. |
||||
|
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:`PartialEmoji`]] |
||||
|
The emoji of the button. This can be in string form or a :class:`PartialEmoji`. |
||||
|
group: Optional[:class:`int`] |
||||
|
The relative group this button belongs to. A Discord component can only have 5 |
||||
|
groups. By default, items are arranged automatically into those 5 groups. If you'd |
||||
|
like to control the relative positioning of the group then passing an index is advised. |
||||
|
For example, group=1 will show up before group=2. Defaults to ``None``, which is automatic |
||||
|
ordering. |
||||
|
""" |
||||
|
|
||||
|
def decorator(func: ItemCallbackType) -> Button: |
||||
|
nonlocal custom_id |
||||
|
if not inspect.iscoroutinefunction(func): |
||||
|
raise TypeError('button function must be a coroutine function') |
||||
|
|
||||
|
custom_id = custom_id or os.urandom(32).hex() |
||||
|
button = Button(style=style, custom_id=custom_id, url=None, disabled=disabled, label=label, emoji=emoji, group=group) |
||||
|
button.callback = func |
||||
|
return button |
||||
|
|
||||
|
return decorator |
@ -0,0 +1,140 @@ |
|||||
|
""" |
||||
|
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, Optional, TYPE_CHECKING, Tuple, Type, TypeVar, Union |
||||
|
import inspect |
||||
|
|
||||
|
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') |
||||
|
ItemCallbackType = Callable[[Any, I, Interaction], Coroutine[Any, Any, Any]] |
||||
|
|
||||
|
|
||||
|
class Item: |
||||
|
"""Represents the base UI item that all UI components inherit from. |
||||
|
|
||||
|
The current UI items supported are: |
||||
|
|
||||
|
- :class:`discord.ui.Button` |
||||
|
""" |
||||
|
|
||||
|
__slots__: Tuple[str, ...] = ( |
||||
|
'_callback', |
||||
|
'_pass_view_arg', |
||||
|
'group_id', |
||||
|
) |
||||
|
|
||||
|
__item_repr_attributes__: Tuple[str, ...] = ('group_id',) |
||||
|
|
||||
|
def __init__(self): |
||||
|
self._callback: Optional[ItemCallbackType] = None |
||||
|
self._pass_view_arg = True |
||||
|
self.group_id: Optional[int] = None |
||||
|
|
||||
|
def to_component_dict(self) -> Dict[str, Any]: |
||||
|
raise NotImplementedError |
||||
|
|
||||
|
def copy(self: I) -> I: |
||||
|
raise NotImplementedError |
||||
|
|
||||
|
def refresh_state(self, component: Component) -> 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 __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 callback(self) -> Optional[ItemCallbackType]: |
||||
|
"""Returns the underlying callback associated with this interaction.""" |
||||
|
return self._callback |
||||
|
|
||||
|
@callback.setter |
||||
|
def callback(self, value: Optional[ItemCallbackType]): |
||||
|
if value is None: |
||||
|
self._callback = None |
||||
|
return |
||||
|
|
||||
|
# Check if it's a partial function |
||||
|
try: |
||||
|
partial = value.func |
||||
|
except AttributeError: |
||||
|
pass |
||||
|
else: |
||||
|
if not inspect.iscoroutinefunction(value.func): |
||||
|
raise TypeError(f'inner partial function must be a coroutine') |
||||
|
|
||||
|
# Check if the partial is bound |
||||
|
try: |
||||
|
bound_partial = partial.__self__ |
||||
|
except AttributeError: |
||||
|
pass |
||||
|
else: |
||||
|
self._pass_view_arg = not hasattr(bound_partial, '__discord_ui_view__') |
||||
|
|
||||
|
self._callback = value |
||||
|
return |
||||
|
|
||||
|
try: |
||||
|
func_self = value.__self__ |
||||
|
except AttributeError: |
||||
|
pass |
||||
|
else: |
||||
|
if not isinstance(func_self, Item): |
||||
|
raise TypeError(f'callback bound method must be from Item not {func_self!r}') |
||||
|
else: |
||||
|
value = value.__func__ |
||||
|
|
||||
|
if not inspect.iscoroutinefunction(value): |
||||
|
raise TypeError(f'callback must be a coroutine not {value!r}') |
||||
|
|
||||
|
self._callback = value |
||||
|
|
||||
|
async def _do_call(self, view: View, interaction: Interaction): |
||||
|
if self._pass_view_arg: |
||||
|
await self._callback(view, self, interaction) |
||||
|
else: |
||||
|
await self._callback(self, interaction) # type: ignore |
@ -0,0 +1,270 @@ |
|||||
|
""" |
||||
|
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, TYPE_CHECKING, Tuple |
||||
|
from functools import partial |
||||
|
from itertools import groupby |
||||
|
|
||||
|
import asyncio |
||||
|
import sys |
||||
|
import time |
||||
|
import os |
||||
|
from .item import Item |
||||
|
from ..enums import ComponentType |
||||
|
from ..components import ( |
||||
|
Component, |
||||
|
_component_factory, |
||||
|
Button as ButtonComponent, |
||||
|
) |
||||
|
|
||||
|
__all__ = ( |
||||
|
'View', |
||||
|
) |
||||
|
|
||||
|
|
||||
|
if TYPE_CHECKING: |
||||
|
from ..interactions import Interaction |
||||
|
from ..types.components import Component as ComponentPayload |
||||
|
|
||||
|
|
||||
|
def _walk_all_components(components: List[Component]) -> Iterator[Component]: |
||||
|
for item in components: |
||||
|
if item.type is ComponentType.group: |
||||
|
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) |
||||
|
return Item.from_component(component) |
||||
|
|
||||
|
|
||||
|
class View: |
||||
|
"""Represents a UI view. |
||||
|
|
||||
|
This object must be inherited to create a UI within Discord. |
||||
|
|
||||
|
Parameters |
||||
|
----------- |
||||
|
timeout: Optional[:class:`float`] |
||||
|
Timeout 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. |
||||
|
""" |
||||
|
|
||||
|
__slots__ = ( |
||||
|
'timeout', |
||||
|
'children', |
||||
|
'id', |
||||
|
'_cancel_callback', |
||||
|
) |
||||
|
|
||||
|
__discord_ui_view__: ClassVar[bool] = True |
||||
|
|
||||
|
if TYPE_CHECKING: |
||||
|
__view_children_items__: ClassVar[List[Item]] |
||||
|
|
||||
|
def __init_subclass__(cls) -> None: |
||||
|
children: List[Item] = [] |
||||
|
for base in reversed(cls.__mro__): |
||||
|
for member in base.__dict__.values(): |
||||
|
if isinstance(member, Item): |
||||
|
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] = [i.copy() for i in self.__view_children_items__] |
||||
|
self.id = os.urandom(16).hex() |
||||
|
self._cancel_callback: Optional[Callable[[View], None]] = None |
||||
|
|
||||
|
def to_components(self) -> List[Dict[str, Any]]: |
||||
|
def key(item: Item) -> int: |
||||
|
if item.group_id is None: |
||||
|
return sys.maxsize |
||||
|
return item.group_id |
||||
|
|
||||
|
children = sorted(self.children, key=key) |
||||
|
components: List[Dict[str, Any]] = [] |
||||
|
for _, group in groupby(children, key=key): |
||||
|
group = list(group) |
||||
|
if len(group) <= 5: |
||||
|
components.append( |
||||
|
{ |
||||
|
'type': 1, |
||||
|
'components': [item.to_component_dict() for item in group], |
||||
|
} |
||||
|
) |
||||
|
else: |
||||
|
components.extend( |
||||
|
{ |
||||
|
'type': 1, |
||||
|
'components': [item.to_component_dict() for item in group[index : index + 5]], |
||||
|
} |
||||
|
for index in range(0, len(group), 5) |
||||
|
) |
||||
|
|
||||
|
return components |
||||
|
|
||||
|
@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 |
||||
|
A :class:`Item` was not passed. |
||||
|
ValueError |
||||
|
Maximum number of children has been exceeded (25). |
||||
|
""" |
||||
|
|
||||
|
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.children.append(item) |
||||
|
|
||||
|
async def _scheduled_task(self, state: Any, item: Item, interaction: Interaction): |
||||
|
await state.http.create_interaction_response(interaction.id, interaction.token, type=6) |
||||
|
await item._do_call(self, interaction) |
||||
|
|
||||
|
def dispatch(self, state: Any, item: Item, interaction: Interaction): |
||||
|
asyncio.create_task(self._scheduled_task(state, 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_state(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 self._cancel_callback: |
||||
|
self._cancel_callback(self) |
||||
|
|
||||
|
|
||||
|
class ViewStore: |
||||
|
def __init__(self, state): |
||||
|
# (component_type, custom_id): (View, Item, Expiry) |
||||
|
self._views: Dict[Tuple[int, str], Tuple[View, Item, Optional[float]]] = {} |
||||
|
# message_id: View |
||||
|
self._synced_message_views: Dict[int, View] = {} |
||||
|
self._state = state |
||||
|
|
||||
|
def __verify_integrity(self): |
||||
|
to_remove: List[Tuple[int, str]] = [] |
||||
|
now = time.monotonic() |
||||
|
for (k, (_, _, expiry)) in self._views.items(): |
||||
|
if expiry is not None and now >= expiry: |
||||
|
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() |
||||
|
|
||||
|
expiry = view._expires_at |
||||
|
view._cancel_callback = partial(self.remove_view) |
||||
|
for item in view.children: |
||||
|
if item.is_dispatchable(): |
||||
|
self._views[(item.type.value, item.custom_id)] = (view, item, expiry) # 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)) # 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() |
||||
|
key = (component_type, custom_id) |
||||
|
value = self._views.get(key) |
||||
|
if value is None: |
||||
|
return |
||||
|
|
||||
|
view, item, _ = value |
||||
|
self._views[key] = (view, item, view._expires_at) |
||||
|
view.dispatch(self._state, item, interaction) |
||||
|
|
||||
|
def is_message_tracked(self, message_id: int): |
||||
|
return message_id in self._synced_message_views |
||||
|
|
||||
|
def update_view(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