aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--discord/state.py2
-rw-r--r--discord/types/interactions.py8
-rw-r--r--discord/ui/__init__.py1
-rw-r--r--discord/ui/button.py2
-rw-r--r--discord/ui/item.py5
-rw-r--r--discord/ui/select.py315
-rw-r--r--discord/ui/view.py9
7 files changed, 334 insertions, 8 deletions
diff --git a/discord/state.py b/discord/state.py
index e4ea28ef..fec75887 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 7657c797..b4db850a 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 9aa9bea5..9f5a2281 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 ca32098d..220d55de 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 bce289aa..e6892bf6 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 00000000..e6276ffb
--- /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 a783afa7..d3a813ed 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])