All Downloads are FREE. Search and download functionalities are using the official Maven repository.

io.github.freya022.botcommands.internal.commands.application.autobuilder.SlashCommandAutoBuilder.kt Maven / Gradle / Ivy

Go to download

A Kotlin-first (and Java) framework that makes creating Discord bots a piece of cake, using the JDA library.

There is a newer version: 3.0.0-alpha.22
Show newest version
package io.github.freya022.botcommands.internal.commands.application.autobuilder

import io.github.freya022.botcommands.api.commands.CommandPath
import io.github.freya022.botcommands.api.commands.annotations.Command
import io.github.freya022.botcommands.api.commands.annotations.GeneratedOption
import io.github.freya022.botcommands.api.commands.annotations.VarArgs
import io.github.freya022.botcommands.api.commands.application.ApplicationCommand
import io.github.freya022.botcommands.api.commands.application.CommandScope
import io.github.freya022.botcommands.api.commands.application.LengthRange
import io.github.freya022.botcommands.api.commands.application.ValueRange
import io.github.freya022.botcommands.api.commands.application.annotations.CommandId
import io.github.freya022.botcommands.api.commands.application.provider.*
import io.github.freya022.botcommands.api.commands.application.slash.GlobalSlashEvent
import io.github.freya022.botcommands.api.commands.application.slash.annotations.*
import io.github.freya022.botcommands.api.commands.application.slash.annotations.LongRange
import io.github.freya022.botcommands.api.commands.application.slash.builder.SlashCommandBuilder
import io.github.freya022.botcommands.api.commands.application.slash.builder.SlashCommandOptionBuilder
import io.github.freya022.botcommands.api.commands.application.slash.builder.SlashSubcommandBuilder
import io.github.freya022.botcommands.api.core.config.BApplicationConfig
import io.github.freya022.botcommands.api.core.reflect.ParameterType
import io.github.freya022.botcommands.api.core.service.annotations.BService
import io.github.freya022.botcommands.api.core.utils.enumSetOf
import io.github.freya022.botcommands.api.core.utils.joinAsList
import io.github.freya022.botcommands.api.core.utils.nullIfBlank
import io.github.freya022.botcommands.api.parameters.ResolverContainer
import io.github.freya022.botcommands.internal.commands.SkipLogger
import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.SlashFunctionMetadata
import io.github.freya022.botcommands.internal.commands.autobuilder.*
import io.github.freya022.botcommands.internal.commands.autobuilder.metadata.MetadataFunctionHolder
import io.github.freya022.botcommands.internal.core.requiredFilter
import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap
import io.github.freya022.botcommands.internal.utils.*
import io.github.freya022.botcommands.internal.utils.ReflectionUtils.nonInstanceParameters
import io.github.oshai.kotlinlogging.KotlinLogging
import net.dv8tion.jda.api.entities.Guild
import net.dv8tion.jda.api.entities.channel.ChannelType
import kotlin.reflect.KFunction
import kotlin.reflect.KParameter
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.hasAnnotation
import kotlin.reflect.jvm.jvmErasure
import net.dv8tion.jda.api.interactions.commands.Command as JDACommand

private val logger = KotlinLogging.logger { }
private val defaultTopLevelMetadata = TopLevelSlashCommandData()

