diff --git a/common/src/main/kotlin/entity/DiscordComponent.kt b/common/src/main/kotlin/entity/DiscordComponent.kt index a48b8780960e..2d918089a65b 100644 --- a/common/src/main/kotlin/entity/DiscordComponent.kt +++ b/common/src/main/kotlin/entity/DiscordComponent.kt @@ -11,13 +11,17 @@ import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonContentPolymorphicSerializer +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive /** - * Represent a [intractable component within a message sent in Discord](https://discord.com/developers/docs/interactions/message-components#what-are-components). + * Represent a [interactable component within a message sent in Discord](https://discord.com/developers/docs/interactions/message-components#what-are-components). * * @property type the [ComponentType] of the component * @property style the [ButtonStyle] of the component (if it is a button) - * @property style the text that appears on the button (if the component is a button) * @property emoji an [DiscordPartialEmoji] that appears on the button (if the component is a button) * @property customId a developer-defined identifier for the button, max 100 characters * @property url a url for link-style buttons @@ -27,36 +31,103 @@ import kotlinx.serialization.encoding.Encoder * @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 + * @property minLength the minimum input length for a text input, min 0, max 4000. + * @property maxLength the maximum input length for a text input, min 1, max 4000. + * @property required whether this component is required to be filled, default false. + * @property value a pre-filled value for this component, max 4000 characters. */ +@Serializable(with = DiscordComponent.Serializer::class) +public sealed class DiscordComponent { + public abstract val type: ComponentType + public abstract val label: Optional + public abstract val emoji: Optional + @SerialName("custom_id") + public abstract val customId: Optional + public abstract val url: Optional + public abstract val disabled: OptionalBoolean + public abstract val components: Optional> + public abstract val options: Optional> + public abstract val placeholder: Optional + @SerialName("min_values") + public abstract val minValues: OptionalInt + @SerialName("max_values") + public abstract val maxValues: OptionalInt + @SerialName("min_length") + public abstract val minLength: OptionalInt + @SerialName("max_length") + public abstract val maxLength: OptionalInt + public abstract val required: OptionalBoolean + public abstract val value: Optional + + internal object Serializer : JsonContentPolymorphicSerializer(DiscordComponent::class) { + override fun selectDeserializer(element: JsonElement): KSerializer { + val componentType = element.jsonObject["type"]?.jsonPrimitive?.intOrNull ?: error("Missing component type ID!") + + return when (componentType) { + ComponentType.TextInput.value -> DiscordModalComponent.serializer() + else -> DiscordChatComponent.serializer() + } + } + } +} + +@Serializable +public data class DiscordChatComponent( + public override val type: ComponentType, + public val style: Optional = Optional.Missing(), + public override val label: Optional = Optional.Missing(), + public override val emoji: Optional = Optional.Missing(), + @SerialName("custom_id") + public override val customId: Optional = Optional.Missing(), + public override val url: Optional = Optional.Missing(), + public override val disabled: OptionalBoolean = OptionalBoolean.Missing, + public override val components: Optional> = Optional.Missing(), + public override val options: Optional> = Optional.Missing(), + public override val placeholder: Optional = Optional.Missing(), + @SerialName("min_values") + public override val minValues: OptionalInt = OptionalInt.Missing, + @SerialName("max_values") + public override val maxValues: OptionalInt = OptionalInt.Missing, + @SerialName("min_length") + public override val minLength: OptionalInt = OptionalInt.Missing, + @SerialName("max_length") + public override val maxLength: OptionalInt = OptionalInt.Missing, + public override val required: OptionalBoolean = OptionalBoolean.Missing, + public override val value: Optional = Optional.Missing() +) : DiscordComponent() @Serializable -public data class DiscordComponent( - val type: ComponentType, - val style: Optional = Optional.Missing(), - val label: Optional = Optional.Missing(), - val emoji: Optional = Optional.Missing(), +public data class DiscordModalComponent( + public override val type: ComponentType, + public val style: Optional = Optional.Missing(), + public override val label: Optional = Optional.Missing(), + public override val emoji: Optional = Optional.Missing(), @SerialName("custom_id") - val customId: Optional = Optional.Missing(), - val url: Optional = Optional.Missing(), - val disabled: OptionalBoolean = OptionalBoolean.Missing, - val components: Optional> = Optional.Missing(), - val options: Optional> = Optional.Missing(), - val placeholder: Optional = Optional.Missing(), + public override val customId: Optional = Optional.Missing(), + public override val url: Optional = Optional.Missing(), + public override val disabled: OptionalBoolean = OptionalBoolean.Missing, + public override val components: Optional> = Optional.Missing(), + public override val options: Optional> = Optional.Missing(), + public override val placeholder: Optional = Optional.Missing(), @SerialName("min_values") - val minValues: OptionalInt = OptionalInt.Missing, + public override val minValues: OptionalInt = OptionalInt.Missing, @SerialName("max_values") - val maxValues: OptionalInt = OptionalInt.Missing, -) + public override val maxValues: OptionalInt = OptionalInt.Missing, + @SerialName("min_length") + public override val minLength: OptionalInt = OptionalInt.Missing, + @SerialName("max_length") + public override val maxLength: OptionalInt = OptionalInt.Missing, + public override val required: OptionalBoolean = OptionalBoolean.Missing, + public override val value: Optional = Optional.Missing() +) : DiscordComponent() /** * Representation of different [DiscordComponent] types. * * @property value the raw type value used by the Discord API */ - @Serializable(with = ComponentType.Serializer::class) public sealed class ComponentType(public val value: Int) { - /** * Fallback type used for types that haven't been added to Kord yet. */ @@ -77,6 +148,11 @@ public sealed class ComponentType(public val value: Int) { */ public object SelectMenu : ComponentType(3) + /** + * A text input object. + */ + public object TextInput : ComponentType(4) + public companion object Serializer : KSerializer { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ComponentType", PrimitiveKind.INT) @@ -85,6 +161,7 @@ public sealed class ComponentType(public val value: Int) { 1 -> ActionRow 2 -> Button 3 -> SelectMenu + 4 -> TextInput else -> Unknown(value) } @@ -99,7 +176,6 @@ public sealed class ComponentType(public val value: Int) { * * @see ComponentType.Button */ - @Serializable(with = ButtonStyle.Serializer::class) public sealed class ButtonStyle(public val value: Int) { @@ -139,7 +215,7 @@ public sealed class ButtonStyle(public val value: Int) { public object Link : ButtonStyle(5) public companion object Serializer : KSerializer { - override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ButtonStyle", PrimitiveKind.INT) + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Button", PrimitiveKind.INT) override fun deserialize(decoder: Decoder): ButtonStyle = when (val value = decoder.decodeInt()) { @@ -154,3 +230,39 @@ public sealed class ButtonStyle(public val value: Int) { override fun serialize(encoder: Encoder, value: ButtonStyle): Unit = encoder.encodeInt(value.value) } } + +/** + * Representation of different TextInputStyles. + * + * @see ComponentType.TextInput + */ +@Serializable(with = TextInputStyle.Serializer::class) +public sealed class TextInputStyle(public val value: Int) { + /** + * A fallback style used for styles that haven't been added to Kord yet. + */ + public class Unknown(value: Int) : TextInputStyle(value) + + /** + * A single-line input. + */ + public object Short : TextInputStyle(1) + + /** + * A multi-line input. + */ + public object Paragraph : TextInputStyle(2) + + public companion object Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("TextInput", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): TextInputStyle = + when (val value = decoder.decodeInt()) { + 1 -> Short + 2 -> Paragraph + else -> Unknown(value) + } + + override fun serialize(encoder: Encoder, value: TextInputStyle): Unit = encoder.encodeInt(value.value) + } +} \ No newline at end of file diff --git a/common/src/main/kotlin/entity/Interactions.kt b/common/src/main/kotlin/entity/Interactions.kt index bb3c5cc74698..d51e10d11e93 100644 --- a/common/src/main/kotlin/entity/Interactions.kt +++ b/common/src/main/kotlin/entity/Interactions.kt @@ -254,6 +254,7 @@ public sealed class InteractionType(public val type: Int) { public object Component : InteractionType(3) public object AutoComplete : InteractionType(4) + public object ModalSubmit : InteractionType(5) public class Unknown(type: Int) : InteractionType(type) override fun toString(): String = when (this) { @@ -261,6 +262,7 @@ public sealed class InteractionType(public val type: Int) { ApplicationCommand -> "InteractionType.ApplicationCommand($type)" Component -> "InteractionType.ComponentInvoke($type)" AutoComplete -> "InteractionType.AutoComplete($type)" + ModalSubmit -> "InteractionType.ModalSubmit($type)" is Unknown -> "InteractionType.Unknown($type)" } @@ -275,6 +277,7 @@ public sealed class InteractionType(public val type: Int) { 2 -> ApplicationCommand 3 -> Component 4 -> AutoComplete + 5 -> ModalSubmit else -> Unknown(type) } } @@ -300,6 +303,7 @@ public data class InteractionCallbackData( @SerialName("component_type") val componentType: Optional = Optional.Missing(), val values: Optional> = Optional.Missing(), + val components: Optional> = Optional.Missing() ) @Serializable(with = Option.Serializer::class) @@ -673,6 +677,7 @@ public sealed class InteractionResponseType(public val type: Int) { public object DeferredUpdateMessage : InteractionResponseType(6) public object UpdateMessage : InteractionResponseType(7) public object ApplicationCommandAutoCompleteResult : InteractionResponseType(8) + public object Modal : InteractionResponseType(9) public class Unknown(type: Int) : InteractionResponseType(type) internal object Serializer : KSerializer { @@ -749,3 +754,11 @@ public data class DiscordGuildApplicationCommandPermission( public data class DiscordAutoComplete( val choices: List> ) + +@Serializable +public data class DiscordModal( + val title: String, + @SerialName("custom_id") + val customId: String, + val components: List, +) diff --git a/core/src/main/kotlin/cache/data/ComponentData.kt b/core/src/main/kotlin/cache/data/ComponentData.kt index dfa5acd07fb1..2b57d36c80b4 100644 --- a/core/src/main/kotlin/cache/data/ComponentData.kt +++ b/core/src/main/kotlin/cache/data/ComponentData.kt @@ -8,41 +8,110 @@ import dev.kord.common.entity.optional.mapList import kotlinx.serialization.Serializable @Serializable -public data class ComponentData( - val type: ComponentType, - val style: Optional = Optional.Missing(), - val label: Optional = Optional.Missing(), +public sealed class ComponentData { + public abstract val type: ComponentType + public abstract val label: Optional //TODO: turn this emoji into a EmojiData, it's lacking the guild id - val emoji: Optional = Optional.Missing(), - val customId: Optional = Optional.Missing(), - val url: Optional = Optional.Missing(), - val disabled: OptionalBoolean = OptionalBoolean.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() -) { + public abstract val emoji: Optional + public abstract val customId: Optional + public abstract val url: Optional + public abstract val disabled: OptionalBoolean + public abstract val components: Optional> + public abstract val placeholder: Optional + public abstract val minValues: OptionalInt + public abstract val maxValues: OptionalInt + public abstract val options: Optional> + public abstract val minLength: OptionalInt + public abstract val maxLength: OptionalInt + public abstract val required: OptionalBoolean + public abstract val value: Optional public companion object { - - public fun from(entity: DiscordComponent): ComponentData = with(entity) { - ComponentData( - type, - style, - label, - emoji, - customId, - url, - disabled, - components.mapList { from(it) }, - placeholder = placeholder, - minValues = minValues, - maxValues = maxValues, - options = options.mapList { SelectOptionData.from(it) } - ) + public fun from(entity: DiscordComponent): ComponentData = with (entity) { + when (entity) { + is DiscordChatComponent -> { + ChatComponentData( + type, + entity.style, + label, + emoji, + customId, + url, + disabled, + components.mapList { from(it) }, + placeholder = placeholder, + minValues = minValues, + maxValues = maxValues, + options = options.mapList { SelectOptionData.from(it) }, + minLength = minLength, + maxLength = maxLength, + required = required, + value = value + ) + } + is DiscordModalComponent -> { + ModalComponentData( + type, + entity.style, + label, + emoji, + customId, + url, + disabled, + components.mapList { from(it) }, + placeholder = placeholder, + minValues = minValues, + maxValues = maxValues, + options = options.mapList { SelectOptionData.from(it) }, + minLength = minLength, + maxLength = maxLength, + required = required, + value = value + ) + } + } } - } - } + +@Serializable +public data class ChatComponentData( + override val type: ComponentType, + val style: Optional = Optional.Missing(), + override val label: Optional = Optional.Missing(), + //TODO: turn this emoji into a EmojiData, it's lacking the guild id + override val emoji: Optional = Optional.Missing(), + override val customId: Optional = Optional.Missing(), + override val url: Optional = Optional.Missing(), + override val disabled: OptionalBoolean = OptionalBoolean.Missing, + override val components: Optional> = Optional.Missing(), + override val placeholder: Optional = Optional.Missing(), + override val minValues: OptionalInt = OptionalInt.Missing, + override val maxValues: OptionalInt = OptionalInt.Missing, + override val options: Optional> = Optional.Missing(), + override val minLength: OptionalInt = OptionalInt.Missing, + override val maxLength: OptionalInt = OptionalInt.Missing, + override val required: OptionalBoolean = OptionalBoolean.Missing, + override val value: Optional = Optional.Missing() +) : ComponentData() + +@Serializable +public data class ModalComponentData( + override val type: ComponentType, + val style: Optional = Optional.Missing(), + override val label: Optional = Optional.Missing(), + //TODO: turn this emoji into a EmojiData, it's lacking the guild id + override val emoji: Optional = Optional.Missing(), + override val customId: Optional = Optional.Missing(), + override val url: Optional = Optional.Missing(), + override val disabled: OptionalBoolean = OptionalBoolean.Missing, + override val components: Optional> = Optional.Missing(), + override val placeholder: Optional = Optional.Missing(), + override val minValues: OptionalInt = OptionalInt.Missing, + override val maxValues: OptionalInt = OptionalInt.Missing, + override val options: Optional> = Optional.Missing(), + override val minLength: OptionalInt = OptionalInt.Missing, + override val maxLength: OptionalInt = OptionalInt.Missing, + override val required: OptionalBoolean = OptionalBoolean.Missing, + override val value: Optional = Optional.Missing() +) : ComponentData() \ No newline at end of file diff --git a/core/src/main/kotlin/entity/component/ButtonComponent.kt b/core/src/main/kotlin/entity/component/ButtonComponent.kt index 162f1ea81abb..9cf1898d9dbe 100644 --- a/core/src/main/kotlin/entity/component/ButtonComponent.kt +++ b/core/src/main/kotlin/entity/component/ButtonComponent.kt @@ -3,6 +3,7 @@ package dev.kord.core.entity.component import dev.kord.common.entity.ButtonStyle import dev.kord.common.entity.ComponentType import dev.kord.common.entity.optional.value +import dev.kord.core.cache.data.ChatComponentData import dev.kord.core.cache.data.ComponentData import dev.kord.core.entity.ReactionEmoji import dev.kord.core.entity.interaction.ComponentInteraction @@ -13,7 +14,7 @@ import dev.kord.core.entity.interaction.ComponentInteraction * a [InteractionCreateEvent] with a [ComponentInteraction] will fire. */ -public class ButtonComponent(override val data: ComponentData) : Component { +public class ButtonComponent(override val data: ChatComponentData) : Component { override val type: ComponentType get() = ComponentType.Button diff --git a/core/src/main/kotlin/entity/component/Component.kt b/core/src/main/kotlin/entity/component/Component.kt index 5b2f12282763..c763b44883a2 100644 --- a/core/src/main/kotlin/entity/component/Component.kt +++ b/core/src/main/kotlin/entity/component/Component.kt @@ -1,7 +1,9 @@ package dev.kord.core.entity.component import dev.kord.common.entity.ComponentType +import dev.kord.core.cache.data.ChatComponentData import dev.kord.core.cache.data.ComponentData +import dev.kord.core.cache.data.ModalComponentData import dev.kord.core.entity.Message /** @@ -29,10 +31,10 @@ public sealed interface Component { * @see SelectMenuComponent * @see UnknownComponent */ - public fun Component(data: ComponentData): Component = when (data.type) { ComponentType.ActionRow -> ActionRowComponent(data) - ComponentType.Button -> ButtonComponent(data) + ComponentType.Button -> ButtonComponent(data as ChatComponentData) ComponentType.SelectMenu -> SelectMenuComponent(data) + ComponentType.TextInput -> TextInputComponent(data as ModalComponentData) is ComponentType.Unknown -> UnknownComponent(data) } diff --git a/core/src/main/kotlin/entity/component/TextInputComponent.kt b/core/src/main/kotlin/entity/component/TextInputComponent.kt new file mode 100644 index 000000000000..358136a600c7 --- /dev/null +++ b/core/src/main/kotlin/entity/component/TextInputComponent.kt @@ -0,0 +1,67 @@ +package dev.kord.core.entity.component + +import dev.kord.common.entity.ButtonStyle +import dev.kord.common.entity.ComponentType +import dev.kord.common.entity.TextInputStyle +import dev.kord.common.entity.optional.OptionalInt +import dev.kord.common.entity.optional.value +import dev.kord.core.cache.data.ChatComponentData +import dev.kord.core.cache.data.ComponentData +import dev.kord.core.cache.data.ModalComponentData +import dev.kord.core.entity.ReactionEmoji +import dev.kord.core.entity.interaction.ComponentInteraction + +/** + * An interactive component rendered on a Message. + * If this button contains a [customId] and is clicked by a user, + * a [InteractionCreateEvent] with a [ComponentInteraction] will fire. + */ + +public class TextInputComponent(override val data: ModalComponentData) : Component { + + override val type: ComponentType + get() = ComponentType.TextInput + + /** + * The style of this text input. + */ + public val style: TextInputStyle get() = data.style.value!! + + /** + * The text that appears on the button, if present. + */ + public val label: String? get() = data.label.value + + /** + * The custom identifier for this Text Input. + */ + public val customId: String? get() = data.customId.value + + /** + * The minimum text length of the text input, if present. + */ + public val minLength: Int? get() = data.minLength.value + + /** + * The maximum text length of the text input, if present. + */ + public val maxLength: Int? get() = data.maxLength.value + + /** + * If the text input is required. + */ + public val required: Boolean? get() = data.required.value + + /** + * The value of the text input. + */ + public val value: String? get() = data.value.value + + /** + * The placeholder text of the text input. + */ + public val placeholder: String? get() = data.placeholder.value + + override fun toString(): String = "TextInputComponent(data=$data)" + +} diff --git a/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt b/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt index ddb696bb9e52..3e52f892f90c 100644 --- a/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt +++ b/core/src/main/kotlin/entity/interaction/ComponentInteraction.kt @@ -92,6 +92,7 @@ public fun ComponentInteraction( kord, supplier ) else GuildSelectMenuInteraction(data, kord, supplier) + ComponentType.TextInput -> TODO("Form interactions are not supported yet!") ComponentType.ActionRow -> error("Action rows can't have interactions") is ComponentType.Unknown -> UnknownComponentInteraction(data, kord, supplier) null -> error("Component type was null") diff --git a/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt b/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt index 4141ea61d824..80e072f575e6 100644 --- a/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt +++ b/rest/src/main/kotlin/builder/component/ActionRowBuilder.kt @@ -2,8 +2,10 @@ package dev.kord.rest.builder.component import dev.kord.common.annotation.KordDsl import dev.kord.common.entity.ButtonStyle +import dev.kord.common.entity.DiscordChatComponent import dev.kord.common.entity.ComponentType import dev.kord.common.entity.DiscordComponent +import dev.kord.common.entity.TextInputStyle import dev.kord.common.entity.optional.Optional import kotlin.contracts.InvocationKind import kotlin.contracts.contract @@ -51,8 +53,25 @@ public class ActionRowBuilder : MessageComponentBuilder { components.add(SelectMenuBuilder(customId).apply(builder)) } + /** + * Creates and adds a text input with the [customId] and configured by the [builder]. + * Text Inputs can only be used within modals. + */ + public inline fun textInput( + style: TextInputStyle, + customId: String, + label: String, + builder: TextInputBuilder.() -> Unit + ) { + contract { + callsInPlace(builder, InvocationKind.EXACTLY_ONCE) + } + + components.add(TextInputBuilder(style, customId, label).apply(builder)) + } + override fun build(): DiscordComponent = - DiscordComponent( + DiscordChatComponent( ComponentType.ActionRow, components = Optional.missingOnEmpty(components.map(ActionRowComponentBuilder::build)) ) diff --git a/rest/src/main/kotlin/builder/component/ButtonBuilder.kt b/rest/src/main/kotlin/builder/component/ButtonBuilder.kt index de943693f48e..7999cefdbb0d 100644 --- a/rest/src/main/kotlin/builder/component/ButtonBuilder.kt +++ b/rest/src/main/kotlin/builder/component/ButtonBuilder.kt @@ -4,6 +4,7 @@ package dev.kord.rest.builder.component import dev.kord.common.annotation.KordDsl import dev.kord.common.entity.ButtonStyle +import dev.kord.common.entity.DiscordChatComponent import dev.kord.common.entity.ComponentType import dev.kord.common.entity.DiscordComponent import dev.kord.common.entity.DiscordPartialEmoji @@ -41,7 +42,7 @@ public sealed class ButtonBuilder : ActionRowComponentBuilder() { public var style: ButtonStyle, public var customId: String, ) : ButtonBuilder() { - override fun build(): DiscordComponent = DiscordComponent( + override fun build(): DiscordComponent = DiscordChatComponent( ComponentType.Button, Optional(style), _label, @@ -60,7 +61,7 @@ public sealed class ButtonBuilder : ActionRowComponentBuilder() { public class LinkButtonBuilder( public var url: String, ) : ButtonBuilder() { - override fun build(): DiscordComponent = DiscordComponent( + override fun build(): DiscordComponent = DiscordChatComponent( ComponentType.Button, Optional(ButtonStyle.Link), _label, diff --git a/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt b/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt index 30ea3c438787..79354df243eb 100644 --- a/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt +++ b/rest/src/main/kotlin/builder/component/SelectMenuBuilder.kt @@ -1,6 +1,7 @@ package dev.kord.rest.builder.component import dev.kord.common.annotation.KordDsl +import dev.kord.common.entity.DiscordChatComponent import dev.kord.common.entity.ComponentType import dev.kord.common.entity.DiscordComponent import dev.kord.common.entity.optional.Optional @@ -57,7 +58,7 @@ public class SelectMenuBuilder( } override fun build(): DiscordComponent { - return DiscordComponent( + return DiscordChatComponent( ComponentType.SelectMenu, customId = Optional(customId), disabled = _disabled, diff --git a/rest/src/main/kotlin/builder/component/TextInputBuilder.kt b/rest/src/main/kotlin/builder/component/TextInputBuilder.kt new file mode 100644 index 000000000000..1c18a1dfac25 --- /dev/null +++ b/rest/src/main/kotlin/builder/component/TextInputBuilder.kt @@ -0,0 +1,63 @@ +package dev.kord.rest.builder.component + +import dev.kord.common.annotation.KordDsl +import dev.kord.common.entity.ComponentType +import dev.kord.common.entity.DiscordComponent +import dev.kord.common.entity.DiscordModalComponent +import dev.kord.common.entity.TextInputStyle +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.delegate.delegate + +/** + * A builder for a [Discord Text Input](https://discord.com/developers/docs/interactions/message-components#text-inputs). + * + * @param customId The identifier for the menu, max 100 characters. + */ +@KordDsl +public class TextInputBuilder( + public var style: TextInputStyle, + public var customId: String, + public var label: String, +) : ActionRowComponentBuilder() { + /** + * The range of values that can be accepted. Accepts any range between [0,4000]. + */ + public var allowedLength: IntRange? = null + + private var _placeholder: Optional = Optional.Missing() + + /** + * Custom placeholder if no value is selected, max 100 characters. + */ + public var placeholder: String? by ::_placeholder.delegate() + + private var _value: Optional = Optional.Missing() + + /** + * A pre-filled value for this component, max 4000 characters. + */ + public var value: String? by ::_value.delegate() + + private var _required: OptionalBoolean = OptionalBoolean.Missing + + /** + * Whether this component is required to be filled, default false. + */ + public var required: Boolean? by ::_required.delegate() + + override fun build(): DiscordComponent { + return DiscordModalComponent( + type = ComponentType.TextInput, + style = Optional(style), + customId = Optional(customId), + label = Optional(label), + minLength = allowedLength?.let { OptionalInt.Value(it.first) } ?: OptionalInt.Missing, + maxLength = allowedLength?.let { OptionalInt.Value(it.last) } ?: OptionalInt.Missing, + placeholder = _placeholder, + value = _value, + required = _required, + ) + } +} diff --git a/rest/src/main/kotlin/builder/interaction/ModalBuilder.kt b/rest/src/main/kotlin/builder/interaction/ModalBuilder.kt new file mode 100644 index 000000000000..e80e816bc182 --- /dev/null +++ b/rest/src/main/kotlin/builder/interaction/ModalBuilder.kt @@ -0,0 +1,34 @@ +package dev.kord.rest.builder.interaction + +import dev.kord.common.annotation.KordDsl +import dev.kord.common.entity.DiscordModal +import dev.kord.rest.builder.RequestBuilder +import dev.kord.rest.builder.component.ActionRowBuilder +import dev.kord.rest.builder.component.MessageComponentBuilder +import kotlin.contracts.InvocationKind +import kotlin.contracts.contract + +@KordDsl +public class ModalBuilder( + public var title: String, + public var customId: String +) : RequestBuilder { + public val components: MutableList = mutableListOf() + + /** + * Adds an Action Row to the modal, configured by the [builder]. + */ + public inline fun actionRow(builder: ActionRowBuilder.() -> Unit) { + contract { + callsInPlace(builder, InvocationKind.EXACTLY_ONCE) + } + + components.add(ActionRowBuilder().apply(builder)) + } + + override fun toRequest(): DiscordModal = DiscordModal( + title, + customId, + components.map { it.build() } + ) +} diff --git a/rest/src/main/kotlin/json/request/InteractionsRequests.kt b/rest/src/main/kotlin/json/request/InteractionsRequests.kt index 76f7f6bd5c30..1e1dbb09dd70 100644 --- a/rest/src/main/kotlin/json/request/InteractionsRequests.kt +++ b/rest/src/main/kotlin/json/request/InteractionsRequests.kt @@ -54,6 +54,12 @@ public data class AutoCompleteResponseCreateRequest( val data: DiscordAutoComplete ) +@Serializable +public data class ModalResponseCreateRequest( + val type: InteractionResponseType, + val data: DiscordModal +) + public data class MultipartInteractionResponseCreateRequest( val request: InteractionResponseCreateRequest, val files: Optional> = Optional.Missing() diff --git a/rest/src/main/kotlin/service/InteractionService.kt b/rest/src/main/kotlin/service/InteractionService.kt index 76b33b0ede31..84341b3825cc 100644 --- a/rest/src/main/kotlin/service/InteractionService.kt +++ b/rest/src/main/kotlin/service/InteractionService.kt @@ -5,6 +5,8 @@ import dev.kord.common.entity.MessageFlag.Ephemeral import dev.kord.common.entity.optional.Optional import dev.kord.common.entity.optional.coerceToMissing import dev.kord.common.entity.optional.orEmpty +import dev.kord.rest.builder.component.ActionRowBuilder +import dev.kord.rest.builder.component.ComponentBuilder import dev.kord.rest.builder.interaction.* import dev.kord.rest.builder.message.create.FollowupMessageCreateBuilder import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder @@ -135,20 +137,37 @@ public class InteractionService(requestHandler: RequestHandler) : RestService(re ) } - @PublishedApi - internal suspend inline fun > createBuilderAutoCompleteInteractionResponse( + public suspend fun createModalInteractionResponse( interactionId: Snowflake, interactionToken: String, - builder: Builder, - builderFunction: Builder.() -> Unit + modal: DiscordModal + ): Unit = call(Route.InteractionResponseCreate) { + interactionIdInteractionToken(interactionId, interactionToken) + body( + ModalResponseCreateRequest.serializer(), + ModalResponseCreateRequest( + InteractionResponseType.Modal, + modal + ) + ) + } + + public suspend inline fun createModalInteractionResponse( + interactionId: Snowflake, + interactionToken: String, + title: String, + customId: String, + builderFunction: ModalBuilder.() -> Unit ) { - // TODO We can remove this cast when we change the type of BaseChoiceBuilder.choices to MutableList>. - // This can be done once https://youtrack.jetbrains.com/issue/KT-51045 is fixed. - // Until then this cast is necessary to get the right serializer through reified generics. - @Suppress("UNCHECKED_CAST") - val choices = (builder.apply(builderFunction).choices ?: emptyList()) as List> + contract { + callsInPlace(builderFunction, InvocationKind.EXACTLY_ONCE) + } - return createAutoCompleteInteractionResponse(interactionId, interactionToken, DiscordAutoComplete(choices)) + return createModalInteractionResponse( + interactionId, + interactionToken, + ModalBuilder(title, customId).apply(builderFunction).toRequest() + ) } public suspend inline fun createIntAutoCompleteInteractionResponse( @@ -202,6 +221,21 @@ public class InteractionService(requestHandler: RequestHandler) : RestService(re ) } + public suspend inline fun > createBuilderAutoCompleteInteractionResponse( + interactionId: Snowflake, + interactionToken: String, + builder: Builder, + builderFunction: Builder.() -> Unit + ) { + // TODO We can remove this cast when we change the type of BaseChoiceBuilder.choices to MutableList>. + // This can be done once https://youtrack.jetbrains.com/issue/KT-51045 is fixed. + // Until then this cast is necessary to get the right serializer through reified generics. + @Suppress("UNCHECKED_CAST") + val choices = (builder.apply(builderFunction).choices ?: emptyList()) as List> + + return createAutoCompleteInteractionResponse(interactionId, interactionToken, DiscordAutoComplete(choices)) + } + public suspend fun getInteractionResponse(applicationId: Snowflake, interactionToken: String): DiscordMessage = call(Route.OriginalInteractionResponseGet) { applicationIdInteractionToken(applicationId, interactionToken)