diff --git a/common/src/main/kotlin/entity/DiscordComponent.kt b/common/src/main/kotlin/entity/DiscordComponent.kt index 85f452d274c3..feb3cf2967bc 100644 --- a/common/src/main/kotlin/entity/DiscordComponent.kt +++ b/common/src/main/kotlin/entity/DiscordComponent.kt @@ -3,6 +3,7 @@ package dev.kord.common.entity import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.optional.Optional import dev.kord.common.entity.optional.OptionalBoolean +import dev.kord.common.entity.optional.OptionalInt import kotlinx.serialization.KSerializer import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @@ -11,6 +12,7 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonNames /** * Represent a [intractable component within a message sent in Discord](https://discord.com/developers/docs/interactions/message-components#what-are-components). @@ -23,6 +25,10 @@ import kotlinx.serialization.encoding.Encoder * @property url a url for link-style buttons * @property disabled whether the button is disabled, default `false` * @property components a list of child components (for action rows) + * @property options the select menu options + * @property placeholder the placeholder text for the select menu + * @property minValues the minimum amount of [options] allowed + * @property maxValues the maximum amount of [options] allowed */ @KordPreview @Serializable @@ -35,7 +41,13 @@ data class DiscordComponent( val customId: Optional = Optional.Missing(), val url: Optional = Optional.Missing(), val disabled: OptionalBoolean = OptionalBoolean.Missing, - val components: Optional> = Optional.Missing() + val components: Optional> = Optional.Missing(), + val options: Optional> = Optional.Missing(), + val placeholder: Optional = Optional.Missing(), + @SerialName("min_values") + val minValues: OptionalInt = OptionalInt.Missing, + @SerialName("max_values") + val maxValues: OptionalInt = OptionalInt.Missing, ) /** @@ -62,6 +74,11 @@ sealed class ComponentType(val value: Int) { */ object Button : ComponentType(2) + /** + * A select menu for picking from choices. + */ + object SelectMenu : ComponentType(3) + companion object Serializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ComponentType", PrimitiveKind.INT) @@ -69,6 +86,7 @@ sealed class ComponentType(val value: Int) { when (val value = decoder.decodeInt()) { 1 -> ActionRow 2 -> Button + 3 -> SelectMenu else -> Unknown(value) } diff --git a/common/src/main/kotlin/entity/DiscordSelectOption.kt b/common/src/main/kotlin/entity/DiscordSelectOption.kt new file mode 100644 index 000000000000..62f94a704844 --- /dev/null +++ b/common/src/main/kotlin/entity/DiscordSelectOption.kt @@ -0,0 +1,23 @@ +package dev.kord.common.entity + +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.OptionalBoolean +import kotlinx.serialization.Serializable + +/** + * Represent a [select option structure](https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure). + * + * @param label the user-facing name of the option, max 25 characters + * @param value the dev-define value of the option, max 100 characters + * @param description an additional description of the option, max 50 characters + * @param emoji the emoji to show in the option + * @param default whether to render this option as selected by default + */ +@Serializable +class DiscordSelectOption( + val label: String, + val value: String, + val description: Optional = Optional.Missing(), + val emoji: Optional = Optional.Missing(), + val default: OptionalBoolean = OptionalBoolean.Missing, +) diff --git a/common/src/main/kotlin/entity/Interactions.kt b/common/src/main/kotlin/entity/Interactions.kt index 740aa920fdf0..3ef59142d86b 100644 --- a/common/src/main/kotlin/entity/Interactions.kt +++ b/common/src/main/kotlin/entity/Interactions.kt @@ -257,7 +257,8 @@ data class InteractionCallbackData( @SerialName("custom_id") val customId: Optional = Optional.Missing(), @SerialName("component_type") - val componentType: Optional = Optional.Missing() + val componentType: Optional = Optional.Missing(), + val values: Optional> = Optional.Missing(), ) @Serializable(with = Option.Serializer::class) diff --git a/common/src/test/kotlin/json/InteractionTest.kt b/common/src/test/kotlin/json/InteractionTest.kt index 1fad5365c8b4..bc5a8d63b97e 100644 --- a/common/src/test/kotlin/json/InteractionTest.kt +++ b/common/src/test/kotlin/json/InteractionTest.kt @@ -2,6 +2,7 @@ package json import dev.kord.common.annotation.KordPreview import dev.kord.common.entity.* +import dev.kord.common.entity.optional.Optional import dev.kord.common.entity.optional.filterList import dev.kord.common.entity.optional.orEmpty import kotlinx.coroutines.coroutineScope @@ -117,4 +118,23 @@ class InteractionTest { } } } -} \ No newline at end of file + + @Test + fun `select menu can be deserialized`() { + val text = file("selectmenu") + + val interaction = json.decodeFromString(DiscordInteraction.serializer(), text) + with(interaction) { + applicationId shouldBe "845027738276462632" + channelId shouldBe "772908445358620702" + with(data){ + componentType shouldBe ComponentType.SelectMenu + customId shouldBe "class_select_1" + values shouldBe listOf("mage", "rogue") + } + guildId shouldBe "772904309264089089" + id shouldBe "847587388497854464" + } + } + +} diff --git a/common/src/test/kotlin/json/Util.kt b/common/src/test/kotlin/json/Util.kt index d02edbcfdc28..9d9ce38ab92c 100644 --- a/common/src/test/kotlin/json/Util.kt +++ b/common/src/test/kotlin/json/Util.kt @@ -60,6 +60,10 @@ infix fun OptionalInt?.shouldBe(value: Int?){ Assertions.assertEquals(value, this.value) } +infix fun Optional.shouldBe(that: T?) { + Assertions.assertEquals(that, this.value) +} + infix fun T.shouldBe(that: T) { Assertions.assertEquals(that, this) } diff --git a/common/src/test/resources/json/interaction/selectmenu.json b/common/src/test/resources/json/interaction/selectmenu.json new file mode 100644 index 000000000000..cb5e41a10b1e --- /dev/null +++ b/common/src/test/resources/json/interaction/selectmenu.json @@ -0,0 +1,119 @@ +{ + "application_id": "845027738276462632", + "channel_id": "772908445358620702", + "data": { + "component_type":3, + "custom_id": "class_select_1", + "values": [ + "mage", + "rogue" + ] + }, + "guild_id": "772904309264089089", + "id": "847587388497854464", + "member": { + "avatar": null, + "deaf": false, + "is_pending": false, + "joined_at": "2020-11-02T19:25:47.248000+00:00", + "mute": false, + "nick": "Bot Man", + "pending": false, + "permissions": "17179869183", + "premium_since": null, + "roles": [ + "785609923542777878" + ], + "user":{ + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", + "discriminator": "1337", + "id": "53908232506183680", + "public_flags": 131141, + "username": "Mason" + } + }, + "message":{ + "application_id": "845027738276462632", + "attachments": [], + "author": { + "avatar": null, + "bot": true, + "discriminator": "5284", + "id": "845027738276462632", + "public_flags": 0, + "username": "Interactions Test" + }, + "channel_id": "772908445358620702", + "components": [ + { + "components": [ + { + "custom_id": "class_select_1", + "max_values": 1, + "min_values": 1, + "options": [ + { + "description": "Sneak n stab", + "emoji":{ + "id": "625891304148303894", + "name": "rogue" + }, + "label": "Rogue", + "value": "rogue" + }, + { + "description": "Turn 'em into a sheep", + "emoji":{ + "id": "625891304081063986", + "name": "mage" + }, + "label": "Mage", + "value": "mage" + }, + { + "description": "You get heals when I'm done doing damage", + "emoji":{ + "id": "625891303795982337", + "name": "priest" + }, + "label": "Priest", + "value": "priest" + } + ], + "placeholder": "Choose a class", + "type": 3 + } + ], + "type": 1 + } + ], + "content": "Mason is looking for new arena partners. What classes do you play?", + "edited_timestamp": null, + "embeds": [], + "flags": 0, + "id": "847587334500646933", + "interaction": { + "id": "847587333942935632", + "name": "dropdown", + "type": 2, + "user": { + "avatar": "a_d5efa99b3eeaa7dd43acca82f5692432", + "discriminator": "1337", + "id": "53908232506183680", + "public_flags": 131141, + "username": "Mason" + } + }, + "mention_everyone": false, + "mention_roles":[], + "mentions":[], + "pinned": false, + "timestamp": "2021-05-27T21:29:27.956000+00:00", + "tts": false, + "type": 20, + "webhook_id": "845027738276462632" + }, + "token": "UNIQUE_TOKEN", + "type": 3, + "version": 1 +} diff --git a/core/src/main/kotlin/cache/data/ComponentData.kt b/core/src/main/kotlin/cache/data/ComponentData.kt index db68257d90e6..ca4376bcf5aa 100644 --- a/core/src/main/kotlin/cache/data/ComponentData.kt +++ b/core/src/main/kotlin/cache/data/ComponentData.kt @@ -1,11 +1,9 @@ package dev.kord.core.cache.data -import dev.kord.common.entity.ButtonStyle -import dev.kord.common.entity.ComponentType -import dev.kord.common.entity.DiscordComponent -import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.* import dev.kord.common.entity.optional.Optional import dev.kord.common.entity.optional.OptionalBoolean +import dev.kord.common.entity.optional.OptionalInt import dev.kord.common.entity.optional.mapList import kotlinx.serialization.Serializable @@ -19,7 +17,11 @@ data class ComponentData( val customId: Optional = Optional.Missing(), val url: Optional = Optional.Missing(), val disabled: OptionalBoolean = OptionalBoolean.Missing, - val components: Optional> = Optional.Missing() + val components: Optional> = Optional.Missing(), + val placeholder: Optional = Optional.Missing(), + val minValues: OptionalInt = OptionalInt.Missing, + val maxValues: OptionalInt = OptionalInt.Missing, + val options: Optional> = Optional.Missing() ) { companion object { @@ -33,7 +35,10 @@ data class ComponentData( customId, url, disabled, - components.mapList { from(it) } + components.mapList { from(it) }, + placeholder = placeholder, + minValues = minValues, + maxValues = maxValues ) } diff --git a/core/src/main/kotlin/cache/data/InteractionData.kt b/core/src/main/kotlin/cache/data/InteractionData.kt index 4884442ee9d5..5033b51ece4a 100644 --- a/core/src/main/kotlin/cache/data/InteractionData.kt +++ b/core/src/main/kotlin/cache/data/InteractionData.kt @@ -94,7 +94,8 @@ data class ApplicationInteractionData( val options: Optional> = Optional.Missing(), val resolvedObjectsData: Optional = Optional.Missing(), val customId: Optional = Optional.Missing(), - val componentType: Optional = Optional.Missing() + val componentType: Optional = Optional.Missing(), + val values: Optional> = Optional.Missing() ) { companion object { @@ -109,7 +110,8 @@ data class ApplicationInteractionData( options.map { it.map { OptionData.from(it) } }, resolved.map { ResolvedObjectsData.from(it, guildId) }, customId, - componentType + componentType, + values = values, ) } } @@ -150,5 +152,3 @@ object NotSerializable : KSerializer { override val descriptor: SerialDescriptor = String.serializer().descriptor override fun serialize(encoder: Encoder, value: Any?) = error("This operation is not supported.") } - - diff --git a/core/src/main/kotlin/cache/data/SelectOptionData.kt b/core/src/main/kotlin/cache/data/SelectOptionData.kt new file mode 100644 index 000000000000..169c0c2f0509 --- /dev/null +++ b/core/src/main/kotlin/cache/data/SelectOptionData.kt @@ -0,0 +1,32 @@ +package dev.kord.core.cache.data + +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.DiscordSelectOption +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.OptionalBoolean +import kotlinx.serialization.Serializable + +@Serializable +data class SelectOptionData( + val label: String, + val value: String, + val description: Optional = Optional.Missing(), + val emoji: Optional = Optional.Missing(), + val default: OptionalBoolean = OptionalBoolean.Missing +) { + + companion object { + + fun from(entity: DiscordSelectOption): SelectOptionData = with(entity){ + SelectOptionData( + label = label, + value = value, + description = description, + emoji = emoji, + default = default + ) + } + + } + +} diff --git a/core/src/main/kotlin/entity/component/ActionRowComponent.kt b/core/src/main/kotlin/entity/component/ActionRowComponent.kt index 98a10f5f1d0f..7ef68e1594ab 100644 --- a/core/src/main/kotlin/entity/component/ActionRowComponent.kt +++ b/core/src/main/kotlin/entity/component/ActionRowComponent.kt @@ -14,14 +14,25 @@ class ActionRowComponent(override val data: ComponentData) : Component { override val type: ComponentType get() = ComponentType.ActionRow + /** + * All components nested inside this component. + */ + val components: List + get() = data.components.orEmpty().map { Component(it) } /** - * The buttons that are nested inside this component + * The buttons that are nested inside this component. + * @see components */ val buttons: List - get() = data.components.orEmpty() - .filter { it.type == ComponentType.Button } - .map { ButtonComponent(it) } + get() = components.filterIsInstance() + + /** + * The buttons that are nested inside this component. + * @see components + */ + val selectMenus: List + get() = components.filterIsInstance() override fun toString(): String = "ActionRowComponent(data=$data)" diff --git a/core/src/main/kotlin/entity/component/ButtonComponent.kt b/core/src/main/kotlin/entity/component/ButtonComponent.kt index 52b66516bc4b..cd9b43dc21f5 100644 --- a/core/src/main/kotlin/entity/component/ButtonComponent.kt +++ b/core/src/main/kotlin/entity/component/ButtonComponent.kt @@ -8,6 +8,7 @@ import dev.kord.core.cache.data.ComponentData import dev.kord.core.entity.ReactionEmoji import dev.kord.core.entity.interaction.ComponentInteraction import dev.kord.core.event.interaction.InteractionCreateEvent +import dev.kord.core.entity.interaction.ButtonInteraction /** * An interactive component rendered on a Message. @@ -47,7 +48,7 @@ class ButtonComponent(override val data: ComponentData) : Component { } /** - * The custom identifier for any [ComponentInteractions][ComponentInteraction] + * The custom identifier for any [ComponentInteractions][ButtonInteraction] * this button will trigger. Present if this button is not a link button. */ val customId: String? get() = data.customId.value diff --git a/core/src/main/kotlin/entity/component/Component.kt b/core/src/main/kotlin/entity/component/Component.kt index e32f80ea8d3b..fc9c68b84b67 100644 --- a/core/src/main/kotlin/entity/component/Component.kt +++ b/core/src/main/kotlin/entity/component/Component.kt @@ -15,6 +15,7 @@ sealed interface Component { * The type of component. * @see ButtonComponent * @see ActionRowComponent + * @see SelectMenuComponent * @see UnknownComponent */ val type: ComponentType get() = data.type @@ -26,11 +27,13 @@ sealed interface Component { * Creates a [Component] from the [data]. * @see ActionRowComponent * @see ButtonComponent + * @see SelectMenuComponent * @see UnknownComponent */ @KordPreview fun Component(data: ComponentData): Component = when (data.type) { ComponentType.ActionRow -> ActionRowComponent(data) ComponentType.Button -> ButtonComponent(data) + ComponentType.SelectMenu -> SelectMenuComponent(data) is ComponentType.Unknown -> UnknownComponent(data) } diff --git a/core/src/main/kotlin/entity/component/SelectMenuComponent.kt b/core/src/main/kotlin/entity/component/SelectMenuComponent.kt new file mode 100644 index 000000000000..f60c8f184400 --- /dev/null +++ b/core/src/main/kotlin/entity/component/SelectMenuComponent.kt @@ -0,0 +1,99 @@ +package dev.kord.core.entity.component + +import dev.kord.common.annotation.KordPreview +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.optional.orEmpty +import dev.kord.common.entity.optional.value +import dev.kord.core.cache.data.ComponentData +import dev.kord.core.cache.data.SelectOptionData +import dev.kord.core.entity.Message +import dev.kord.core.entity.interaction.SelectMenuInteraction + +/** + * An interactive dropdown menu rendered on a [Message] that consists of multiple [options]. + */ +@KordPreview +class SelectMenuComponent(override val data: ComponentData) : Component { + + /** + * The custom identifier for any [ComponentInteractions][SelectMenuInteraction] + * this select menu will trigger. + */ + val customId: String get() = data.customId.value!! + + /** + * The placeholder value if no value has been selected, null if not set. + */ + val placeholder: String? get() = data.placeholder.value + + /** + * The possible options to choose from. + */ + val options: List get() = data.options.orEmpty().map { SelectOption(it) } + + /** + * The minimum amount of [options] that can be chosen, default `1`. + */ + val minValues: Int get() = data.minValues.orElse(1) + + /** + * The maximum amount of [options] that can be chosen, default `1`. + */ + val maxValues: Int get() = data.maxValues.orElse(1) + + override fun equals(other: Any?): Boolean { + if (other !is SelectMenuComponent) return false + + return other.data == data + } + + override fun hashCode(): Int { + return data.hashCode() + } + + override fun toString(): String = "SelectMenuComponent(data=$data)" +} + +/** + * An option in a [SelectMenuComponent]. + */ +class SelectOption(val data: SelectOptionData) { + + /** + * The user-facing name of the option, max 25 characters. + */ + val label: String get() = data.label + + /** + * The dev-define value of the option, max 100 characters. + */ + val value: String get() = data.value + + /** + * An additional description of the option, max 50 characters. Null if not set. + */ + val description: String? get() = data.description.value + + /** + * The emoji to show in the option. Null if not set. + */ + val emoji: DiscordPartialEmoji? = data.emoji.value + + /** + * Whether this option is selected by default. + */ + val default: Boolean? = data.default.value + + override fun equals(other: Any?): Boolean { + if (other !is SelectOption) return false + + return other.data == data + } + + override fun hashCode(): Int { + return data.hashCode() + } + + override fun toString(): String = "SelectOption(data=$data)" + +} diff --git a/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt b/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt new file mode 100644 index 000000000000..b90cc839fbd6 --- /dev/null +++ b/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt @@ -0,0 +1,137 @@ +package dev.kord.core.entity.interaction + +import dev.kord.common.annotation.KordPreview +import dev.kord.common.entity.ComponentType +import dev.kord.common.entity.Snowflake +import dev.kord.common.entity.optional.orEmpty +import dev.kord.common.entity.optional.unwrap +import dev.kord.core.Kord +import dev.kord.core.behavior.UserBehavior +import dev.kord.core.behavior.interaction.ComponentInteractionBehavior +import dev.kord.core.cache.data.InteractionData +import dev.kord.core.entity.Message +import dev.kord.core.entity.component.ActionRowComponent +import dev.kord.core.entity.component.ButtonComponent +import dev.kord.core.entity.component.Component +import dev.kord.core.entity.component.SelectMenuComponent +import dev.kord.core.supplier.EntitySupplier +import dev.kord.core.supplier.EntitySupplyStrategy +import dev.kord.rest.builder.component.SelectMenuBuilder + +/** + * An interaction created from a user interaction with a [Component]. + * + * @see ButtonInteraction + * @see SelectMenuInteraction + */ +@KordPreview +sealed class ComponentInteraction : Interaction(), ComponentInteractionBehavior { + + override val user: UserBehavior + get() = UserBehavior(data.member.value!!.userId, kord) + + /** + * The message that contains the interacted component, null if the message is ephemeral. + */ + val message: Message? + get() = data.message.unwrap { Message(it, kord, supplier) } + + /** + * The [ButtonComponent.customId] or [SelectMenuComponent.customId] that triggered the interaction. + */ + val componentId: String get() = data.data.customId.value!! + + /** + * The [Component] the user interacted with, null if the message is ephemeral. + */ + abstract val component: Component? + + abstract override fun withStrategy(strategy: EntitySupplyStrategy<*>): ComponentInteraction + + abstract override fun toString(): String + + override fun equals(other: Any?): Boolean { + if (other !is Interaction) return false + + return other.data == data + } + + override fun hashCode(): Int = data.hashCode() +} + +/** + * Creates a [ComponentInteraction] with the given [data], [applicationId], [kord] and [supplier]. + * + * @throws IllegalArgumentException if the interaction is not from a [ButtonComponent] or a [SelectMenuComponent]. + */ +@KordPreview +fun ComponentInteraction( + data: InteractionData, + applicationId: Snowflake, + kord: Kord, + supplier: EntitySupplier = kord.defaultSupplier, +): ComponentInteraction = when (val type = data.data.componentType.value) { + ComponentType.Button -> ButtonInteraction(data, applicationId, kord, supplier) + ComponentType.SelectMenu -> SelectMenuInteraction(data, applicationId, kord, supplier) + else -> throw IllegalArgumentException("unknown component type for interaction: $type") +} + +/** + * An interaction created from a user pressing a [ButtonComponent]. + */ +@KordPreview +class ButtonInteraction( + override val data: InteractionData, + override val applicationId: Snowflake, + override val kord: Kord, + override val supplier: EntitySupplier +) : ComponentInteraction() { + + override val component: ButtonComponent? + get() = message?.components.orEmpty() + .filterIsInstance() + .flatMap { it.buttons } + .firstOrNull { it.customId == componentId } + + override fun withStrategy(strategy: EntitySupplyStrategy<*>): ButtonInteraction { + return ButtonInteraction(data, applicationId, kord, strategy.supply(kord)) + } + + override fun toString(): String = + "ButtonInteraction(data=$data, applicationId=$applicationId, kord=$kord, supplier=$supplier, user=$user)" + +} + +/** + * An interaction created from a user interacting with a [SelectMenuComponent]. + */ +@KordPreview +class SelectMenuInteraction( + override val data: InteractionData, + override val applicationId: Snowflake, + override val kord: Kord, + override val supplier: EntitySupplier +) : ComponentInteraction() { + + /** + * The selected values, the expected range should between 0 and 25. + * + * @see [SelectMenuBuilder.minimumValues] + * @see [SelectMenuBuilder.maximumValues] + */ + val values: List get() = data.data.values.orEmpty() + + override val component: SelectMenuComponent? + get() = message?.components.orEmpty() + .filterIsInstance() + .flatMap { it.selectMenus } + .firstOrNull { it.customId == componentId } + + override fun withStrategy(strategy: EntitySupplyStrategy<*>): SelectMenuInteraction { + return SelectMenuInteraction(data, applicationId, kord, strategy.supply(kord)) + } + + override fun toString(): String = + "SelectMenuInteraction(data=$data, applicationId=$applicationId, kord=$kord, supplier=$supplier, user=$user)" + +} diff --git a/core/src/main/kotlin/entity/interaction/Interaction.kt b/core/src/main/kotlin/entity/interaction/Interaction.kt index 096d1784ddb3..6e4b7eeb8b67 100644 --- a/core/src/main/kotlin/entity/interaction/Interaction.kt +++ b/core/src/main/kotlin/entity/interaction/Interaction.kt @@ -350,54 +350,6 @@ class DmInteraction( DmInteraction(data, applicationId, kord, strategy.supply(kord)) } -/** - * An [Interaction] that was made with a [Component]. - */ -@KordPreview -class ComponentInteraction( - override val data: InteractionData, - override val applicationId: Snowflake, - override val kord: Kord, - override val supplier: EntitySupplier -) : Interaction(), ComponentInteractionBehavior { - - override val user: UserBehavior = UserBehavior(data.member.value!!.userId, kord) - - /** - * The message that contains the interacted component, null if the message is ephemeral. - */ - val message: Message? - get() = data.message.unwrap { - Message(it, kord, supplier) - } - - /** - * The [ButtonComponent.customId] that triggered the interaction. - */ - val componentId: String get() = data.data.customId.value!! - - /** - * The [ButtonComponent] the user interacted with, null if the message is ephemeral. - * - * @see Component - */ - val component: ButtonComponent? - get() = message?.components.orEmpty() - .filterIsInstance() - .flatMap { it.buttons } - .firstOrNull { it.customId == componentId } - - - override fun withStrategy(strategy: EntitySupplyStrategy<*>): ComponentInteraction = ComponentInteraction( - data, applicationId, kord, strategy.supply(kord) - ) - - override fun toString(): String { - return "ComponentInteraction(data=$data, applicationId=$applicationId, kord=$kord, supplier=$supplier, user=$user)" - } - -} - @KordPreview class GuildInteraction( override val data: InteractionData, diff --git a/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt b/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt index 074b1a5bea31..a0817c80ecd8 100644 --- a/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt +++ b/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt @@ -44,6 +44,19 @@ class ActionRowBuilder : MessageComponentBuilder { ) } + /** + * Creates and adds a select menu with the [customId] and configured by the [builder]. + * An ActionRow with a select menu cannot have any other select menus or buttons. + */ + @OptIn(ExperimentalContracts::class) + inline fun selectMenu(customId: String, builder: SelectMenuBuilder.() -> Unit){ + contract { + callsInPlace(builder, InvocationKind.EXACTLY_ONCE) + } + + components.add(SelectMenuBuilder(customId).apply(builder)) + } + override fun build(): DiscordComponent = DiscordComponent( ComponentType.ActionRow, diff --git a/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt b/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt new file mode 100644 index 000000000000..60ea5df0f39f --- /dev/null +++ b/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt @@ -0,0 +1,72 @@ +package dev.kord.rest.builder.component + +import dev.kord.common.annotation.KordPreview +import dev.kord.common.entity.ComponentType +import dev.kord.common.entity.DiscordComponent +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.OptionalInt +import dev.kord.common.entity.optional.delegate.delegate +import kotlin.contracts.ExperimentalContracts +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +/** + * A builder for a + * [Discord Select Menu](https://discord.com/developers/docs/interactions/message-components#select-menus). + * + * @param customId The identifier for the menu, max 100 characters. + */ +@KordPreview +class SelectMenuBuilder( + var customId: String +) : ActionRowComponentBuilder { + + /** + * The choices in the select, max 25. + */ + val options: MutableList = mutableListOf() + + /** + * The range of values that can be accepted. Accepts any range between [0,25]. + * Defaults to `1..1`. + */ + var allowedValues: IntRange = 1..1 + + + private var _placeholder: Optional = Optional.Missing() + + /** + * Custom placeholder if no value is selected, max 100 characters. + * + * [Option defaults][SelectOptionBuilder.default] have priority over placeholders, + * if any option is marked as default then that label will be shown instead. + */ + var placeholder: String? by ::_placeholder.delegate() + + /** + * Adds a new option to the select menu with the given [label] and [value] and configured by the [builder]. + * + * @param label The user-facing name of the option, max 25 characters. + * @param value The dev-define value of the option, max 100 characters. + */ + @OptIn(ExperimentalContracts::class) + inline fun option(label: String, value: String, builder: SelectOptionBuilder.() -> Unit = {}) { + contract { + callsInPlace(builder, InvocationKind.EXACTLY_ONCE) + } + + options.add(SelectOptionBuilder(label = label, value = value).apply(builder)) + } + + override fun build(): DiscordComponent { + return DiscordComponent( + ComponentType.SelectMenu, + customId = Optional(customId), + placeholder = _placeholder, + minValues = OptionalInt.Value(allowedValues.first), + maxValues = OptionalInt.Value(allowedValues.last), + options = Optional(options.map { it.build() }) + ) + } + +} diff --git a/rest/src/main/kotlin/builder/component/SelectOptionBuilder.kt b/rest/src/main/kotlin/builder/component/SelectOptionBuilder.kt new file mode 100644 index 000000000000..6a253316fc82 --- /dev/null +++ b/rest/src/main/kotlin/builder/component/SelectOptionBuilder.kt @@ -0,0 +1,53 @@ +package dev.kord.rest.builder.component + +import dev.kord.common.annotation.KordPreview +import dev.kord.common.entity.DiscordPartialEmoji +import dev.kord.common.entity.DiscordSelectOption +import dev.kord.common.entity.optional.Optional +import dev.kord.common.entity.optional.OptionalBoolean +import dev.kord.common.entity.optional.delegate.delegate + +/** + * A builder for a + * [Discord Select Option](https://discord.com/developers/docs/interactions/message-components#select-menu-object-select-option-structure). + * + * @param label The user-facing name of the option, max 25 characters. + * @param value The dev-define value of the option, max 100 characters. + */ +@KordPreview +class SelectOptionBuilder( + var label: String, + var value: String +) { + + private var _description: Optional = Optional.Missing() + + /** + * An additional description of the option, max 50 characters. + */ + var description by ::_description.delegate() + + private var _emoji: Optional = Optional.Missing() + + /** + * An emoji to display in the option. + */ + var emoji: DiscordPartialEmoji? by ::_emoji.delegate() + + + private var _default: OptionalBoolean = OptionalBoolean.Missing + + /** + * Whether this option should be rendered as the default. + */ + var default: Boolean? by ::_default.delegate() + + fun build(): DiscordSelectOption = DiscordSelectOption( + label = label, + value = value, + description = _description, + emoji = _emoji, + default = _default + ) + +}