From ef9f61a93301084bd41d7c833152d44dfc04a7d3 Mon Sep 17 00:00:00 2001 From: Rapptz Date: Fri, 28 May 2021 05:33:06 -0400 Subject: [PATCH] Add support for select components --- discord/state.py | 2 +- discord/types/interactions.py | 8 +- discord/ui/__init__.py | 1 + discord/ui/button.py | 2 +- discord/ui/item.py | 5 +- discord/ui/select.py | 315 ++++++++++++++++++++++++++++++++++ discord/ui/view.py | 9 +- 7 files changed, 334 insertions(+), 8 deletions(-) create mode 100644 discord/ui/select.py diff --git a/discord/state.py b/discord/state.py index e4ea28ef5..fec758877 100644 --- a/discord/state.py +++ b/discord/state.py @@ -515,7 +515,7 @@ class ConnectionState: self.dispatch('raw_message_edit', raw) if 'components' in data and self._view_store.is_message_tracked(raw.message_id): - self._view_store.update_view(raw.message_id, data['components']) + self._view_store.update_from_message(raw.message_id, data['components']) def parse_message_reaction_add(self, data): emoji = data['emoji'] diff --git a/discord/types/interactions.py b/discord/types/interactions.py index 7657c7975..b4db850a6 100644 --- a/discord/types/interactions.py +++ b/discord/types/interactions.py @@ -124,7 +124,11 @@ class ApplicationCommandInteractionData(_ApplicationCommandInteractionDataOption name: str -class ComponentInteractionData(TypedDict): +class _ComponentInteractionDataOptional(TypedDict, total=False): + values: List[str] + + +class ComponentInteractionData(_ComponentInteractionDataOptional): custom_id: str component_type: ComponentType @@ -154,7 +158,7 @@ class InteractionApplicationCommandCallbackData(TypedDict, total=False): flags: int -InteractionResponseType = Literal[1, 2, 3, 4, 5] +InteractionResponseType = Literal[1, 2, 3, 4, 5, 6, 7] class _InteractionResponseOptional(TypedDict, total=False): diff --git a/discord/ui/__init__.py b/discord/ui/__init__.py index 9aa9bea58..9f5a22811 100644 --- a/discord/ui/__init__.py +++ b/discord/ui/__init__.py @@ -12,3 +12,4 @@ Bot UI Kit helper for the Discord API from .view import * from .item import * from .button import * +from .select import * diff --git a/discord/ui/button.py b/discord/ui/button.py index ca32098d9..220d55ded 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -202,7 +202,7 @@ class Button(Item[V]): def is_dispatchable(self) -> bool: return True - def refresh_state(self, button: ButtonComponent) -> None: + def refresh_component(self, button: ButtonComponent) -> None: self._underlying = button diff --git a/discord/ui/item.py b/discord/ui/item.py index bce289aac..e6892bf64 100644 --- a/discord/ui/item.py +++ b/discord/ui/item.py @@ -59,7 +59,10 @@ class Item(Generic[V]): def to_component_dict(self) -> Dict[str, Any]: raise NotImplementedError - def refresh_state(self, component: Component) -> None: + def refresh_component(self, component: Component) -> None: + return None + + def refresh_state(self, interaction: Interaction) -> None: return None @classmethod diff --git a/discord/ui/select.py b/discord/ui/select.py new file mode 100644 index 000000000..e6276ffb7 --- /dev/null +++ b/discord/ui/select.py @@ -0,0 +1,315 @@ +""" +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 ..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. + + .. 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. + """ + + __item_repr_attributes__: Tuple[str, ...] = ( + 'placeholder', + 'min_values', + 'max_values', + 'options', + ) + + def __init__( + self, + *, + custom_id: str = MISSING, + placeholder: Optional[str] = None, + min_values: int = 1, + max_values: int = 1, + options: List[SelectOption] = MISSING, + group: Optional[int] = None, + ) -> None: + self._selected_values: List[str] = [] + 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, + ) + self.group_id = group + + @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 + + def add_option( + self, + *, + label: str, + value: str, + description: Optional[str] = None, + emoji: Optional[Union[str, 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 25 characters. + value: :class:`str` + The value of the option. This is not displayed to users. + Can only be up to 100 characters. + description: Optional[:class:`str`] + An additional description of the option, if any. + Can only be up to 50 characters. + emoji: Optional[Union[:class:`str`, :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`. + default: :class:`bool` + Whether this option is selected by default. + + Raises + ------- + ValueError + The number of options exceeds 25. + """ + + if isinstance(emoji, str): + emoji = PartialEmoji.from_str(emoji) + + 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 values(self) -> List[str]: + """List[:class:`str`]: A list of values that have been selected by the user.""" + return self._selected_values + + 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, + group=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, + group: 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. + + + 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. + group: Optional[:class:`int`] + The relative group this select menu 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. + 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. + """ + + def decorator(func: ItemCallbackType) -> ItemCallbackType: + if not inspect.iscoroutinefunction(func): + raise TypeError('button function must be a coroutine function') + + func.__discord_ui_model_type__ = Select + func.__discord_ui_model_kwargs__ = { + 'placeholder': placeholder, + 'custom_id': custom_id, + 'group': group, + 'min_values': min_values, + 'max_values': max_values, + 'options': options, + } + return func + + return decorator diff --git a/discord/ui/view.py b/discord/ui/view.py index a783afa7e..d3a813edc 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -35,6 +35,7 @@ from .item import Item, ItemCallbackType from ..enums import ComponentType from ..components import ( Component, + ActionRow as ActionRowComponent, _component_factory, Button as ButtonComponent, ) @@ -52,7 +53,7 @@ if TYPE_CHECKING: def _walk_all_components(components: List[Component]) -> Iterator[Component]: for item in components: - if item.type is ComponentType.action_row: + if isinstance(item, ActionRowComponent): yield from item.children else: yield item @@ -115,6 +116,7 @@ class View: 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) loop = asyncio.get_running_loop() @@ -277,7 +279,7 @@ class View: except (KeyError, AttributeError): children.append(_component_to_item(component)) else: - older.refresh_state(component) + older.refresh_component(component) children.append(older) self.children = children @@ -358,12 +360,13 @@ class ViewStore: view, item, _ = value self._views[key] = (view, item, view._expires_at) + item.refresh_state(interaction) 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]): + 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])