diff --git a/CHANGELOG.md b/CHANGELOG.md index 7d76398448..1d23024b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,9 @@ These changes are available on the `master` branch, but have not yet been releas ([#2818](https://github.com/Pycord-Development/pycord/pull/2818)) - Added `Interaction.attachment_size_limit`. ([#2854](https://github.com/Pycord-Development/pycord/pull/2854)) +- Added the ability to pass default values into `ui.Select` of type + `ComponentType.channel_select`, `ComponentType.user_select`, + `ComponentType.role_select` and `ComponentType.mentionable_select` ### Fixed diff --git a/discord/components.py b/discord/components.py index 39576c9eea..aa77e3c031 100644 --- a/discord/components.py +++ b/discord/components.py @@ -27,8 +27,10 @@ from typing import TYPE_CHECKING, Any, ClassVar, Iterator, TypeVar +from .abc import GuildChannel from .asset import AssetMixin from .colour import Colour +from .emoji import AppEmoji, GuildEmoji from .enums import ( ButtonStyle, ChannelType, @@ -38,11 +40,14 @@ try_enum, ) from .flags import AttachmentFlags +from .member import Member from .partial_emoji import PartialEmoji, _EmojiTag +from .role import Role +from .threads import Thread +from .user import User from .utils import MISSING, get_slots if TYPE_CHECKING: - 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 @@ -53,7 +58,9 @@ 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 SelectMenuDefaultValueType from .types.components import SelectOption as SelectOptionPayload from .types.components import SeparatorComponent as SeparatorComponentPayload from .types.components import TextDisplayComponent as TextDisplayComponentPayload @@ -66,6 +73,7 @@ "Button", "SelectMenu", "SelectOption", + "SelectDefaultValue", "InputText", "Section", "TextDisplay", @@ -399,6 +407,9 @@ class SelectMenu(Component): except for :attr:`ComponentType.channel_select`. disabled: :class:`bool` Whether the select is disabled or not. + default_values: List[:class:`SelectDefaultValue`] + A list of options that are selected by default for select menus of type user, role, channel, and mentionable. + Will be an empty list for the component type :attr:`ComponentType.string_select`. """ __slots__: tuple[str, ...] = ( @@ -409,6 +420,7 @@ class SelectMenu(Component): "options", "channel_types", "disabled", + "default_values", ) __repr_info__: ClassVar[tuple[str, ...]] = __slots__ @@ -428,6 +440,24 @@ def __init__(self, data: SelectMenuPayload): self.channel_types: list[ChannelType] = [ try_enum(ChannelType, ct) for ct in data.get("channel_types", []) ] + _default_values = [] + for d in data.get("default_values", []): + if isinstance(d, SelectDefaultValue): + _default_values.append(d) + elif isinstance(d, dict) and "id" in d and "type" in d: + _default_values.append(SelectDefaultValue(id=d["id"], type=d["type"])) + elif isinstance(d, (User, Member)): + _default_values.append(SelectDefaultValue(id=d.id, type="user")) + elif isinstance(d, Role): + _default_values.append(SelectDefaultValue(id=d.id, type="role")) + elif isinstance(d, (GuildChannel, Thread)): + _default_values.append(SelectDefaultValue(id=d.id, type="channel")) + else: + raise TypeError( + f"expected SelectDefaultValue, User, Member, Role, GuildChannel or Mentionable, not {d.__class__}" + ) + + self.default_values: list[SelectDefaultValue] = _default_values def to_dict(self) -> SelectMenuPayload: payload: SelectMenuPayload = { @@ -445,6 +475,8 @@ def to_dict(self) -> SelectMenuPayload: payload["channel_types"] = [ct.value for ct in self.channel_types] if self.placeholder: payload["placeholder"] = self.placeholder + if self.type is not ComponentType.string_select and self.default_values: + payload["default_values"] = [dv.to_dict() for dv in self.default_values] return payload @@ -568,6 +600,54 @@ def to_dict(self) -> SelectOptionPayload: return payload +class SelectDefaultValue: + """Represents a :class:`discord.SelectMenu` object's default option for user, role, channel, and mentionable select + menu types. + + These can be created by users. + + .. versionadded:: 2.7 + + Attributes + ---------- + id: :class:`int` + The snowflake ID of the default option. + type: :class:`SelectMenuDefaultValueType` + The type of the default value. This is not displayed to users. + """ + + __slots__: tuple[str, ...] = ( + "id", + "type", + ) + + def __init__(self, *, id: int, type: SelectMenuDefaultValueType) -> None: + self.id = id + self.type = type + + def __repr__(self) -> str: + return "" + + def __str__(self) -> str: + return f"{self.id} {self.type}" + + @classmethod + def from_dict(cls, data: SelectDefaultValuePayload) -> SelectDefaultValue: + + return cls( + id=data["id"], + type=data["type"], + ) + + def to_dict(self) -> SelectDefaultValuePayload: + payload: SelectDefaultValuePayload = { + "id": self.id, + "type": self.type.value, + } + + return payload + + class Section(Component): """Represents a Section from Components V2. diff --git a/discord/enums.py b/discord/enums.py index a8affc8a8b..670c90dd18 100644 --- a/discord/enums.py +++ b/discord/enums.py @@ -1088,6 +1088,17 @@ class SubscriptionStatus(Enum): inactive = 2 +class SelectMenuDefaultValueType(Enum): + """The type of a select menu's default value.""" + + Channel = "channel" + Role = "role" + User = "user" + + def __str__(self): + return self.name + + class SeparatorSpacingSize(Enum): """A separator component's spacing size.""" diff --git a/discord/types/components.py b/discord/types/components.py index 16d9661b3a..0be4bf2006 100644 --- a/discord/types/components.py +++ b/discord/types/components.py @@ -36,6 +36,7 @@ ComponentType = Literal[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 16, 17] ButtonStyle = Literal[1, 2, 3, 4, 5, 6] InputTextStyle = Literal[1, 2] +SelectMenuDefaultValueType = Literal["channel", "role", "user"] SeparatorSpacingSize = Literal[1, 2] @@ -80,6 +81,11 @@ class SelectOption(TypedDict): default: bool +class SelectDefaultValue(TypedDict): + id: Snowflake + type: SelectMenuDefaultValueType + + class SelectMenu(BaseComponent): placeholder: NotRequired[str] min_values: NotRequired[int] @@ -89,6 +95,7 @@ class SelectMenu(BaseComponent): options: NotRequired[list[SelectOption]] type: Literal[3, 5, 6, 7, 8] custom_id: str + default_values: NotRequired[list[SelectDefaultValue]] class TextDisplayComponent(BaseComponent): diff --git a/discord/ui/select.py b/discord/ui/select.py index 7c2f8b1f4a..86ccf57c69 100644 --- a/discord/ui/select.py +++ b/discord/ui/select.py @@ -30,9 +30,9 @@ from typing import TYPE_CHECKING, Callable, TypeVar 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, SelectMenuDefaultValueType from ..errors import InvalidArgument from ..interactions import Interaction from ..member import Member @@ -124,6 +124,7 @@ class Select(Item[V]): "options", "channel_types", "disabled", + "default_values", "custom_id", "id", ) @@ -140,6 +141,10 @@ def __init__( channel_types: list[ChannelType] | None = None, disabled: bool = False, row: int | None = None, + default_values: ( + list[User | Member | Role | GuildChannel | Thread | SelectDefaultValue] + | None + ) = None, id: int | None = None, ) -> None: if options and select_type is not ComponentType.string_select: @@ -148,6 +153,70 @@ def __init__( raise InvalidArgument( "channel_types parameter is only valid for channel selects" ) + _default_values = [] + if default_values: + if select_type is ComponentType.string_select: + raise InvalidArgument( + "default_values parameter is only valid for SelectMenu of type user, role, channel, or mentionable" + "selects" + ) + elif select_type is ComponentType.role_select: + for r in default_values: + if not isinstance(r, Role): + raise ValueError( + f"default_values must be a list of Role objects, not {r.__class__.__name__}" + ) + _default_values.append( + SelectDefaultValue( + id=r.id, type=SelectMenuDefaultValueType.Role + ) + ) + elif select_type is ComponentType.user_select: + for u in default_values: + if not isinstance(u, (User, Member)): + raise ValueError( + f"default_values must be a list of User/Member objects, " + f"not {u.__class__.__name__}" + ) + _default_values.append( + SelectDefaultValue( + id=u.id, type=SelectMenuDefaultValueType.User + ) + ) + elif select_type is ComponentType.channel_select: + for c in default_values: + if not isinstance(c, GuildChannel): + raise ValueError( + f"default_values must be a list of GuildChannel objects, " + f"not {c.__class__.__name__}" + ) + if channel_types and c.type not in channel_types: + raise ValueError( + f"default_values must be a list of channels of type {channel_types}, " + f"not {c.__class__.__name__}" + ) + _default_values.append( + SelectDefaultValue( + id=c.id, type=SelectMenuDefaultValueType.Channel + ) + ) + elif select_type is ComponentType.mentionable_select: + for m in default_values: + if not isinstance(m, (User, Member, Role)): + raise ValueError( + f"default_values must be a list of User/Member/Role objects, " + f"not {m.__class__.__name__}" + ) + _default_values.append( + SelectDefaultValue( + id=m.id, + type=( + SelectMenuDefaultValueType.Role + if isinstance(m, Role) + else SelectMenuDefaultValueType.User + ), + ) + ) super().__init__() self._selected_values: list[str] = [] self._interaction: Interaction | None = None @@ -173,6 +242,7 @@ def __init__( disabled=disabled, options=options or [], channel_types=channel_types or [], + default_values=_default_values, id=id, ) self.row = row @@ -334,6 +404,118 @@ def append_option(self, option: SelectOption) -> Self: self._underlying.options.append(option) return self + @property + def default_values(self) -> list[SelectDefaultValue]: + """A list of default values that are selected by default in this menu.""" + return self._underlying.default_values + + @default_values.setter + def default_values(self, value: list[SelectDefaultValue]): + if self._underlying.type is ComponentType.string_select: + raise InvalidArgument( + "default_values can only be set on non string selects" + ) + if not isinstance(value, list): + raise TypeError("options must be a list of SelectDefaultValue") + if not all(isinstance(obj, SelectDefaultValue) for obj in value): + raise TypeError("all list items must subclass SelectDefaultValue") + + self._underlying.default_values = value + + def add_default_value( + self, + *, + default_value: User | Member | Role | GuildChannel | Thread, + ): + """Adds a default value to the select menu. + + To append a pre-existing :class:`discord.SelectDefaultValue` use the + :meth:`append_default_value` method instead. + + Parameters + ---------- + default_value: Union[:class:`discord.User`, :class:`discord.Member`, :class:`discord.Role`, + :class:`discord.abc.GuildChannel`, :class:`discord.Thread`] + The to be added default value + + Raises + ------ + ValueError + The number of options exceeds 25. + """ + if self._underlying.type is ComponentType.string_select: + raise Exception("Default values can only be set on non string selects") + default_value_type = None + if self.type is ComponentType.channel_select and not isinstance( + default_value, (GuildChannel, Thread) + ): + raise InvalidArgument( + "Default values have to be of type GuildChannel or Thread with type ComponentType.channel_select" + ) + elif self.type is ComponentType.channel_select: + default_value_type = SelectMenuDefaultValueType.Channel + if self.type is ComponentType.user_select and not isinstance( + default_value, (User, Member) + ): + raise InvalidArgument( + "Default values have to be of type User or Member with type ComponentType.user_select" + ) + elif self.type is ComponentType.user_select: + default_value_type = SelectMenuDefaultValueType.User + if self.type is ComponentType.role_select and not isinstance( + default_value, Role + ): + raise InvalidArgument( + "Default values have to be of type Role with type ComponentType.role_select" + ) + elif self.type is ComponentType.role_select: + default_value_type = SelectMenuDefaultValueType.Role + if self.type is ComponentType.mentionable_select and not isinstance( + default_value, (User, Member, Role) + ): + raise InvalidArgument( + "Default values have to be of type User, Member or Role with type ComponentType.mentionable_select" + ) + elif self.type is ComponentType.mentionable_select and isinstance( + default_value, (User, Member) + ): + default_value_type = SelectMenuDefaultValueType.User + elif self.type is ComponentType.mentionable_select and isinstance( + default_value, Role + ): + default_value_type = SelectMenuDefaultValueType.Role + if default_value_type is None: + raise InvalidArgument( + "Default values have to be of type User, Member, Role or GuildChannel" + ) + default_value = SelectDefaultValue( + id=default_value.id, + type=default_value_type, + ) + + self.append_default_value(default_value) + + def append_default_value(self, default_value: SelectDefaultValue): + """Appends a default value to the select menu. + + Parameters + ---------- + default_value: :class:`discord.SelectDefaultValue` + The default value to append to the select menu. + + Raises + ------ + ValueError + The number of options exceeds 25. + """ + if self._underlying.type is ComponentType.string_select: + raise Exception("Default values can only be set on string selects") + + if len(self._underlying.default_values) > 25: + raise ValueError("maximum number of options already provided") + + self._underlying.default_values.append(default_value) + @property def values( self, @@ -471,6 +653,7 @@ def select( channel_types: list[ChannelType] = MISSING, disabled: bool = False, row: int | None = None, + default_values: list[User | Member | Role | GuildChannel | Thread] = MISSING, id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A decorator that attaches a select menu to a component. @@ -533,6 +716,11 @@ def select( ): raise TypeError("options may only be specified for string selects") + if default_values is not MISSING and select_type is ComponentType.string_select: + raise TypeError( + "default_values may only be specified for user/role/channel/mentionable selects" + ) + 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") @@ -554,6 +742,8 @@ def decorator(func: ItemCallbackType) -> ItemCallbackType: model_kwargs["options"] = options if channel_types: model_kwargs["channel_types"] = channel_types + if default_values: + model_kwargs["default_values"] = default_values func.__discord_ui_model_type__ = Select func.__discord_ui_model_kwargs__ = model_kwargs @@ -597,6 +787,7 @@ def user_select( custom_id: str | None = None, min_values: int = 1, max_values: int = 1, + default_values: list[Member | User] | None = None, disabled: bool = False, row: int | None = None, id: int | None = None, @@ -613,6 +804,7 @@ def user_select( max_values=max_values, disabled=disabled, row=row, + default_values=default_values, id=id, ) @@ -625,6 +817,7 @@ def role_select( max_values: int = 1, disabled: bool = False, row: int | None = None, + default_values: list[Role] | None = None, id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.role_select`. @@ -639,6 +832,7 @@ def role_select( max_values=max_values, disabled=disabled, row=row, + default_values=default_values, id=id, ) @@ -651,6 +845,7 @@ def mentionable_select( max_values: int = 1, disabled: bool = False, row: int | None = None, + default_values: list[Member | User | Role] | None = None, id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.mentionable_select`. @@ -665,6 +860,7 @@ def mentionable_select( max_values=max_values, disabled=disabled, row=row, + default_values=default_values, id=id, ) @@ -678,6 +874,7 @@ def channel_select( disabled: bool = False, channel_types: list[ChannelType] = MISSING, row: int | None = None, + default_values: list[GuildChannel | Thread] | None = None, id: int | None = None, ) -> Callable[[ItemCallbackType], ItemCallbackType]: """A shortcut for :meth:`discord.ui.select` with select type :attr:`discord.ComponentType.channel_select`. @@ -693,5 +890,6 @@ def channel_select( disabled=disabled, channel_types=channel_types, row=row, + default_values=default_values, id=id, ) diff --git a/docs/api/enums.rst b/docs/api/enums.rst index 1210990717..ea223ea12e 100644 --- a/docs/api/enums.rst +++ b/docs/api/enums.rst @@ -506,6 +506,24 @@ of :class:`enum.Enum`. Represents a channel select component. +.. class:: SelectMenuDefaultValueType + + Represents the type of a select menu default value. + + .. versionadded:: 2.7.1 + + .. attribute:: channel + + Represents a select menu default value of type channel. + + .. attribute:: role + + Represents a select menu default value of type role. + + .. attribute:: user + + Represents a select menu default value of type user. + .. class:: ButtonStyle Represents the style of the button component. diff --git a/examples/views/channel_select.py b/examples/views/channel_select.py index d00416f915..1b785383ee 100644 --- a/examples/views/channel_select.py +++ b/examples/views/channel_select.py @@ -33,6 +33,42 @@ async def channel_select(ctx: discord.ApplicationContext) -> None: await ctx.respond("Select channels:", view=view) +@bot.slash_command() +async def channel_select_default(ctx: discord.ApplicationContext) -> None: + """Sends a message with our dropdown that contains a channel select with default value set to the current channel.""" + + # Get the current channel + if ctx.channel: + default_values = [ctx.channel] + else: + default_values = None + + # Defines a simple View that allows the user to use the Select menu. + # In this view, we define the channel_select with `discord.ui.channel_select` + # Using the decorator automatically sets `select_type` to `discord.ComponentType.channel_select`. + # Default values are set to the current channel. + class DefaultValueSelect(discord.ui.View): + @discord.ui.channel_select( + placeholder="Select channels...", + min_values=1, + max_values=3, + default_values=default_values, + ) # Users can select a maximum of 3 channels in the dropdown + async def channel_select_dropdown_default( + self, select: discord.ui.Select, interaction: discord.Interaction + ) -> None: + await interaction.response.send_message( + f"You selected the following channels:" + + f", ".join(f"{channel.mention}" for channel in select.values) + ) + + # Create the view containing our dropdown + view = DefaultValueSelect() + + # Sending a message containing our View + await ctx.respond("Select channels:", view=view) + + @bot.event async def on_ready() -> None: print(f"Logged in as {bot.user} (ID: {bot.user.id})") diff --git a/examples/views/role_select.py b/examples/views/role_select.py index 89540b9663..98c1883908 100644 --- a/examples/views/role_select.py +++ b/examples/views/role_select.py @@ -33,6 +33,48 @@ async def role_select(ctx: discord.ApplicationContext) -> None: await ctx.respond("Select roles:", view=view) +@bot.slash_command() +async def role_select_default(ctx: discord.ApplicationContext) -> None: + """Sends a message with our dropdown that contains a role select with default values set to the user's roles.""" + + # Get the first three user's roles + if isinstance(ctx.author, discord.Member) and ctx.author.roles: + default_values = [] + for role in ctx.author.roles: + if role == ctx.author.guild.default_role: + continue + default_values.append(role) + if len(default_values) == 3: + break + else: + default_values = None + + # Defines a simple View that allows the user to use the Select menu. + # In this view, we define the role_select with `discord.ui.role_select` + # Using the decorator automatically sets `select_type` to `discord.ComponentType.role_select`. + # Default values are set to the first three user's roles. + class DefaultDropdownView(discord.ui.View): + @discord.ui.role_select( + placeholder="Select roles...", + min_values=1, + max_values=3, + default_values=default_values, + ) # Users can select a maximum of 3 roles in the dropdown + async def role_select_dropdown( + self, select: discord.ui.Select, interaction: discord.Interaction + ) -> None: + await interaction.response.send_message( + f"You selected the following roles:" + + f", ".join(f"{role.mention}" for role in select.values) + ) + + # Create the view containing our dropdown + view = DefaultDropdownView() + + # Sending a message containing our View + await ctx.respond("Select roles:", view=view) + + @bot.event async def on_ready() -> None: print(f"Logged in as {bot.user} (ID: {bot.user.id})")