From ff36aedf7bbc04780cbd4efdbfa278a0b7aee404 Mon Sep 17 00:00:00 2001
From: Rapptz <rapptz@gmail.com>
Date: Fri, 28 May 2021 02:41:52 -0400
Subject: [PATCH] Add support for reading SelectMenu components from messages

---
 discord/components.py       | 152 +++++++++++++++++++++++++++++++++++-
 discord/enums.py            |   1 +
 discord/types/components.py |  28 ++++++-
 docs/api.rst                |  21 +++++
 4 files changed, 199 insertions(+), 3 deletions(-)

diff --git a/discord/components.py b/discord/components.py
index 0a09a2d56..7e89f8ffc 100644
--- a/discord/components.py
+++ b/discord/components.py
@@ -33,6 +33,8 @@ if TYPE_CHECKING:
     from .types.components import (
         Component as ComponentPayload,
         ButtonComponent as ButtonComponentPayload,
+        SelectMenu as SelectMenuPayload,
+        SelectOption as SelectOptionPayload,
         ActionRow as ActionRowPayload,
     )
 
@@ -41,6 +43,8 @@ __all__ = (
     'Component',
     'ActionRow',
     'Button',
+    'SelectMenu',
+    'SelectOption',
 )
 
 C = TypeVar('C', bound='Component')
@@ -53,6 +57,7 @@ class Component:
 
     - :class:`ActionRow`
     - :class:`Button`
+    - :class:`SelectMenu`
 
     This class is abstract and cannot be instantiated.
 
@@ -71,7 +76,7 @@ class Component:
 
     def __repr__(self) -> str:
         attrs = ' '.join(f'{key}={getattr(self, key)!r}' for key in self.__repr_info__)
-        return f'<{self.__class__.__name__} type={self.type!r} {attrs}>'
+        return f'<{self.__class__.__name__} {attrs}>'
 
     @classmethod
     def _raw_construct(cls: Type[C], **kwargs) -> C:
@@ -94,6 +99,8 @@ class ActionRow(Component):
 
     This is a component that holds up to 5 children components in a row.
 
+    This inherits from :class:`Component`.
+
     .. versionadded:: 2.0
 
     Attributes
@@ -186,12 +193,155 @@ class Button(Component):
         return payload  # type: ignore
 
 
+class SelectMenu(Component):
+    """Represents a select menu from the Discord Bot UI Kit.
+
+    A select menu is functionally the same as a dropdown, however
+    on mobile it renders a bit differently.
+
+    .. versionadded:: 2.0
+
+    Attributes
+    ------------
+    custom_id: Optional[:class:`str`]
+        The ID of the select menu that gets received during an interaction.
+    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:`SelectOption`]
+        A list of options that can be selected in this menu.
+    """
+
+    __slots__: Tuple[str, ...] = (
+        'custom_id',
+        'placeholder',
+        'min_values',
+        'max_values',
+        'options',
+    )
+
+    __repr_info__: ClassVar[Tuple[str, ...]] = __slots__
+
+    def __init__(self, data: SelectMenuPayload):
+        self.type = ComponentType.select
+        self.custom_id: str = data['custom_id']
+        self.placeholder: Optional[str] = data.get('placeholder')
+        self.min_values: int = data.get('min_values', 1)
+        self.max_values: int = data.get('max_values', 1)
+        self.options: List[SelectOption] = [SelectOption.from_dict(option) for option in data.get('options', [])]
+
+    def to_dict(self) -> SelectMenuPayload:
+        payload: SelectMenuPayload = {
+            'type': self.type.value,
+            'custom_id': self.custom_id,
+            'min_values': self.min_values,
+            'max_values': self.max_values,
+            'options': [op.to_dict() for op in self.options],
+        }
+
+        if self.placeholder:
+            payload['placeholder'] = self.placeholder
+
+        return payload
+
+
+class SelectOption:
+    """Represents a select menu's option.
+
+    These can be created by users.
+
+    .. versionadded:: 2.0
+
+    Attributes
+    -----------
+    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[:class:`PartialEmoji`]
+        The emoji of the option, if available.
+    default: :class:`bool`
+        Whether this option is selected by default.
+    """
+
+    __slots__: Tuple[str, ...] = (
+        'label',
+        'value',
+        'description',
+        'emoji',
+        'default',
+    )
+
+    def __init__(
+        self,
+        *,
+        label: str,
+        value: str,
+        description: Optional[str] = None,
+        emoji: Optional[PartialEmoji] = None,
+        default: bool = False,
+    ) -> None:
+        self.label = label
+        self.value = value
+        self.description = description
+        self.emoji = emoji
+        self.default = default
+
+    def __repr__(self) -> str:
+        return (
+            f'<SelectOption label={self.label!r} value={self.value!r} description={self.description!r} '
+            f'emoji={self.emoji!r} default={self.default!r}>'
+        )
+
+    @classmethod
+    def from_dict(cls, data: SelectOptionPayload) -> SelectOption:
+        try:
+            emoji = PartialEmoji.from_dict(data['emoji'])
+        except KeyError:
+            emoji = None
+
+        return cls(
+            label=data['label'],
+            value=data['value'],
+            description=data.get('description'),
+            emoji=emoji,
+            default=data.get('default', False),
+        )
+
+    def to_dict(self) -> SelectOptionPayload:
+        payload: SelectOptionPayload = {
+            'label': self.label,
+            'value': self.value,
+            'default': self.default,
+        }
+
+        if self.emoji:
+            payload['emoji'] = self.emoji.to_dict()  # type: ignore
+
+        if self.description:
+            payload['description'] = self.description
+
+        return payload
+
+
 def _component_factory(data: ComponentPayload) -> Component:
     component_type = data['type']
     if component_type == 1:
         return ActionRow(data)
     elif component_type == 2:
         return Button(data)  # type: ignore