@BService
internal class SlashCommandAutoBuilder(
    applicationConfig: BApplicationConfig,
    private val resolverContainer: ResolverContainer,
    functionAnnotationsMap: FunctionAnnotationsMap
) : GlobalApplicationCommandProvider, GuildApplicationCommandProvider {
    private class TopLevelSlashCommandMetadata(
        val name: String,
        val annotation: TopLevelSlashCommandData,
        val metadata: SlashFunctionMetadata
    ) : MetadataFunctionHolder {
        override val func: KFunction<*> get() = metadata.func

        val subcommands: MutableList = arrayListOf()
        val subcommandGroups: MutableMap = hashMapOf()
    }

    private class SlashSubcommandGroupMetadata(val name: String) {
        class Properties(val description: String)

        lateinit var properties: Properties

        val subcommands: MutableMap> = hashMapOf()
    }

    private val forceGuildCommands = applicationConfig.forceGuildCommands

    private val topLevelMetadata: MutableMap = hashMapOf()

    init {
        val functions: List =
            functionAnnotationsMap
                .getWithClassAnnotation()
                .requiredFilter(FunctionFilter.nonStatic())
                .requiredFilter(FunctionFilter.firstArg(GlobalSlashEvent::class))
                .map {
                    val func = it.function
                    val annotation = func.findAnnotation() ?: throwInternal("${annotationRef()} should be present")
                    val path = CommandPath.of(annotation.name, annotation.group.nullIfBlank(), annotation.subcommand.nullIfBlank()).also { path ->
                        if (path.group != null && path.nameCount == 2) {
                            throwUser(func, "Slash commands with groups need to have their subcommand name set")
                        }
                    }
                    val commandId = func.findAnnotation()?.value

                    SlashFunctionMetadata(it, annotation, path, commandId)
                }

        // Create all top level metadata
        val missingTopLevels = functions.groupByTo(hashMapOf()) { it.path.name }
        functions.forEach { slashFunctionMetadata ->
            slashFunctionMetadata.func.findAnnotation()?.let { annotation ->
                // Remove all slash commands with the top level name
                val name = slashFunctionMetadata.path.name
                check(name in missingTopLevels) {
                    val refs = functions
                        .filter { it.path.name == name && it.func.hasAnnotation() }
                        .joinAsList { it.func.shortSignature }
                    "Cannot have multiple ${annotationRef()} on a same top-level command '$name':\n$refs"
                }

                missingTopLevels.remove(name)
                topLevelMetadata[name] = TopLevelSlashCommandMetadata(name, annotation, slashFunctionMetadata)
            }
        }

        // Create default metadata for top level commands with no subcommands or groups
        // This can only be applied to single top level commands
        // as the function metadata needs to be taken from the function that has the top level annotation.
        // This is especially important for annotations such as @Test,
        // which are read on the function with the top-level annotation.
        // Picking a random function is not suited in this case.
        missingTopLevels.values
            .mapNotNull { it.singleOrNull() }
            .forEach { slashFunctionMetadata ->
                val name = slashFunctionMetadata.path.name
                missingTopLevels.remove(name)
                topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(name, defaultTopLevelMetadata, slashFunctionMetadata))
            }

        // Check if all commands have their metadata
        check(missingTopLevels.isEmpty()) {
            val missingTopLevelRefs = missingTopLevels.entries.joinAsList { (name, metadataList) ->
                if (metadataList.size == 1) throwInternal("Single top level commands should have been assigned the metadata")
                "$name:\n${metadataList.joinAsList("\t -") { it.func.shortSignature }}"
            }

            "At least one top-level slash command must be annotated with ${annotationRef()}:\n$missingTopLevelRefs"
        }

        // Assign subcommands and groups
        functions.forEachWithDelayedExceptions { metadata ->
            if (metadata.path.nameCount < 2) return@forEachWithDelayedExceptions

            val topLevelMetadata = topLevelMetadata[metadata.path.name]
                ?: throwInternal("Missing top level metadata '${metadata.path.name}' when assigning subcommands")
            if (metadata.path.nameCount == 2) {
                topLevelMetadata.subcommands.add(metadata)
            } else if (metadata.path.nameCount == 3) {
                topLevelMetadata
                    .subcommandGroups
                    .getOrPut(metadata.path.group!!) { metadata.toSubcommandGroupMetadata() }
                    .subcommands
                    .getOrPut(metadata.path.subname!!) { arrayListOf() }
                    .add(metadata)
            }
        }

        // For each subcommand group, find the SlashCommandGroupData from its subcommands
        topLevelMetadata.values.forEach { topLevelSlashCommandMetadata ->
            topLevelSlashCommandMetadata.subcommandGroups.values.forEach { slashSubcommandGroupMetadata ->
                val groupSubcommands = slashSubcommandGroupMetadata.subcommands.values.flatten()
                val annotation = groupSubcommands
                    .mapNotNull { metadata -> metadata.func.findAnnotation() }
                    .also { annotations ->
                        check(annotations.size <= 1) {
                            val refs = groupSubcommands
                                .filter { it.func.hasAnnotation() }
                                .joinAsList { it.func.shortSignature }
                            "Cannot have multiple ${annotationRef()} on a same subcommand group '${topLevelSlashCommandMetadata.name} ${slashSubcommandGroupMetadata.name}':\n$refs"
                        }
                    }
                    .firstOrNull() ?: SlashCommandGroupData()

                slashSubcommandGroupMetadata.properties = SlashSubcommandGroupMetadata.Properties(annotation.description)
            }
        }
    }

    private fun SlashFunctionMetadata.toSubcommandGroupMetadata() = SlashSubcommandGroupMetadata(path.group!!)

    override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declare(manager)

    override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) = declare(manager)

    private fun declare(manager: AbstractApplicationCommandManager) {
        val skipLogger = SkipLogger(logger)
        topLevelMetadata
            .values
            .forEachWithDelayedExceptions loop@{ topLevelMetadata ->
                val metadata = topLevelMetadata.metadata
                runFiltered(
                    manager,
                    skipLogger,
                    forceGuildCommands,
                    metadata.path,
                    metadata.instance,
                    metadata.commandId,
                    metadata.func,
                    topLevelMetadata.annotation.scope
                ) {
                    processCommand(manager, topLevelMetadata)
                }
            }
        skipLogger.log((manager as? GuildApplicationCommandManager)?.guild, JDACommand.Type.SLASH)
    }

    private fun processCommand(manager: AbstractApplicationCommandManager, topLevelMetadata: TopLevelSlashCommandMetadata) {
        val metadata = topLevelMetadata.metadata
        val annotation = metadata.annotation
        val path = metadata.path

        val name = path.name
        val subcommandsMetadata = topLevelMetadata.subcommands
        val subcommandGroupsMetadata = topLevelMetadata.subcommandGroups
        val isTopLevelOnly = subcommandsMetadata.isEmpty() && subcommandGroupsMetadata.isEmpty()
        val actualScope = if (forceGuildCommands) CommandScope.GUILD else topLevelMetadata.annotation.scope
        manager.slashCommand(name, actualScope, if (isTopLevelOnly) metadata.func.castFunction() else null) {
            isDefaultLocked = topLevelMetadata.annotation.defaultLocked
            nsfw = topLevelMetadata.annotation.nsfw

            // On top-level only commands, the description can be set on either of the annotations, but not both
            if (isTopLevelOnly) {
                // One of them needs to not be set
                require(topLevelMetadata.annotation.description.isBlank() || annotation.description.isBlank()) {
                    "Slash command annotated with ${annotationRef()} must only have a description set once"
                }
            }
            description = annotation.description.nullIfBlank() ?: topLevelMetadata.annotation.description.nullIfBlank()

            subcommandsMetadata.forEach { subMetadata ->
                subcommand(subMetadata.path.subname!!, subMetadata.func.castFunction()) {
                    configureSubcommand(manager, subMetadata)
                }
            }

            subcommandGroupsMetadata.values.forEach { groupMetadata ->
                subcommandGroup(groupMetadata.name) {
                    [email protected] = groupMetadata.properties.description.nullIfBlank()

                    groupMetadata.subcommands.forEach { (subname, metadataList) ->
                        metadataList.forEach { subMetadata ->
                            subcommand(subname, subMetadata.func.castFunction()) {
                                configureSubcommand(manager, subMetadata)
                            }
                        }
                    }
                }
            }

            configureBuilder(metadata)

            if (isTopLevelOnly) {
                processOptions((manager as? GuildApplicationCommandManager)?.guild, metadata)
            }
        }
    }

    private fun SlashSubcommandBuilder.configureSubcommand(manager: AbstractApplicationCommandManager, subMetadata: SlashFunctionMetadata) {
        this.description = subMetadata.annotation.description.nullIfBlank()
        this.configureBuilder(subMetadata)
        this.processOptions((manager as? GuildApplicationCommandManager)?.guild, subMetadata)
    }

    private fun SlashCommandBuilder.configureBuilder(metadata: SlashFunctionMetadata) {
        fillCommandBuilder(metadata.func)
        fillApplicationCommandBuilder(metadata.func)
    }

    private fun SlashCommandBuilder.processOptions(guild: Guild?, metadata: SlashFunctionMetadata) {
        val instance = metadata.instance
        val func = metadata.func
        val path = metadata.path

        func.nonInstanceParameters.drop(1).forEach { kParameter ->
            val declaredName = kParameter.findDeclarationName()
            when (val optionAnnotation = kParameter.findAnnotation()) {
                null -> when (kParameter.findAnnotation()) {
                    null -> {
                        resolverContainer.requireCustomOption(func, kParameter, SlashOption::class)
                        customOption(declaredName)
                    }
                    else -> generatedOption(
                        declaredName, instance.getGeneratedValueSupplier(
                            guild,
                            metadata.commandId,
                            path,
                            kParameter.findOptionName(),
                            ParameterType.ofType(kParameter.type)
                        )
                    )
                }
                else -> {
                    val optionName = optionAnnotation.name.nullIfBlank() ?: declaredName.toDiscordString()
                    if (kParameter.type.jvmErasure.isValue) {
                        val inlineClassType = kParameter.type.jvmErasure
                        when (val varArgs = kParameter.findAnnotation()) {
                            null -> inlineClassOption(declaredName, optionName, inlineClassType) {
                                configureOption(guild, instance, kParameter, optionAnnotation)
                            }
                            else -> inlineClassOptionVararg(declaredName, inlineClassType, varArgs.value, varArgs.numRequired, { i -> "${optionName}_$i" }) {
                                configureOption(guild, instance, kParameter, optionAnnotation)
                            }
                        }
                    } else {
                        when (val varArgs = kParameter.findAnnotation()) {
                            null -> option(declaredName, optionName) {
                                configureOption(guild, instance, kParameter, optionAnnotation)
                            }
                            else -> optionVararg(declaredName, varArgs.value, varArgs.numRequired, { i -> "${optionName}_$i" }) {
                                configureOption(guild, instance, kParameter, optionAnnotation)
                            }
                        }
                    }
                }
            }
        }
    }

    context(SlashCommandBuilder)
    private fun SlashCommandOptionBuilder.configureOption(guild: Guild?, instance: ApplicationCommand, kParameter: KParameter, optionAnnotation: SlashOption) {
        description = optionAnnotation.description.nullIfBlank()

        kParameter.findAnnotation()?.let { range -> valueRange = ValueRange.ofLong(range.from, range.to) }
        kParameter.findAnnotation()?.let { range -> valueRange = ValueRange.ofDouble(range.from, range.to) }
        kParameter.findAnnotation()?.let { length -> lengthRange = LengthRange.of(length.min, length.max) }

        kParameter.findAnnotation()?.let { channelTypesAnnotation ->
            channelTypes = enumSetOf().also { types ->
                types += channelTypesAnnotation.value
            }
        }

        processAutocomplete(optionAnnotation)

        usePredefinedChoices = optionAnnotation.usePredefinedChoices
        choices = instance.getOptionChoices(guild, [email protected], optionName)
    }

    private fun SlashCommandOptionBuilder.processAutocomplete(optionAnnotation: SlashOption) {
        if (optionAnnotation.autocomplete.isNotEmpty()) {
            autocompleteByName(optionAnnotation.autocomplete)
        }
    }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy