diff --git a/CHANGELOG.md b/CHANGELOG.md index 19823624e2..cb928d9d80 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,14 @@ These changes are available on the `master` branch, but have not yet been releas - Added support for setting guild-specific `avatar`, `banner`, and `bio` for the bot user through `Member.edit`. ([#2908](https://github.com/Pycord-Development/pycord/pull/2908)) +- Added support for select default values. + ([#2899](https://github.com/Pycord-Development/pycord/pull/2899)) + - Adds a new generic parameter to selects to type `ui.Select.values` return type. + - Adds `SelectDefaultValue` object to create select default values. + - Adds `SelectDefaultValueType` enum. + - Adds pre-typed and pre-constructed with select_type `ui.Select` aliases for the + different select types: `ui.StringSelect`, `ui.UserSelect`, `ui.RoleSelect`, + `ui.MentionableSelect`, and `ui.ChannelSelect`. ### Changed diff --git a/discord/abc.py b/discord/abc.py index 1fd36948e1..da3d6ddd18 100644 --- a/discord/abc.py +++ b/discord/abc.py @@ -235,7 +235,7 @@ class User(Snowflake, Protocol): name: str discriminator: str global_name: str | None - avatar: Asset + avatar: Asset | None bot: bool @property diff --git a/discord/components.py b/discord/components.py index a798edb74d..9f9e76aa8e 100644 --- a/discord/components.py +++ b/discord/components.py @@ -25,7 +25,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar +from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar, overload from .asset import AssetMixin from .colour import Colour @@ -34,6 +34,7 @@ ChannelType, ComponentType, InputTextStyle, + SelectDefaultValueType, SeparatorSpacingSize, try_enum, ) @@ -42,9 +43,9 @@ from .utils import MISSING, find, get_slots if TYPE_CHECKING: + from . import abc from .emoji import AppEmoji, GuildEmoji from .types.components import ActionRow as ActionRowPayload - from .types.components import BaseComponent as BaseComponentPayload from .types.components import ButtonComponent as ButtonComponentPayload from .types.components import Component as ComponentPayload from .types.components import ContainerComponent as ContainerComponentPayload @@ -54,6 +55,7 @@ from .types.components import MediaGalleryComponent as MediaGalleryComponentPayload from .types.components import MediaGalleryItem as MediaGalleryItemPayload from .types.components import SectionComponent as SectionComponentPayload + from .types.components import SelectDefaultValue as SelectDefaultValuePayload from .types.components import SelectMenu as SelectMenuPayload from .types.components import SelectOption as SelectOptionPayload from .types.components import SeparatorComponent as SeparatorComponentPayload @@ -78,6 +80,7 @@ "Separator", "Container", "Label", + "SelectDefaultValue", ) C = TypeVar("C", bound="Component") @@ -437,6 +440,7 @@ class SelectMenu(Component): "channel_types", "disabled", "required", + "default_values", ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ @@ -457,6 +461,9 @@ def __init__(self, data: SelectMenuPayload): try_enum(ChannelType, ct) for ct in data.get("channel_types", []) ] self.required: bool | None = data.get("required") + self.default_values: list[SelectDefaultValue] = SelectDefaultValue._from_data( + data.get("default_values") + ) def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { @@ -476,10 +483,187 @@ def to_dict(self) -> SelectMenuPayload: payload["placeholder"] = self.placeholder if self.required is not None: payload["required"] = self.required + if self.type is not ComponentType.string_select: + payload["default_values"] = [dv.to_dict() for dv in self.default_values] return payload +class SelectDefaultValue: + r"""Represents a :class:`discord.SelectMenu`\s default value. + + This is only applicable to selects of type other than :attr:`ComponentType.string_select`. + + .. versionadded:: 2.7 + + Parameters + ---------- + object: :class:`abc.Snowflake` + The model type this select default value is based of. + + Below, is a table defining the model instance type and the default value type it will be mapped: + + +-----------------------------------+--------------------------------------------------------------------------+ + | Model Type | Default Value Type | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.User` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Member` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Role` | :attr:`discord.SelectDefaultValueType.role` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.abc.GuildChannel` | :attr:`discord.SelectDefaultValueType.channel` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Object` | depending on :attr:`discord.Object.type`, it will be mapped to any above | + +-----------------------------------+--------------------------------------------------------------------------+ + + If you pass a model that is not defined in the table, ``TypeError`` will be raised. + + .. note:: + + The :class:`discord.abc.GuildChannel` protocol includes :class:`discord.TextChannel`, :class:`discord.VoiceChannel`, :class:`discord.StageChannel`, + :class:`discord.ForumChannel`, :class:`discord.Thread`, :class:`discord.MediaChannel`. This list is not exhaustive, and is bound to change + based of the new channel types Discord adds. + + id: :class:`int` + The ID of the default value. This cannot be used with ``object``. + type: :class:`SelectDefaultValueType` + The default value type. This cannot be used with ``object``. + + Raises + ------ + TypeError + You did not provide any parameter, you provided all parameters, or you provided ``id`` but not ``type``. + """ + + __slots__ = ("id", "type") + + @overload + def __init__( + self, + object: abc.Snowflake, + /, + ) -> None: ... + + @overload + def __init__( + self, + /, + *, + id: int, + type: SelectDefaultValueType, + ) -> None: ... + + def __init__( + self, + object: abc.Snowflake = MISSING, + /, + *, + id: int = MISSING, + type: SelectDefaultValueType = MISSING, + ) -> None: + self.id: int = id + self.type: SelectDefaultValueType = type + if object is not MISSING: + if any(p is not MISSING for p in (id, type)): + raise TypeError("you cannot pass id or type when passing object") + self._handle_model(object, inst=self) + elif id is not MISSING and type is not MISSING: + self.id = id + self.type = type + else: + raise TypeError("you must provide an object model, or an id and type") + + def __repr__(self) -> str: + return f"" + + @classmethod + def _from_data( + cls, default_values: list[SelectDefaultValuePayload] | None + ) -> list[SelectDefaultValue]: + if not default_values: + return [] + return [ + cls(id=int(d["id"]), type=try_enum(SelectDefaultValueType, d["type"])) + for d in default_values + ] + + @classmethod + def _handle_model( + cls, + model: abc.Snowflake, + select_type: ComponentType | None = None, + inst: SelectDefaultValue | None = None, + ) -> SelectDefaultValue: + # preventing >circular imports< + from discord import Member, Object, Role, User, abc + from discord.user import _UserTag + + instances_mapping: dict[ + type, tuple[tuple[ComponentType, ...], SelectDefaultValueType] + ] = { + Role: ( + (ComponentType.role_select, ComponentType.mentionable_select), + SelectDefaultValueType.role, + ), + User: ( + (ComponentType.user_select, ComponentType.mentionable_select), + SelectDefaultValueType.user, + ), + Member: ( + (ComponentType.user_select, ComponentType.mentionable_select), + SelectDefaultValueType.user, + ), + _UserTag: ( + (ComponentType.user_select, ComponentType.mentionable_select), + SelectDefaultValueType.user, + ), + abc.GuildChannel: ( + (ComponentType.channel_select,), + SelectDefaultValueType.channel, + ), + } + + obj_id = model.id + obj_type = model.__class__ + + if isinstance(model, Object): + obj_type = model.type + + sel_types = None + def_type = None + + for typ, (st, dt) in instances_mapping.items(): + if issubclass(obj_type, typ): + sel_types = st + def_type = dt + break + + if sel_types is None or def_type is None: + raise TypeError( + f"{obj_type.__name__} is not a valid instance for a select default value" + ) + + # we can't actually check select types when not in a select context + if select_type is not None and select_type not in sel_types: + raise TypeError( + f"{model.__class__.__name__} objects can not be set as a default value for {select_type.value} selects", + ) + + if inst is None: + return cls(id=obj_id, type=def_type) + else: + inst.id = obj_id + inst.type = def_type + return inst + + def to_dict(self) -> SelectDefaultValuePayload: + return { + "id": self.id, + "type": self.type.value, + } + + class SelectOption: """Represents a :class:`discord.SelectMenu`'s option. diff --git a/discord/enums.py b/discord/enums.py index 5dcc73967a..cc2429a84c 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -83,6 +83,7 @@ "ThreadArchiveDuration", "SubscriptionStatus", "SeparatorSpacingSize", + "SelectDefaultValueType", ) @@ -1120,6 +1121,14 @@ def __int__(self): return self.value +class SelectDefaultValueType(Enum): + """Represents the default value type of a select menu.""" + + channel = "channel" + role = "role" + user = "user" + + T = TypeVar("T") diff --git a/discord/object.py b/discord/object.py index d8bd72268f..fd684069d8 100644 --- a/discord/object.py +++ b/discord/object.py @@ -33,6 +33,8 @@ if TYPE_CHECKING: import datetime + from .abc import Snowflake + SupportsIntCast = Union[SupportsInt, str, bytes, bytearray] __all__ = ("Object",) @@ -70,9 +72,11 @@ class Object(Hashable): ---------- id: :class:`int` The ID of the object. + type: type[:class:`abc.Snowflake`] + The model this object's ID is based off. """ - def __init__(self, id: SupportsIntCast): + def __init__(self, id: SupportsIntCast, type: type[Snowflake] = utils.MISSING): try: id = int(id) except ValueError: @@ -81,6 +85,7 @@ def __init__(self, id: SupportsIntCast): ) from None else: self.id = id + self.type: type[Snowflake] = type or self.__class__ def __repr__(self) -> str: return f"" diff --git a/discord/types/components.py b/discord/types/components.py index 14063cdf54..5c90bec604 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -37,6 +37,7 @@ ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] SeparatorSpacingSize = Literal[1, 2] +SelectDefaultValueType = Literal["channel", "role", "user"] class BaseComponent(TypedDict): @@ -46,7 +47,7 @@ class BaseComponent(TypedDict): class ActionRow(BaseComponent): type: Literal[1] - components: list[ButtonComponent, InputText, SelectMenu] + components: list[ButtonComponent | InputText | SelectMenu] class ButtonComponent(BaseComponent): @@ -80,6 +81,11 @@ class SelectOption(TypedDict): default: bool +class SelectDefaultValue(TypedDict): + id: Snowflake + type: SelectDefaultValueType + + class SelectMenu(BaseComponent): placeholder: NotRequired[str] min_values: NotRequired[int] @@ -90,6 +96,7 @@ class SelectMenu(BaseComponent): type: Literal[3, 5, 6, 7, 8] custom_id: str required: NotRequired[bool] + default_values: NotRequired[list[SelectDefaultValue]] class TextDisplayComponent(BaseComponent): @@ -100,7 +107,7 @@ class TextDisplayComponent(BaseComponent): class SectionComponent(BaseComponent): type: Literal[9] components: list[TextDisplayComponent] - accessory: NotRequired[ThumbnailComponent, ButtonComponent] + accessory: NotRequired[ThumbnailComponent | ButtonComponent] class UnfurledMediaItem(TypedDict): diff --git a/discord/ui/button.py b/discord/ui/button.py index 29e74cd8af..f08de8d193 100644 --- a/discord/ui/button.py +++ b/discord/ui/button.py @@ -294,7 +294,7 @@ def button( emoji: str | GuildEmoji | AppEmoji | PartialEmoji | None = None, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: +) -> Callable[[ItemCallbackType[Button[V]]], Button[V]]: """A decorator that attaches a button to a component. The function being decorated should have three parameters, ``self`` representing @@ -347,4 +347,4 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: } return func - return decorator + return decorator # type: ignore # lie to the type checkers, because after a View is instated, the button callback is converted into a Button instance diff --git a/discord/ui/select.py b/discord/ui/select.py index d39aad0981..a779016b4e 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -27,12 +27,15 @@ import inspect import os -from typing import TYPE_CHECKING, Callable, TypeVar +import sys +from collections.abc import Sequence +from functools import partial +from typing import TYPE_CHECKING, Any, Callable, Generic, Literal, TypeVar, overload from ..channel import _threaded_guild_channel_factory -from ..components import SelectMenu, SelectOption +from ..components import SelectDefaultValue, SelectMenu, SelectOption from ..emoji import AppEmoji, GuildEmoji -from ..enums import ChannelType, ComponentType +from ..enums import ChannelType, ComponentType, SelectDefaultValueType from ..errors import InvalidArgument from ..interactions import Interaction from ..member import Member @@ -51,21 +54,33 @@ "role_select", "mentionable_select", "channel_select", + "StringSelect", + "UserSelect", + "RoleSelect", + "MentionableSelect", + "ChannelSelect", ) if TYPE_CHECKING: from typing_extensions import Self - from ..abc import GuildChannel + from ..abc import GuildChannel, Snowflake from ..types.components import SelectMenu as SelectMenuPayload from ..types.interactions import ComponentInteractionData from .view import View + ST = TypeVar("ST", bound=Snowflake | str, covariant=True, default=Any) +else: + if sys.version_info >= (3, 13): + ST = TypeVar("ST", bound="Snowflake | str", covariant=True, default=Any) + else: + ST = TypeVar("ST", bound="Snowflake | str", covariant=True) + S = TypeVar("S", bound="Select") V = TypeVar("V", bound="View", covariant=True) -class Select(Item[V]): +class Select(Generic[V, ST], Item[V]): """Represents a UI select menu. This is usually represented as a drop down menu. @@ -91,6 +106,10 @@ class Select(Item[V]): :attr:`discord.ComponentType.string_select`, :attr:`discord.ComponentType.user_select`, :attr:`discord.ComponentType.role_select`, :attr:`discord.ComponentType.mentionable_select`, or :attr:`discord.ComponentType.channel_select`. + + The default is :attr:`discord.ComponentType.string_select`, but if this is created using any of the provided + aliases: :class:`StringSelect`, :class:`RoleSelect`, :class:`UserSelect`, :class:`MentionableSelect`, or + :class:`ChannelSelect`, the default will be its respective select type. 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. @@ -109,7 +128,7 @@ class Select(Item[V]): A list of channel types that can be selected in this menu. Only valid for selects of type :attr:`discord.ComponentType.channel_select`. disabled: :class:`bool` - Whether the select is disabled or not. Only useable in views. Defaults to ``True`` in views. + Whether the select is disabled or not. Only useable in views. Defaults to ``False`` in views. 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 @@ -131,6 +150,37 @@ class Select(Item[V]): required: Optional[:class:`bool`] Whether the select is required or not. Only useable in modals. Defaults to ``True`` in modals. + .. versionadded:: 2.7 + default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] + The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`. + + These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue` + instances. + + Below, is a table defining the model instance type and the default value type it will be mapped: + + +-----------------------------------+--------------------------------------------------------------------------+ + | Model Type | Default Value Type | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.User` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Member` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Role` | :attr:`discord.SelectDefaultValueType.role` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.abc.GuildChannel` | :attr:`discord.SelectDefaultValueType.channel` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Object` | depending on :attr:`discord.Object.type`, it will be mapped to any above | + +-----------------------------------+--------------------------------------------------------------------------+ + + If you pass a model that is not defined in the table, ``TypeError`` will be raised. + + .. note:: + + The :class:`discord.abc.GuildChannel` protocol includes :class:`discord.TextChannel`, :class:`discord.VoiceChannel`, :class:`discord.StageChannel`, + :class:`discord.ForumChannel`, :class:`discord.Thread`, :class:`discord.MediaChannel`. This list is not exhaustive, and is bound to change + based of the new channel types Discord adds. + .. versionadded:: 2.7 """ @@ -147,8 +197,68 @@ class Select(Item[V]): "label", "description", "required", + "default_values", ) + @overload + def __init__( + self, + select_type: Literal[ComponentType.string_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + options: list[SelectOption] | None = ..., + disabled: bool = ..., + row: int | None = ..., + id: int | None = ..., + label: str | None = ..., + description: str | None = ..., + required: bool | None = ..., + ) -> None: ... + + @overload + def __init__( + self, + select_type: Literal[ComponentType.channel_select] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + channel_types: list[ChannelType] | None = ..., + disabled: bool = ..., + row: int | None = ..., + id: int | None = ..., + label: str | None = ..., + description: str | None = ..., + required: bool | None = ..., + default_values: Sequence[SelectDefaultValue | ST] | None = ..., + ) -> None: ... + + @overload + def __init__( + self, + select_type: Literal[ + ComponentType.user_select, + ComponentType.role_select, + ComponentType.mentionable_select, + ] = ..., + *, + custom_id: str | None = ..., + placeholder: str | None = ..., + min_values: int = ..., + max_values: int = ..., + disabled: bool = ..., + row: int | None = ..., + id: int | None = ..., + label: str | None = ..., + description: str | None = ..., + required: bool | None = ..., + default_values: Sequence[SelectDefaultValue | ST] | None = ..., + ) -> None: ... + def __init__( self, select_type: ComponentType = ComponentType.string_select, @@ -165,6 +275,7 @@ def __init__( label: str | None = None, description: str | None = None, required: bool | None = None, + default_values: Sequence[SelectDefaultValue | ST] | None = None, ) -> None: if options and select_type is not ComponentType.string_select: raise InvalidArgument("options parameter is only valid for string selects") @@ -208,9 +319,54 @@ def __init__( channel_types=channel_types or [], id=id, required=required, + default_values=self._handle_default_values(default_values, select_type), ) self.row = row + def _handle_default_values( + self, + default_values: Sequence[Snowflake | ST] | None, + select_type: ComponentType, + ) -> list[SelectDefaultValue]: + if not default_values: + return [] + + ret = [] + + valid_default_types = { + ComponentType.user_select: (SelectDefaultValueType.user,), + ComponentType.role_select: (SelectDefaultValueType.role,), + ComponentType.channel_select: (SelectDefaultValueType.channel,), + ComponentType.mentionable_select: ( + SelectDefaultValueType.user, + SelectDefaultValueType.role, + ), + } + + for dv in default_values: + if isinstance(dv, SelectDefaultValue): + try: + valid_types = valid_default_types[select_type] + except KeyError: + raise TypeError( + f"select default values are not allowed for this select type ({select_type.name})" + ) + + if dv.type not in valid_types: + raise TypeError( + f"{dv.type.name} is not a valid select default value for selects of type {select_type.name}" + ) + + ret.append(dv) + continue + if isinstance(dv, str): + # this should not be here anyways, but guarding it + continue + + ret.append(SelectDefaultValue._handle_model(dv, select_type)) + + return ret + @property def custom_id(self) -> str: """The ID of the select menu that gets received during an interaction.""" @@ -306,6 +462,132 @@ def options(self, value: list[SelectOption]): self._underlying.options = value + @property + def default_values(self) -> list[SelectDefaultValue]: + """A list of the select's default values. This is only applicable if + the select type is not :attr:`discord.ComponentType.string_select`. + + .. versionadded:: 2.7 + """ + return self._underlying.default_values + + @default_values.setter + def default_values( + self, values: Sequence[SelectDefaultValue | Snowflake] | None + ) -> None: + default_values = self._handle_default_values(values, self.type) + self._underlying.default_values = default_values + + def add_default_value( + self, + *, + id: int, + type: SelectDefaultValueType = MISSING, + ) -> Self: + """Adds a default value to the select menu. + + To append a pre-existing :class:`discord.SelectDefaultValue` use the + :meth:`append_default_value` method instead. + + .. versionadded:: 2.7 + + Parameters + ---------- + id: :class:`int` + The ID of the entity to add as a default. + type: :class:`discord.SelectDefaultValueType` + The default value type of the ID. This is only required if :attr:`.type` is of + type :attr:`discord.ComponentType.mentionable_select`. + + Raises + ------ + TypeError + The select type is a mentionable_select and type was not provided, or the select + type is string_select. + ValueError + The number of default select values exceeds 25. + """ + if type is MISSING and self.type is ComponentType.mentionable_select: + raise TypeError( + "type is required when select is of type mentionable_select" + ) + + types = { + ComponentType.user_select: SelectDefaultValueType.user, + ComponentType.role_select: SelectDefaultValueType.role, + ComponentType.channel_select: SelectDefaultValueType.channel, + } + + def_type = types.get(self.type, type) + self.append_default_value(SelectDefaultValue(id=id, type=def_type)) + return self + + def append_default_value( + self, + value: SelectDefaultValue | Snowflake, + /, + ) -> Self: + """Appends a default value to this select menu. + + .. versionadded:: 2.7 + + Parameters + ---------- + value: Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`] + The default value to append to this select. + + These can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultvalue` + instances. + + Below, is a table defining the model instance type and the default value type it will be mapped: + + +-----------------------------------+--------------------------------------------------------------------------+ + | Model Type | Default Value Type | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.User` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Member` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Role` | :attr:`discord.SelectDefaultValueType.role` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.abc.GuildChannel` | :attr:`discord.SelectDefaultValueType.channel` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Object` | depending on :attr:`discord.Object.type`, it will be mapped to any above | + +-----------------------------------+--------------------------------------------------------------------------+ + + If you pass a model that is not defined in the table, ``TypeError`` will be raised. + + .. note:: + + The :class:`discord.abc.GuildChannel` protocol includes :class:`discord.TextChannel`, :class:`discord.VoiceChannel`, :class:`discord.StageChannel`, + :class:`discord.ForumChannel`, :class:`discord.Thread`, :class:`discord.MediaChannel`. This list is not exhaustive, and is bound to change + based of the new channel types Discord adds. + + Raises + ------ + TypeError + The select type is string_select, which does not allow for default_values + ValueError + The number of default select values exceeds 25. + """ + + if self.type is ComponentType.string_select: + raise TypeError("string_select selects do not allow default_values") + + if len(self.default_values) >= 25: + raise ValueError("maximum number of default values exceeded (25)") + + if not isinstance(value, SelectDefaultValue): + value = SelectDefaultValue._handle_model(value) + + if not isinstance(value, SelectDefaultValue): + raise TypeError( + f"expected a SelectDefaultValue object, got {value.__class__.__name__}" + ) + + self._underlying.default_values.append(value) + return self + def add_option( self, *, @@ -378,29 +660,25 @@ def append_option(self, option: SelectOption) -> Self: return self @property - def values( - self, - ) -> ( - list[str] - | list[Member | User] - | list[Role] - | list[Member | User | Role] - | list[GuildChannel | Thread] - ): + def values(self) -> list[ST]: """List[:class:`str`] | List[:class:`discord.Member` | :class:`discord.User`]] | List[:class:`discord.Role`]] | List[:class:`discord.Member` | :class:`discord.User` | :class:`discord.Role`]] | List[:class:`discord.abc.GuildChannel`] | None: A list of values that have been selected by the user. This will be ``None`` if the select has not been interacted with yet. """ - if self._interaction is None: + if self._interaction is None or self._interaction.data is None: # The select has not been interacted with yet - return None + return [] select_type = self._underlying.type if select_type is ComponentType.string_select: - return self._selected_values + return self._selected_values # type: ignore # ST is str resolved = [] selected_values = list(self._selected_values) state = self._interaction._state guild = self._interaction.guild + + if guild is None: + return [] + resolved_data = self._interaction.data.get("resolved", {}) if select_type is ComponentType.channel_select: for channel_id, _data in resolved_data.get("channels", {}).items(): @@ -425,6 +703,9 @@ def values( # For threads, if this fallback occurs, info like thread owner id, message count, # flags, and more will be missing due to a lack of data sent by Discord. obj_type = _threaded_guild_channel_factory(_data["type"])[0] + if obj_type is None: + # should not be None, but assert anyways + continue result = obj_type(state=state, data=_data, guild=guild) resolved.append(result) elif select_type in ( @@ -465,7 +746,7 @@ def refresh_component(self, component: SelectMenu) -> None: def refresh_state(self, interaction: Interaction | dict) -> None: data: ComponentInteractionData = ( - interaction.data if isinstance(interaction, Interaction) else interaction + interaction.data if isinstance(interaction, Interaction) else interaction # type: ignore ) self._selected_values = data.get("values", []) self._interaction = interaction @@ -488,7 +769,8 @@ def from_component(cls: type[S], component: SelectMenu) -> S: row=None, id=component.id, required=component.required, - ) + default_values=component.default_values, + ) # type: ignore @property def type(self) -> ComponentType: @@ -504,6 +786,68 @@ def uses_label(self) -> bool: return bool(self.label or self.description or (self.required is not None)) +if TYPE_CHECKING: + StringSelect = Select[V, str] + """A typed alias for :class:`Select` for string values. + + When creating an instance with this, it will automatically provide the ``select_type`` + parameter as a :attr:`discord.ComponentType.string_select`. + """ + UserSelect = Select[V, User | Member] + """A typed alias for :class:`Select` for user-like values. + + When creating an instance with this, it will automatically provide the ``select_type`` + parameter as a :attr:`discord.ComponentType.user_select`. + """ + RoleSelect = Select[V, Role] + """A typed alias for :class:`Select` for role values. + + When creating an instance with this, it will automatically provide the ``select_type`` + parameter as a :attr:`discord.ComponentType.role_select`. + """ + MentionableSelect = Select[V, User | Member | Role] + """A typed alias for :class:`Select` for mentionable (role and user-like) values. + + When creating an instance with this, it will automatically provide the ``select_type`` + parameter as a :attr:`discord.ComponentType.mentionable_select`. + """ + ChannelSelect = Select[V, GuildChannel | Thread] + """A typed alias for :class:`Select` for channel values. + + When creating an instance with this, it will automatically provide the ``select_type`` + parameter as a :attr:`discord.ComponentType.channel_select`. + """ +else: + StringSelect: Select[V, str] = partial( + Select, select_type=ComponentType.string_select + ) + """An alias for :class:`Select` that will pass :attr:`discord.ComponentType.string_select` + as its default ``select_type``. + """ + UserSelect: Select[V, User | Member] = partial( + Select, select_type=ComponentType.user_select + ) + """An alias for :class:`Select` that will pass :attr:`discord.ComponentType.user_select` + as its default ``select_type``. + """ + RoleSelect: Select[V, Role] = partial(Select, select_type=ComponentType.role_select) + """An alias for :class:`Select` that will pass :attr:`discord.ComponentType.role_select` + as its default ``select_type``. + """ + MentionableSelect: Select[V, Role | User | Member] = partial( + Select, select_type=ComponentType.mentionable_select + ) + """An alias for :class:`Select` that will pass :attr:`discord.ComponentType.mentionable_select` + as its default ``select_type``. + """ + ChannelSelect: Select[V, GuildChannel | Thread] = partial( + Select, select_type=ComponentType.channel_select + ) + """An alias for :class:`Select` that will pass :attr:`discord.ComponentType.channel_select` + as its default ``select_type``. + """ + + _select_types = ( ComponentType.string_select, ComponentType.user_select, @@ -525,7 +869,8 @@ def select( disabled: bool = False, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: + default_values: Sequence[SelectDefaultValue | Snowflake] | None = None, +) -> Callable[[ItemCallbackType[Select[V, ST]]], Select[V, ST]]: """A decorator that attaches a select menu to a component. The function being decorated should have three parameters, ``self`` representing @@ -574,6 +919,37 @@ def select( Whether the select is disabled or not. Defaults to ``False``. id: Optional[:class:`int`] The select menu's ID. + default_values: Optional[Sequence[Union[:class:`discord.SelectDefaultValue`, :class:`discord.abc.Snowflake`]]] + The default values of this select. Only applicable if :attr:`.select_type` is not :attr:`discord.ComponentType.string_select`. + + This can be either :class:`discord.SelectDefaultValue` instances or models, which will be converted into :class:`discord.SelectDefaultValue` + instances. + + Below, is a table defining the model instance type and the default value type it will be mapped: + + +-----------------------------------+--------------------------------------------------------------------------+ + | Model Type | Default Value Type | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.User` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Member` | :attr:`discord.SelectDefaultValueType.user` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Role` | :attr:`discord.SelectDefaultValueType.role` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.abc.GuildChannel` | :attr:`discord.SelectDefaultValueType.channel` | + +-----------------------------------+--------------------------------------------------------------------------+ + | :class:`discord.Object` | depending on :attr:`discord.Object.type`, it will be mapped to any above | + +-----------------------------------+--------------------------------------------------------------------------+ + + If you pass a model that is not defined in the table, ``TypeError`` will be raised. + + .. note:: + + The :class:`discord.abc.GuildChannel` protocol includes :class:`discord.TextChannel`, :class:`discord.VoiceChannel`, :class:`discord.StageChannel`, + :class:`discord.ForumChannel`, :class:`discord.Thread`, :class:`discord.MediaChannel`. This list is not exhaustive, and is bound to change + based of the new channel types Discord adds. + + .. versionadded:: 2.7 """ if select_type not in _select_types: raise ValueError( @@ -589,6 +965,11 @@ def select( if channel_types is not MISSING and select_type is not ComponentType.channel_select: raise TypeError("channel_types may only be specified for channel selects") + if default_values is not None and select_type is ComponentType.string_select: + raise TypeError( + "default_values may only be specified for selects other than string selects" + ) + def decorator(func: ItemCallbackType) -> ItemCallbackType: if not inspect.iscoroutinefunction(func): raise TypeError("select function must be a coroutine function") @@ -602,6 +983,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: "max_values": max_values, "disabled": disabled, "id": id, + "default_values": default_values, } if options: model_kwargs["options"] = options @@ -613,7 +995,7 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: return func - return decorator + return decorator # type: ignore # lie to the type checkers because after a View is instated the select callback is converted into a Select instance def string_select( @@ -626,7 +1008,7 @@ def string_select( disabled: bool = False, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: +) -> Callable[[ItemCallbackType[StringSelect[V]]], StringSelect[V]]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.string_select`. .. versionadded:: 2.3 @@ -653,7 +1035,8 @@ def user_select( disabled: bool = False, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: + default_values: Sequence[SelectDefaultValue | Snowflake] | None = None, +) -> Callable[[ItemCallbackType[UserSelect[V]]], UserSelect[V]]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.user_select`. .. versionadded:: 2.3 @@ -667,6 +1050,7 @@ def user_select( disabled=disabled, row=row, id=id, + default_values=default_values, ) @@ -679,7 +1063,8 @@ def role_select( disabled: bool = False, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: + default_values: Sequence[SelectDefaultValue | Snowflake] | None = None, +) -> Callable[[ItemCallbackType[RoleSelect[V]]], RoleSelect[V]]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.role_select`. .. versionadded:: 2.3 @@ -693,6 +1078,7 @@ def role_select( disabled=disabled, row=row, id=id, + default_values=default_values, ) @@ -705,7 +1091,11 @@ def mentionable_select( disabled: bool = False, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: + default_values: Sequence[SelectDefaultValue | Snowflake] | None = None, +) -> Callable[ + [ItemCallbackType[MentionableSelect[V]]], + MentionableSelect[V], +]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.mentionable_select`. .. versionadded:: 2.3 @@ -719,6 +1109,7 @@ def mentionable_select( disabled=disabled, row=row, id=id, + default_values=default_values, ) @@ -732,7 +1123,11 @@ def channel_select( channel_types: list[ChannelType] = MISSING, row: int | None = None, id: int | None = None, -) -> Callable[[ItemCallbackType], ItemCallbackType]: + default_values: Sequence[SelectDefaultValue | Snowflake] | None = None, +) -> Callable[ + [ItemCallbackType[ChannelSelect[V]]], + ChannelSelect[V], +]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.channel_select`. .. versionadded:: 2.3 @@ -747,4 +1142,5 @@ def channel_select( channel_types=channel_types, row=row, id=id, + default_values=default_values, ) diff --git a/discord/ui/view.py b/discord/ui/view.py index c7d719f06e..ddb5368b51 100644 --- a/discord/ui/view.py +++ b/discord/ui/view.py @@ -46,7 +46,7 @@ from ..components import TextDisplay as TextDisplayComponent from ..components import Thumbnail as ThumbnailComponent from ..components import _component_factory -from ..utils import find, get +from ..utils import find from .item import Item, ItemCallbackType __all__ = ("View", "_component_to_item", "_walk_all_components") diff --git a/docs/api/data_classes.rst b/docs/api/data_classes.rst index ad71d7deee..87f86f1732 100644 --- a/docs/api/data_classes.rst +++ b/docs/api/data_classes.rst @@ -26,6 +26,11 @@ dynamic attributes in mind. .. autoclass:: SelectOption :members: +.. attributetable:: SelectDefaultValue + +.. autoclass:: SelectDefaultValue + :members: + .. attributetable:: MediaGalleryItem .. autoclass:: MediaGalleryItem diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 7ceb0db38a..20692fce21 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -2584,3 +2584,19 @@ of :class:`enum.Enum`. .. attribute:: creation_date Sort by post creation date. + +.. class:: SelectDefaultValueType + + Represents the default value type of a select menu. + + .. attribute:: channel + + The default value is a channel. + + .. attribute:: role + + The default value is a role. + + .. attribute:: user + + The default value is a user. diff --git a/docs/api/ui_kit.rst b/docs/api/ui_kit.rst index ad2769eb03..8714f59b85 100644 --- a/docs/api/ui_kit.rst +++ b/docs/api/ui_kit.rst @@ -55,6 +55,27 @@ Objects :members: :inherited-members: + +.. class:: discord.ui.StringSelect + + An alias for :class:`Select` with ``select_type`` as :attr:`discord.ComponentType.string_select`. + +.. class:: discord.ui.UserSelect + + An alias for :class:`Select` with ``select_type`` as :attr:`discord.ComponentType.user_select`. + +.. class:: discord.ui.RoleSelect + + An alias for :class:`Select` with ``select_type`` as :attr:`discord.ComponentType.role_select`. + +.. class:: discord.ui.MentionableSelect + + An alias for :class:`Select` with ``select_type`` as :attr:`discord.ComponentType.mentionable_select`. + +.. class:: discord.ui.ChannelSelect + + An alias for :class:`Select` with ``select_type`` as :attr:`discord.ComponentType.channel_select`. + .. attributetable:: discord.ui.Section .. autoclass:: discord.ui.Section diff --git a/examples/views/channel_select.py b/examples/views/channel_select.py index d00416f915..bc24d1109e 100644 --- a/examples/views/channel_select.py +++ b/examples/views/channel_select.py @@ -11,11 +11,14 @@ class DropdownView(discord.ui.View): placeholder="Select channels...", min_values=1, max_values=3 ) # Users can select a maximum of 3 channels in the dropdown async def channel_select_dropdown( - self, select: discord.ui.Select, interaction: discord.Interaction + self, select: discord.ui.ChannelSelect, interaction: discord.Interaction ) -> None: + # update the select default values to the chosen values + select.default_values = select.values # this is a list of GuildChannels await interaction.response.send_message( - f"You selected the following channels:" - + f", ".join(f"{channel.mention}" for channel in select.values) + "You selected the following channels:" + + ", ".join(f"{channel.mention}" for channel in select.values), + view=self, ) diff --git a/examples/views/role_select.py b/examples/views/role_select.py index 89540b9663..e8da45f414 100644 --- a/examples/views/role_select.py +++ b/examples/views/role_select.py @@ -11,11 +11,14 @@ class DropdownView(discord.ui.View): placeholder="Select roles...", min_values=1, max_values=3 ) # Users can select a maximum of 3 roles in the dropdown async def role_select_dropdown( - self, select: discord.ui.Select, interaction: discord.Interaction + self, select: discord.ui.RoleSelect, interaction: discord.Interaction ) -> None: + # update the select default values to set them to the select ones + select.default_values = select.values # this is a list of Role await interaction.response.send_message( - f"You selected the following roles:" - + f", ".join(f"{role.mention}" for role in select.values) + "You selected the following roles:" + + ", ".join(f"{role.mention}" for role in select.values), + view=self, )