+    elif component_type == 3:
+        return SelectMenu(data)  # type: ignore
     else:
         as_enum = try_enum(ComponentType, component_type)
         return Component._raw_construct(type=as_enum)
diff --git a/discord/enums.py b/discord/enums.py
index cd6b54b07..fa86767db 100644
--- a/discord/enums.py
+++ b/discord/enums.py
@@ -458,6 +458,7 @@ class VideoQualityMode(Enum):
 class ComponentType(Enum):
     action_row = 1
     button = 2
+    select = 3
 
     def __int__(self):
         return self.value
diff --git a/discord/types/components.py b/discord/types/components.py
index 8f1838b87..551a97ace 100644
--- a/discord/types/components.py
+++ b/discord/types/components.py
@@ -27,7 +27,7 @@ from __future__ import annotations
 from typing import List, Literal, TypedDict, Union
 from .emoji import PartialEmoji
 
-ComponentType = Literal[1, 2]
+ComponentType = Literal[1, 2, 3]
 ButtonStyle = Literal[1, 2, 3, 4, 5]
 
 
@@ -43,9 +43,33 @@ class _ButtonComponentOptional(TypedDict, total=False):
     emoji: PartialEmoji
     label: str
 
+
 class ButtonComponent(_ButtonComponentOptional):
     type: Literal[2]
     style: ButtonStyle
 
 
-Component = Union[ActionRow, ButtonComponent]
+class _SelectMenuOptional(TypedDict, total=False):
+    placeholder: str
+    min_values: int
+    max_values: int
+
+
+class _SelectOptionsOptional(TypedDict, total=False):
+    description: str
+    emoji: PartialEmoji
+
+
+class SelectOption(_SelectOptionsOptional):
+    label: str
+    value: str
+    default: bool
+
+
+class SelectMenu(_SelectMenuOptional):
+    type: Literal[3]
+    custom_id: str
+    options: List[SelectOption]
+
+
+Component = Union[ActionRow, ButtonComponent, SelectMenu]
diff --git a/docs/api.rst b/docs/api.rst
index 5639b85e5..62764a23d 100644
--- a/docs/api.rst
+++ b/docs/api.rst
@@ -1224,6 +1224,10 @@ of :class:`enum.Enum`.
     .. attribute:: button
 
         Represents a button component.
+    .. attribute:: select
+
+        Represents a select component.
+
 
 .. class:: ButtonStyle
 
@@ -2902,6 +2906,15 @@ Button
     :members:
     :inherited-members:
 
+SelectMenu
+~~~~~~~~~~~
+
+.. attributetable:: SelectMenu
+
+.. autoclass:: SelectMenu()
+    :members:
+    :inherited-members:
+
 
 DeletedReferencedMessage
 ~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -3316,6 +3329,14 @@ PartialMessage
 .. autoclass:: PartialMessage
     :members:
 
+SelectOption
+~~~~~~~~~~~~~
+
+.. attributetable:: SelectOption
+
+.. autoclass:: SelectOption
+    :members:
+
 Intents
 ~~~~~~~~~~