diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 240797827ad..35ff91e53c9 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -14,4 +14,4 @@ dependencies { implementation(kotlin("gradle-plugin-api", version = "1.5.0")) implementation(gradleApi()) implementation(localGroovy()) -} \ No newline at end of file +} diff --git a/common/src/main/kotlin/entity/DiscordComponent.kt b/common/src/main/kotlin/entity/DiscordComponent.kt new file mode 100644 index 00000000000..85f452d274c --- /dev/null +++ b/common/src/main/kotlin/entity/DiscordComponent.kt @@ -0,0 +1,140 @@ +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 kotlinx.serialization.KSerializer +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.descriptors.PrimitiveKind +import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder + +/** + * Represent a [intractable 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 + * @property disabled whether the button is disabled, default `false` + * @property components a list of child components (for action rows) + */ +@KordPreview +@Serializable +data class DiscordComponent( + val type: ComponentType, + val style: Optional = Optional.Missing(), + val label: Optional = Optional.Missing(), + 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() +) + +/** + * Representation of different [DiscordComponent] types. + * + * @property value the raw type value used by the Discord API + */ +@KordPreview +@Serializable(with = ComponentType.Serializer::class) +sealed class ComponentType(val value: Int) { + + /** + * Fallback type used for types that haven't been added to Kord yet. + */ + class Unknown(value: Int) : ComponentType(value) + + /** + * A container for other components. + */ + object ActionRow : ComponentType(1) + + /** + * A clickable button. + */ + object Button : ComponentType(2) + + companion object Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ComponentType", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): ComponentType = + when (val value = decoder.decodeInt()) { + 1 -> ActionRow + 2 -> Button + else -> Unknown(value) + } + + override fun serialize(encoder: Encoder, value: ComponentType) = encoder.encodeInt(value.value) + } +} + +/** + * Representation of different ButtonStyles. + * + * A cheat sheet on how the styles look like can be found [here](https://discord.com/assets/7bb017ce52cfd6575e21c058feb3883b.png) + * + * @see ComponentType.Button + */ +@KordPreview +@Serializable(with = ButtonStyle.Serializer::class) +sealed class ButtonStyle(val value: Int) { + + /** + * A fallback style used for styles that haven't been added to Kord yet. + */ + class Unknown(value: Int) : ButtonStyle(value) + + /** + * Blurple. + * Requires: [DiscordComponent.customId] + */ + object Primary : ButtonStyle(1) + + /** + * Grey. + * Requires: [DiscordComponent.customId] + */ + object Secondary : ButtonStyle(2) + + /** + * Green + * Requires: [DiscordComponent.customId] + */ + object Success : ButtonStyle(3) + + /** + * Red. + * Requires: [DiscordComponent.customId] + */ + object Danger : ButtonStyle(4) + + /** + * Grey, navigates to an URL. + * Requires: [DiscordComponent.url] + */ + object Link : ButtonStyle(5) + + companion object Serializer : KSerializer { + override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ButtonStyle", PrimitiveKind.INT) + + override fun deserialize(decoder: Decoder): ButtonStyle = + when (val value = decoder.decodeInt()) { + 1 -> Primary + 2 -> Secondary + 3 -> Success + 4 -> Danger + 5 -> Link + else -> Unknown(value) + } + + override fun serialize(encoder: Encoder, value: ButtonStyle) = encoder.encodeInt(value.value) + } +} diff --git a/common/src/main/kotlin/entity/DiscordEmoji.kt b/common/src/main/kotlin/entity/DiscordEmoji.kt index d096c5b966f..d008388215a 100644 --- a/common/src/main/kotlin/entity/DiscordEmoji.kt +++ b/common/src/main/kotlin/entity/DiscordEmoji.kt @@ -47,7 +47,7 @@ data class DiscordUpdatedEmojis( */ @Serializable data class DiscordPartialEmoji( - val id: Snowflake?, - val name: String?, + val id: Snowflake? = null, + val name: String? = null, val animated: OptionalBoolean = OptionalBoolean.Missing, ) \ No newline at end of file diff --git a/common/src/main/kotlin/entity/DiscordMessage.kt b/common/src/main/kotlin/entity/DiscordMessage.kt index 9fc5d7f3a2f..dfaca152950 100644 --- a/common/src/main/kotlin/entity/DiscordMessage.kt +++ b/common/src/main/kotlin/entity/DiscordMessage.kt @@ -63,6 +63,7 @@ import kotlin.contracts.contract * @param stickers The stickers sent with the message (bots currently can only receive messages with stickers, not send). * @param referencedMessage the message associated with [messageReference]. * @param applicationId if the message is a response to an [Interaction][DiscordInteraction], this is the id of the interaction's application + * @param components a list of [components][DiscordComponent] which have been added to this message */ @Serializable data class DiscordMessage( @@ -103,6 +104,11 @@ data class DiscordMessage( val stickers: Optional> = Optional.Missing(), @SerialName("referenced_message") val referencedMessage: Optional = Optional.Missing(), + /* + * don't trust the docs: + * This is a list even though the docs say it's a component + */ + val components: Optional> = Optional.Missing(), val interaction: Optional = Optional.Missing() ) diff --git a/common/src/main/kotlin/entity/Interactions.kt b/common/src/main/kotlin/entity/Interactions.kt index 80f4130d371..571e4c11e09 100644 --- a/common/src/main/kotlin/entity/Interactions.kt +++ b/common/src/main/kotlin/entity/Interactions.kt @@ -5,10 +5,8 @@ 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.OptionalSnowflake -import kotlinx.serialization.KSerializer -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.SerializationException +import kotlinx.serialization.* +import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.* import kotlinx.serialization.encoding.* @@ -73,6 +71,7 @@ sealed class ApplicationCommandOptionType(val type: Int) { object User : ApplicationCommandOptionType(6) object Channel : ApplicationCommandOptionType(7) object Role : ApplicationCommandOptionType(8) + object Mentionable : ApplicationCommandOptionType(9) class Unknown(type: Int) : ApplicationCommandOptionType(type) companion object; @@ -157,12 +156,11 @@ data class ResolvedObjects( @Serializable @KordPreview -data class DiscordInteraction( +class DiscordInteraction( val id: Snowflake, @SerialName("application_id") val applicationId: Snowflake, - val type: InteractionType, - val data: DiscordApplicationCommandInteractionData, + val data: InteractionCallbackData, @SerialName("guild_id") val guildId: OptionalSnowflake = OptionalSnowflake.Missing, @SerialName("channel_id") @@ -171,18 +169,59 @@ data class DiscordInteraction( val user: Optional = Optional.Missing(), val token: String, val version: Int, -) + @Serializable(with = MaybeMessageSerializer::class) + val message: Optional = Optional.Missing(), + val type: InteractionType +) { + + /** + * Serializer that handles incomplete messages in [DiscordInteraction.message]. Discards + * any incomplete messages as missing optionals. + */ + private object MaybeMessageSerializer : + KSerializer> by Optional.serializer(DiscordMessage.serializer()) { + + override fun deserialize(decoder: Decoder): Optional { + decoder as JsonDecoder + + val element = decoder.decodeJsonElement().jsonObject + + //check if required fields are present, if not, discard the data + return if ( + element["channel_id"] == null || + element["author"] == null + ) { + Optional.Missing() + } else { + decoder.json.decodeFromJsonElement( + Optional.serializer(DiscordMessage.serializer()), element + ) + } + } + + + } +} + @Serializable(InteractionType.Serializer::class) @KordPreview sealed class InteractionType(val type: Int) { object Ping : InteractionType(1) object ApplicationCommand : InteractionType(2) + + /* + * don't trust the docs: + * + * this type exists and is needed for components even though it's not documented + */ + object Component : InteractionType(3) class Unknown(type: Int) : InteractionType(type) override fun toString(): String = when (this) { Ping -> "InteractionType.Ping($type)" ApplicationCommand -> "InteractionType.ApplicationCommand($type)" + Component -> "InteractionType.ComponentInvoke($type)" is Unknown -> "InteractionType.Unknown($type)" } @@ -196,6 +235,7 @@ sealed class InteractionType(val type: Int) { return when (val type = decoder.decodeInt()) { 1 -> Ping 2 -> ApplicationCommand + 3 -> Component else -> Unknown(type) } } @@ -208,18 +248,22 @@ sealed class InteractionType(val type: Int) { } @Serializable -@KordPreview -data class DiscordApplicationCommandInteractionData( - val id: Snowflake, - val name: String, +data class InteractionCallbackData( + val id: OptionalSnowflake = OptionalSnowflake.Missing, + val name: Optional = Optional.Missing(), val resolved: Optional = Optional.Missing(), - val options: Optional> = Optional.Missing() + val options: Optional> = Optional.Missing(), + @SerialName("custom_id") + val customId: Optional = Optional.Missing(), + @SerialName("component_type") + val componentType: Optional = Optional.Missing() ) @Serializable(with = Option.Serializer::class) @KordPreview sealed class Option { abstract val name: String + abstract val type: ApplicationCommandOptionType internal object Serializer : KSerializer