diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ApplicationCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ApplicationCommandAutoBuilder.kt new file mode 100644 index 000000000..a85ee1e01 --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ApplicationCommandAutoBuilder.kt @@ -0,0 +1,139 @@ +package io.github.freya022.botcommands.internal.commands.application.autobuilder + +import io.github.freya022.botcommands.api.commands.application.ApplicationCommandFilter +import io.github.freya022.botcommands.api.commands.application.CommandScope +import io.github.freya022.botcommands.api.commands.application.annotations.DeclarationFilter +import io.github.freya022.botcommands.api.commands.application.annotations.Test +import io.github.freya022.botcommands.api.commands.application.builder.ApplicationCommandBuilder +import io.github.freya022.botcommands.api.commands.application.provider.* +import io.github.freya022.botcommands.api.commands.text.annotations.NSFW +import io.github.freya022.botcommands.api.core.BContext +import io.github.freya022.botcommands.api.core.config.BApplicationConfig +import io.github.freya022.botcommands.api.core.objectLogger +import io.github.freya022.botcommands.api.core.utils.findAllAnnotations +import io.github.freya022.botcommands.api.core.utils.hasAnnotationRecursive +import io.github.freya022.botcommands.api.core.utils.simpleNestedName +import io.github.freya022.botcommands.internal.commands.SkipLogger +import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.ApplicationFunctionMetadata +import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.RootAnnotatedApplicationCommand +import io.github.freya022.botcommands.internal.commands.autobuilder.CommandAutoBuilder +import io.github.freya022.botcommands.internal.commands.autobuilder.forEachWithDelayedExceptions +import io.github.freya022.botcommands.internal.utils.* +import net.dv8tion.jda.api.interactions.commands.Command as JDACommand +import kotlin.reflect.KFunction + +internal abstract class ApplicationCommandAutoBuilder( + applicationConfig: BApplicationConfig +) : CommandAutoBuilder(), + GlobalApplicationCommandProvider, + GuildApplicationCommandProvider { + + private val logger = this.objectLogger() + + protected val forceGuildCommands: Boolean = applicationConfig.forceGuildCommands + protected abstract val commandType: JDACommand.Type + + protected abstract val rootAnnotatedCommands: Collection + + override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) { + // On global manager, do not register any command if forceGuildCommands is enabled, + // as none of them would be global + if (forceGuildCommands) + return + + val skipLogger = SkipLogger(logger) + rootAnnotatedCommands.forEachWithDelayedExceptions forEach@{ rootAnnotatedCommand -> + val scope = rootAnnotatedCommand.scope + if (scope != CommandScope.GLOBAL) return@forEach + + val testState = checkTestCommand(manager, rootAnnotatedCommand.func, scope, manager.context) + if (testState != TestState.NO_ANNOTATION) + throwInternal("Test commands on a global scope should have thrown in ${::checkTestCommand.shortSignatureNoSrc}") + + context(skipLogger) { declareTopLevel(manager, rootAnnotatedCommand) } + } + + skipLogger.log(guild = null, commandType) + } + + override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) { + val skipLogger = SkipLogger(logger) + rootAnnotatedCommands.forEachWithDelayedExceptions forEach@{ rootAnnotatedCommand -> + val scope = rootAnnotatedCommand.scope + + // If guild commands aren't forced, check the scope + val canBeDeclared = forceGuildCommands || scope == CommandScope.GUILD + if (!canBeDeclared) return@forEach + + val testState = checkTestCommand(manager, rootAnnotatedCommand.func, scope, manager.context) + if (scope == CommandScope.GLOBAL && testState != TestState.NO_ANNOTATION) + throwInternal("Test commands on a global scope should have thrown in ${::checkTestCommand.shortSignatureNoSrc}") + + if (testState == TestState.EXCLUDE) + return@forEach skipLogger.skip(rootAnnotatedCommand.name, "Is a test command while this guild isn't a test guild") + + context(skipLogger) { declareTopLevel(manager, rootAnnotatedCommand) } + } + + skipLogger.log(manager.guild, commandType) + } + + private fun checkTestCommand(manager: AbstractApplicationCommandManager, func: KFunction<*>, scope: CommandScope, context: BContext): TestState { + if (func.hasAnnotationRecursive()) { + requireAt(scope == CommandScope.GUILD, func) { + "Test commands must have their scope set to GUILD" + } + if (manager !is GuildApplicationCommandManager) throwInternal("GUILD scoped command was not registered with a guild command manager") + + //Returns whether the command can be registered + return when (manager.guild.idLong) { + in AnnotationUtils.getEffectiveTestGuildIds(context, func) -> TestState.INCLUDE + else -> TestState.EXCLUDE + } + } + + return TestState.NO_ANNOTATION + } + + context(logger: SkipLogger) + protected abstract fun declareTopLevel(manager: AbstractApplicationCommandManager, rootCommand: T) + + context(logger: SkipLogger) + internal fun checkDeclarationFilter( + manager: AbstractApplicationCommandManager, + metadata: ApplicationFunctionMetadata<*>, + ): Boolean { + val func = metadata.func + val path = metadata.path + val commandId = metadata.commandId + + func.findAllAnnotations().forEach { declarationFilter -> + checkAt(manager is GuildApplicationCommandManager, func) { + "${annotationRef()} can only be used on guild commands" + } + + declarationFilter.filters.forEach { + if (!serviceContainer.getService(it).filter(manager.guild, path, commandId)) { + val commandIdStr = if (commandId != null) " (id ${commandId})" else "" + logger.skip(path, "${it.simpleNestedName} rejected this command$commandIdStr") + return false + } + } + } + return true + } + + protected fun ApplicationCommandBuilder<*>.fillApplicationCommandBuilder(func: KFunction<*>) { + filters += AnnotationUtils.getFilters(context, func, ApplicationCommandFilter::class) + + if (func.hasAnnotationRecursive()) { + throwArgument(func, "${annotationRef()} can only be used on text commands, use the #nsfw method on your annotation instead") + } + } + + private enum class TestState { + INCLUDE, + EXCLUDE, + NO_ANNOTATION + } +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ContextCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ContextCommandAutoBuilder.kt index 1362f96fa..3653a37bb 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ContextCommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ContextCommandAutoBuilder.kt @@ -8,16 +8,13 @@ import io.github.freya022.botcommands.api.commands.application.context.annotatio import io.github.freya022.botcommands.api.commands.application.context.message.options.builder.MessageCommandOptionRegistry import io.github.freya022.botcommands.api.commands.application.context.user.options.builder.UserCommandOptionRegistry import io.github.freya022.botcommands.api.commands.application.options.builder.ApplicationOptionRegistry -import io.github.freya022.botcommands.api.commands.application.provider.GlobalApplicationCommandProvider -import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandProvider import io.github.freya022.botcommands.api.core.config.BApplicationConfig import io.github.freya022.botcommands.api.core.options.builder.inlineClassAggregate import io.github.freya022.botcommands.api.core.reflect.wrap import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.parameters.resolvers.ICustomResolver +import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.RootAnnotatedApplicationCommand import io.github.freya022.botcommands.internal.commands.application.autobuilder.utils.ParameterAdapter -import io.github.freya022.botcommands.internal.commands.autobuilder.CommandAutoBuilder -import io.github.freya022.botcommands.internal.commands.autobuilder.requireServiceOptionOrOptional import io.github.freya022.botcommands.internal.parameters.ResolverContainer import io.github.freya022.botcommands.internal.utils.ReflectionUtils.nonInstanceParameters import io.github.freya022.botcommands.internal.utils.findDeclarationName @@ -26,17 +23,15 @@ import kotlin.reflect.KClass import kotlin.reflect.KFunction import kotlin.reflect.jvm.jvmErasure -internal sealed class ContextCommandAutoBuilder( +internal sealed class ContextCommandAutoBuilder( override val serviceContainer: ServiceContainer, applicationConfig: BApplicationConfig, private val resolverContainer: ResolverContainer -) : CommandAutoBuilder, GlobalApplicationCommandProvider, GuildApplicationCommandProvider { +) : ApplicationCommandAutoBuilder(applicationConfig) { protected abstract val commandAnnotation: KClass override val optionAnnotation: KClass = ContextOption::class - protected val forceGuildCommands = applicationConfig.forceGuildCommands - protected fun ApplicationCommandBuilder<*>.processOptions( guild: Guild?, func: KFunction<*>, @@ -80,4 +75,4 @@ internal sealed class ContextCommandAutoBuilder( registry.serviceOption(parameter.declaredName) } } -} \ No newline at end of file +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/MessageContextCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/MessageContextCommandAutoBuilder.kt index 053c1d3a3..f37cfea46 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/MessageContextCommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/MessageContextCommandAutoBuilder.kt @@ -7,7 +7,6 @@ import io.github.freya022.botcommands.api.commands.application.annotations.Requi import io.github.freya022.botcommands.api.commands.application.context.annotations.JDAMessageCommand import io.github.freya022.botcommands.api.commands.application.context.message.GlobalMessageEvent import io.github.freya022.botcommands.api.commands.application.provider.AbstractApplicationCommandManager -import io.github.freya022.botcommands.api.commands.application.provider.GlobalApplicationCommandManager import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager import io.github.freya022.botcommands.api.core.config.BApplicationConfig import io.github.freya022.botcommands.api.core.service.ServiceContainer @@ -15,20 +14,17 @@ import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive import io.github.freya022.botcommands.internal.commands.SkipLogger import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.MessageContextFunctionMetadata -import io.github.freya022.botcommands.internal.commands.autobuilder.* +import io.github.freya022.botcommands.internal.commands.autobuilder.castFunction import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap import io.github.freya022.botcommands.internal.parameters.ResolverContainer import io.github.freya022.botcommands.internal.utils.FunctionFilter import io.github.freya022.botcommands.internal.utils.annotationRef import io.github.freya022.botcommands.internal.utils.throwInternal -import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.InteractionContextType import net.dv8tion.jda.api.interactions.commands.Command.Type as CommandType import kotlin.reflect.KClass -private val logger = KotlinLogging.logger { } - @BService @RequiresApplicationCommands internal class MessageContextCommandAutoBuilder( @@ -36,56 +32,37 @@ internal class MessageContextCommandAutoBuilder( resolverContainer: ResolverContainer, functionAnnotationsMap: FunctionAnnotationsMap, serviceContainer: ServiceContainer -) : ContextCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer) { - override val commandAnnotation: KClass get() = JDAMessageCommand::class - - private val messageFunctions: List +) : ContextCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer) { - init { - messageFunctions = functionAnnotationsMap - .getWithClassAnnotation() - .requiredFilter(FunctionFilter.nonStatic()) - .requiredFilter(FunctionFilter.firstArg(GlobalMessageEvent::class)) - .map { - val func = it.function - val annotation = func.findAnnotationRecursive() ?: throwInternal("${annotationRef()} should be present") - val path = CommandPath.ofName(annotation.name) - val commandId = func.findAnnotationRecursive()?.value - - MessageContextFunctionMetadata(it, annotation, path, commandId) - } - } + override val commandAnnotation: KClass get() = JDAMessageCommand::class + override val commandType: CommandType + get() = CommandType.MESSAGE - //Separated functions so global command errors don't prevent guild commands from being registered - override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declareMessage(manager) - override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) = declareMessage(manager) + override val rootAnnotatedCommands = functionAnnotationsMap + .getWithClassAnnotation() + .requiredFilter(FunctionFilter.nonStatic()) + .requiredFilter(FunctionFilter.firstArg(GlobalMessageEvent::class)) + .map { + val func = it.function + val annotation = func.findAnnotationRecursive() ?: throwInternal("${annotationRef()} should be present") + val path = CommandPath.ofName(annotation.name) + val commandId = func.findAnnotationRecursive()?.value - private fun declareMessage(manager: AbstractApplicationCommandManager) { - with(SkipLogger(logger)) { - messageFunctions.forEachWithDelayedExceptions { metadata -> - runFiltered( - manager, - forceGuildCommands, - metadata, - metadata.annotation.scope - ) { processMessageCommand(manager, metadata) } - } - log((manager as? GuildApplicationCommandManager)?.guild, CommandType.MESSAGE) + MessageContextFunctionMetadata(it, annotation, path, commandId) } - } - context(_: SkipLogger) - private fun processMessageCommand(manager: AbstractApplicationCommandManager, metadata: MessageContextFunctionMetadata) { - val func = metadata.func - val instance = metadata.instance - val path = metadata.path - val commandId = metadata.commandId + context(logger: SkipLogger) + override fun declareTopLevel( + manager: AbstractApplicationCommandManager, + rootCommand: MessageContextFunctionMetadata, + ) { + val func = rootCommand.func - if (!checkDeclarationFilter(manager, metadata.func, path, metadata.commandId)) + if (!checkDeclarationFilter(manager, rootCommand)) return // Already logged - val annotation = metadata.annotation - manager.messageCommand(path.name, func.castFunction()) { + val annotation = rootCommand.annotation + manager.messageCommand(rootCommand.path.name, func.castFunction()) { fillCommandBuilder(func) fillApplicationCommandBuilder(func) @@ -98,7 +75,7 @@ internal class MessageContextCommandAutoBuilder( isDefaultLocked = annotation.defaultLocked nsfw = annotation.nsfw - processOptions((manager as? GuildApplicationCommandManager)?.guild, func, instance, commandId) + processOptions((manager as? GuildApplicationCommandManager)?.guild, func, rootCommand.instance, rootCommand.commandId) } } } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/SlashCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/SlashCommandAutoBuilder.kt index c067dd24d..77dbeaa05 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/SlashCommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/SlashCommandAutoBuilder.kt @@ -8,7 +8,8 @@ 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.annotations.RequiresApplicationCommands -import io.github.freya022.botcommands.api.commands.application.provider.* +import io.github.freya022.botcommands.api.commands.application.provider.AbstractApplicationCommandManager +import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager 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 @@ -28,24 +29,21 @@ 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.resolvers.ICustomResolver import io.github.freya022.botcommands.internal.commands.SkipLogger +import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.SlashCommandMetadata import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.SlashFunctionMetadata import io.github.freya022.botcommands.internal.commands.application.autobuilder.utils.ParameterAdapter -import io.github.freya022.botcommands.internal.commands.autobuilder.* -import io.github.freya022.botcommands.internal.commands.autobuilder.metadata.MetadataFunctionHolder +import io.github.freya022.botcommands.internal.commands.autobuilder.castFunction import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap import io.github.freya022.botcommands.internal.parameters.ResolverContainer 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.interactions.InteractionContextType import net.dv8tion.jda.api.interactions.commands.Command as JDACommand import kotlin.reflect.KClass -import kotlin.reflect.KFunction import kotlin.reflect.jvm.jvmErasure -private val logger = KotlinLogging.logger { } private val defaultTopLevelMetadata = TopLevelSlashCommandData() @BService @@ -55,33 +53,19 @@ internal class SlashCommandAutoBuilder( applicationConfig: BApplicationConfig, private val resolverContainer: ResolverContainer, functionAnnotationsMap: FunctionAnnotationsMap -) : CommandAutoBuilder, 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() - } +) : ApplicationCommandAutoBuilder(applicationConfig) { override val optionAnnotation: KClass = SlashOption::class + override val commandType: JDACommand.Type get() = JDACommand.Type.SLASH - private val forceGuildCommands = applicationConfig.forceGuildCommands - - private val topLevelMetadata: MutableMap = hashMapOf() + private val metadata: Map + override val rootAnnotatedCommands: Collection + get() = metadata.values init { + val topLevelMetadata: MutableMap = hashMapOf() + val groupedBuilders: MutableMap = hashMapOf() + val functions: List = functionAnnotationsMap .getWithClassAnnotation() @@ -111,9 +95,8 @@ internal class SlashCommandAutoBuilder( "Multiple annotated commands share the same path:\n$sharedPaths" } - val missingTopLevels = functions.groupByTo(hashMapOf()) { it.path.name } // Check that top level names don't appear more than once - missingTopLevels.values.forEach { metadataList -> + functions.groupBy { it.path.name }.values.forEach { metadataList -> val hasTopLevel = metadataList.any { it.path.nameCount == 1 } val hasSubcommands = metadataList.any { it.path.nameCount > 1 } check(!hasTopLevel || !hasSubcommands) { @@ -127,167 +110,171 @@ internal class SlashCommandAutoBuilder( } } - // Create all top level metadata - functions.forEach { slashFunctionMetadata -> - slashFunctionMetadata.func.findAnnotationRecursive()?.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.hasAnnotationRecursive() } - .joinAsList { it.func.shortSignature } - "Cannot have multiple ${annotationRef()} on a same top-level command '$name':\n$refs" - } + // Find subcommands that don't have a @TopLevelSlashCommandData + run { + val subcommandsByName = functions + .filter { it.path.nameCount > 1 } + .groupBy { it.path.name } + + val subcommandListsWithoutTopAnnotation = subcommandsByName.filterValues { metadataList -> + val hasTopLevelAnnotation = metadataList.any { it.func.hasAnnotationRecursive() } + !hasTopLevelAnnotation + } - missingTopLevels.remove(name) - topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(name, annotation, slashFunctionMetadata)) + require(subcommandListsWithoutTopAnnotation.isEmpty()) { + val topNamesWithoutAnnotation = subcommandListsWithoutTopAnnotation.keys.joinAsList() + "Subcommands must have at least one function be annotated with ${annotationRef()}:\n$topNamesWithoutAnnotation" } } - // 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)) + val functionsByName = functions.groupBy { it.path.name } + // At this point we have made sure that subcommands have an @TopLevelSlashCommandData at least once + functionsByName.forEach { (name, metadataList) -> + fun findTopLevelMetadata(): SlashFunctionMetadata? { + return metadataList.firstOrNull { it.func.hasAnnotationRecursive() } + } + + fun findTopLevelAnnotation(): TopLevelSlashCommandData? { + return metadataList.firstNotNullOfOrNull { it.func.findAnnotationRecursive() } } - // 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 }}" + fun throwMissingTopLevelAnnotation(): Nothing { + throwInternal("${annotationRef()} should have been checked present for command '$name'") } - "At least one top-level slash command must be annotated with ${annotationRef()}:\n$missingTopLevelRefs" + if (metadataList.size == 1) { + val metadata = metadataList.single() + if (metadata.path.nameCount == 1) { + topLevelMetadata.putIfAbsentOrThrowInternal(name, SlashCommandMetadata.TopLevel(findTopLevelAnnotation() ?: defaultTopLevelMetadata, metadata)) + } else { + val topLevelAnnotation = findTopLevelAnnotation() ?: throwMissingTopLevelAnnotation() + groupedBuilders.putIfAbsentOrThrowInternal(name, SlashCommandMetadata.Grouped.Builder(name, topLevelAnnotation, metadata)) + } + } else if (metadataList.size >= 2) { + val topLevelAnnotation = findTopLevelAnnotation() ?: throwMissingTopLevelAnnotation() + val topLevelMetadata = findTopLevelMetadata() ?: throwMissingTopLevelAnnotation() + groupedBuilders.putIfAbsentOrThrowInternal(name, SlashCommandMetadata.Grouped.Builder(name, topLevelAnnotation, topLevelMetadata)) + } else { + throwInternal("No functions for '$name'") + } } // Assign subcommands and groups - functions.forEachWithDelayedExceptions { metadata -> - if (metadata.path.nameCount < 2) return@forEachWithDelayedExceptions + functions.forEach { metadata -> + if (metadata.path.nameCount < 2) return@forEach - val topLevelMetadata = topLevelMetadata[metadata.path.name] + val builder = groupedBuilders[metadata.path.name] ?: throwInternal("Missing top level metadata '${metadata.path.name}' when assigning subcommands") if (metadata.path.nameCount == 2) { - topLevelMetadata.subcommands.add(metadata) + builder.subcommands.add(metadata) } else if (metadata.path.nameCount == 3) { - topLevelMetadata + builder .subcommandGroups - .getOrPut(metadata.path.group!!) { SlashSubcommandGroupMetadata(metadata.path.group!!) } + .getOrPut(metadata.path.group!!) { SlashCommandMetadata.Grouped.SubcommandGroup.Builder(metadata.path.group!!) } .subcommands - .getOrPut(metadata.path.subname!!) { arrayListOf() } .add(metadata) } } // For each subcommand group, find the SlashCommandGroupData from its subcommands - topLevelMetadata.values.forEach { topLevelSlashCommandMetadata -> + groupedBuilders.values.forEach { topLevelSlashCommandMetadata -> topLevelSlashCommandMetadata.subcommandGroups.values.forEach { slashSubcommandGroupMetadata -> - val groupSubcommands = slashSubcommandGroupMetadata.subcommands.values.flatten() - val annotation = groupSubcommands - .mapNotNull { metadata -> metadata.func.findAnnotationRecursive() } - .also { annotations -> - check(annotations.size <= 1) { - val refs = groupSubcommands - .filter { it.func.hasAnnotationRecursive() } - .joinAsList { it.func.shortSignature } - "Cannot have multiple ${annotationRef()} on a same subcommand group '${topLevelSlashCommandMetadata.name} ${slashSubcommandGroupMetadata.name}':\n$refs" - } + val groupSubcommands = slashSubcommandGroupMetadata.subcommands + val annotation = run { + val annotations = groupSubcommands.mapNotNull { metadata -> metadata.func.findAnnotationRecursive() } + + check(annotations.size <= 1) { + val refs = groupSubcommands + .filter { it.func.hasAnnotationRecursive() } + .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) + annotations.firstOrNull() ?: SlashCommandGroupData() + } + + slashSubcommandGroupMetadata.properties = SlashCommandMetadata.Grouped.SubcommandGroup.Properties(annotation.description) } } - } - override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declare(manager) - - override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) = declare(manager) - - private fun declare(manager: AbstractApplicationCommandManager) { - with(SkipLogger(logger)) { - topLevelMetadata - .values - .forEachWithDelayedExceptions loop@{ topLevelMetadata -> - val metadata = topLevelMetadata.metadata - runFiltered( - manager, - forceGuildCommands, - metadata, - topLevelMetadata.annotation.scope - ) { - processCommand(manager, topLevelMetadata) - } - } - log((manager as? GuildApplicationCommandManager)?.guild, JDACommand.Type.SLASH) - } + this.metadata = topLevelMetadata + groupedBuilders.mapValues { (_, builder) -> builder.build() } } context(_: SkipLogger) - private fun processCommand(manager: AbstractApplicationCommandManager, topLevelMetadata: TopLevelSlashCommandMetadata) { - val metadata = topLevelMetadata.metadata + override fun declareTopLevel(manager: AbstractApplicationCommandManager, rootCommand: SlashCommandMetadata) { + val metadata = rootCommand.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() - - // Check we don't have subcommands before filtering, else it would filter out all of them - if (isTopLevelOnly && !checkDeclarationFilter(manager, metadata.func, path, metadata.commandId)) - return // Already logged - manager.slashCommand(name, if (isTopLevelOnly) metadata.func.castFunction() else null) { + fun TopLevelSlashCommandBuilder.configureTopLevelCommons() { contexts = if (forceGuildCommands) { setOf(InteractionContextType.GUILD) } else { - topLevelMetadata.annotation.contexts.toEnumSetOr(manager.defaultContexts) + rootCommand.annotation.contexts.toEnumSetOr(manager.defaultContexts) } - integrationTypes = topLevelMetadata.annotation.integrationTypes.toEnumSetOr(manager.defaultIntegrationTypes) - isDefaultLocked = topLevelMetadata.annotation.defaultLocked - nsfw = topLevelMetadata.annotation.nsfw + integrationTypes = rootCommand.annotation.integrationTypes.toEnumSetOr(manager.defaultIntegrationTypes) + isDefaultLocked = rootCommand.annotation.defaultLocked + nsfw = rootCommand.annotation.nsfw // Prioritize [[TopLevelSlashCommandData]] as this is top level - description = topLevelMetadata.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() + description = rootCommand.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() + } - addSubcommands(manager, subcommandsMetadata, metadata.commandId) + if (rootCommand is SlashCommandMetadata.TopLevel) { + if (!checkDeclarationFilter(manager, metadata)) + return - addSubcommandGroups(manager, subcommandGroupsMetadata, metadata.commandId) + manager.slashCommand(name, metadata.func.castFunction()) { + configureTopLevelCommons() - configureBuilder(metadata) + configureBuilder(metadata) - if (isTopLevelOnly) { processOptions((manager as? GuildApplicationCommandManager)?.guild, metadata) } + } else if (rootCommand is SlashCommandMetadata.Grouped) { + val filteredSubcommands = rootCommand.subcommands.filter { subMetadata -> + checkDeclarationFilter(manager, subMetadata) + } + // Filter subcommands from groups and remove groups with no subcommands + val filteredSubcommandGroups = rootCommand.subcommandGroups.values + // Make a copy of subcommand groups but with subcommands filtered + .map { subGroupMetadata -> + subGroupMetadata.filterSubcommands { subMetadata -> + checkDeclarationFilter(manager, subMetadata) + } + } + // Remove groups without subcommands + .filter { it.subcommands.isNotEmpty() } + + if (filteredSubcommands.isEmpty() && filteredSubcommandGroups.isEmpty()) + return + + manager.slashCommand(name, function = null) { + configureTopLevelCommons() + + addSubcommands(manager, filteredSubcommands) + + addSubcommandGroups(manager, filteredSubcommandGroups) + + configureBuilder(metadata) + } } } context(_: SkipLogger) private fun TopLevelSlashCommandBuilder.addSubcommandGroups( manager: AbstractApplicationCommandManager, - subcommandGroupsMetadata: MutableMap, - commandId: String?, + subcommandGroupsMetadata: Collection, ) { - subcommandGroupsMetadata.values.forEach { groupMetadata -> + subcommandGroupsMetadata.forEach { groupMetadata -> subcommandGroup(groupMetadata.name) { description = groupMetadata.properties.description.nullIfBlank() - groupMetadata.subcommands.forEach { (subname, metadataList) -> - metadataList.forEach subcommandLoop@{ subMetadata -> - if (!checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, commandId)) - return@subcommandLoop // Already logged - - subcommand(subname, subMetadata.func.castFunction()) { - configureSubcommand(manager, subMetadata) - } + groupMetadata.subcommands.forEach { subMetadata -> + subcommand(subMetadata.path.subname!!, subMetadata.func.castFunction()) { + configureSubcommand(manager, subMetadata) } } } @@ -297,13 +284,9 @@ internal class SlashCommandAutoBuilder( context(_: SkipLogger) private fun TopLevelSlashCommandBuilder.addSubcommands( manager: AbstractApplicationCommandManager, - subcommandsMetadata: MutableList, - commandId: String?, + subcommandsMetadata: List, ) { subcommandsMetadata.forEach { subMetadata -> - if (!checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, commandId)) - return@forEach // Already logged - subcommand(subMetadata.path.subname!!, subMetadata.func.castFunction()) { configureSubcommand(manager, subMetadata) } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/UserContextCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/UserContextCommandAutoBuilder.kt index d287e72d8..511addf0c 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/UserContextCommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/UserContextCommandAutoBuilder.kt @@ -7,7 +7,6 @@ import io.github.freya022.botcommands.api.commands.application.annotations.Requi import io.github.freya022.botcommands.api.commands.application.context.annotations.JDAUserCommand import io.github.freya022.botcommands.api.commands.application.context.user.GlobalUserEvent import io.github.freya022.botcommands.api.commands.application.provider.AbstractApplicationCommandManager -import io.github.freya022.botcommands.api.commands.application.provider.GlobalApplicationCommandManager import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager import io.github.freya022.botcommands.api.core.config.BApplicationConfig import io.github.freya022.botcommands.api.core.service.ServiceContainer @@ -15,20 +14,17 @@ import io.github.freya022.botcommands.api.core.service.annotations.BService import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive import io.github.freya022.botcommands.internal.commands.SkipLogger import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.UserContextFunctionMetadata -import io.github.freya022.botcommands.internal.commands.autobuilder.* +import io.github.freya022.botcommands.internal.commands.autobuilder.castFunction import io.github.freya022.botcommands.internal.core.requiredFilter import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap import io.github.freya022.botcommands.internal.parameters.ResolverContainer import io.github.freya022.botcommands.internal.utils.FunctionFilter import io.github.freya022.botcommands.internal.utils.annotationRef import io.github.freya022.botcommands.internal.utils.throwInternal -import io.github.oshai.kotlinlogging.KotlinLogging import net.dv8tion.jda.api.interactions.InteractionContextType import net.dv8tion.jda.api.interactions.commands.Command.Type as CommandType import kotlin.reflect.KClass -private val logger = KotlinLogging.logger { } - @BService @RequiresApplicationCommands internal class UserContextCommandAutoBuilder( @@ -36,56 +32,37 @@ internal class UserContextCommandAutoBuilder( resolverContainer: ResolverContainer, functionAnnotationsMap: FunctionAnnotationsMap, serviceContainer: ServiceContainer -) : ContextCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer) { - override val commandAnnotation: KClass get() = JDAUserCommand::class - - private val userFunctions: List +) : ContextCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer) { - init { - userFunctions = functionAnnotationsMap - .getWithClassAnnotation() - .requiredFilter(FunctionFilter.nonStatic()) - .requiredFilter(FunctionFilter.firstArg(GlobalUserEvent::class)) - .map { - val func = it.function - val annotation = func.findAnnotationRecursive() ?: throwInternal("${annotationRef()} should be present") - val path = CommandPath.ofName(annotation.name) - val commandId = func.findAnnotationRecursive()?.value - - UserContextFunctionMetadata(it, annotation, path, commandId) - } - } + override val commandAnnotation: KClass get() = JDAUserCommand::class + override val commandType: CommandType + get() = CommandType.USER - //Separated functions so global command errors don't prevent guild commands from being registered - override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declareUser(manager) - override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) = declareUser(manager) + override val rootAnnotatedCommands = functionAnnotationsMap + .getWithClassAnnotation() + .requiredFilter(FunctionFilter.nonStatic()) + .requiredFilter(FunctionFilter.firstArg(GlobalUserEvent::class)) + .map { + val func = it.function + val annotation = func.findAnnotationRecursive() ?: throwInternal("${annotationRef()} should be present") + val path = CommandPath.ofName(annotation.name) + val commandId = func.findAnnotationRecursive()?.value - private fun declareUser(manager: AbstractApplicationCommandManager) { - with(SkipLogger(logger)) { - userFunctions.forEachWithDelayedExceptions { metadata -> - runFiltered( - manager, - forceGuildCommands, - metadata, - metadata.annotation.scope - ) { processUserCommand(manager, metadata) } - } - log((manager as? GuildApplicationCommandManager)?.guild, CommandType.USER) + UserContextFunctionMetadata(it, annotation, path, commandId) } - } context(_: SkipLogger) - private fun processUserCommand(manager: AbstractApplicationCommandManager, metadata: UserContextFunctionMetadata) { - val func = metadata.func - val instance = metadata.instance - val path = metadata.path - val commandId = metadata.commandId + override fun declareTopLevel( + manager: AbstractApplicationCommandManager, + rootCommand: UserContextFunctionMetadata, + ) { + val func = rootCommand.func - if (!checkDeclarationFilter(manager, metadata.func, path, metadata.commandId)) + if (!checkDeclarationFilter(manager, rootCommand)) return // Already logged - val annotation = metadata.annotation - manager.userCommand(path.name, func.castFunction()) { + val annotation = rootCommand.annotation + manager.userCommand(rootCommand.path.name, func.castFunction()) { fillCommandBuilder(func) fillApplicationCommandBuilder(func) @@ -98,7 +75,7 @@ internal class UserContextCommandAutoBuilder( isDefaultLocked = annotation.defaultLocked nsfw = annotation.nsfw - processOptions((manager as? GuildApplicationCommandManager)?.guild, func, instance, commandId) + processOptions((manager as? GuildApplicationCommandManager)?.guild, func, rootCommand.instance, rootCommand.commandId) } } } diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/MessageContextFunctionMetadata.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/MessageContextFunctionMetadata.kt index d6051124e..f3ba3bada 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/MessageContextFunctionMetadata.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/MessageContextFunctionMetadata.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata import io.github.freya022.botcommands.api.commands.CommandPath +import io.github.freya022.botcommands.api.commands.application.CommandScope import io.github.freya022.botcommands.api.commands.application.context.annotations.JDAMessageCommand import io.github.freya022.botcommands.internal.core.ClassPathFunction @@ -9,4 +10,11 @@ internal class MessageContextFunctionMetadata( annotation: JDAMessageCommand, path: CommandPath, commandId: String? -) : ApplicationFunctionMetadata(classPathFunction, annotation, path, commandId) \ No newline at end of file +) : ApplicationFunctionMetadata(classPathFunction, annotation, path, commandId), RootAnnotatedApplicationCommand { + override val metadata: ApplicationFunctionMetadata<*> + get() = this + override val scope: CommandScope + get() = annotation.scope + override val name: String + get() = path.name +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/RootAnnotatedApplicationCommand.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/RootAnnotatedApplicationCommand.kt new file mode 100644 index 000000000..d9133bd3c --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/RootAnnotatedApplicationCommand.kt @@ -0,0 +1,10 @@ +package io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata + +import io.github.freya022.botcommands.api.commands.application.CommandScope +import io.github.freya022.botcommands.internal.commands.autobuilder.metadata.MetadataFunctionHolder + +internal interface RootAnnotatedApplicationCommand : MetadataFunctionHolder { + val metadata: ApplicationFunctionMetadata<*> + val scope: CommandScope + val name: String +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/SlashCommandMetadata.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/SlashCommandMetadata.kt new file mode 100644 index 000000000..351bcf21b --- /dev/null +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/SlashCommandMetadata.kt @@ -0,0 +1,59 @@ +package io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata + +import io.github.freya022.botcommands.api.commands.application.CommandScope +import io.github.freya022.botcommands.api.commands.application.slash.annotations.TopLevelSlashCommandData +import io.github.freya022.botcommands.api.core.utils.toImmutableList +import kotlin.reflect.KFunction + +internal sealed interface SlashCommandMetadata : RootAnnotatedApplicationCommand { + val annotation: TopLevelSlashCommandData + override val metadata: SlashFunctionMetadata + + override val scope: CommandScope + get() = annotation.scope + override val name: String + get() = metadata.path.name + + class TopLevel( + override val annotation: TopLevelSlashCommandData, + override val metadata: SlashFunctionMetadata, + ) : SlashCommandMetadata { + override val func: KFunction<*> get() = metadata.func + } + + class Grouped private constructor( + override val annotation: TopLevelSlashCommandData, + override val metadata: SlashFunctionMetadata, + val subcommands: List, + val subcommandGroups: Map, + ) : SlashCommandMetadata { + override val func: KFunction<*> get() = metadata.func + + internal class Builder( + val name: String, + val annotation: TopLevelSlashCommandData, + val metadata: SlashFunctionMetadata, + ) { + val subcommands: MutableList = arrayListOf() + val subcommandGroups: MutableMap = hashMapOf() + + fun build() = Grouped(annotation, metadata, subcommands.toImmutableList(), subcommandGroups.mapValues { (_, builder) -> builder.build() }) + } + + internal class SubcommandGroup private constructor(val name: String, val properties: Properties, val subcommands: List) { + inline fun filterSubcommands(block: (SlashFunctionMetadata) -> Boolean): SubcommandGroup { + return SubcommandGroup(name, properties, subcommands.filter(block)) + } + + internal class Properties(val description: String) + + internal class Builder(val name: String) { + lateinit var properties: Properties + + val subcommands: MutableList = arrayListOf() + + fun build() = SubcommandGroup(name, properties, subcommands.toImmutableList()) + } + } + } +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/UserContextFunctionMetadata.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/UserContextFunctionMetadata.kt index fb2426f26..265ce58fb 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/UserContextFunctionMetadata.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/UserContextFunctionMetadata.kt @@ -1,6 +1,7 @@ package io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata import io.github.freya022.botcommands.api.commands.CommandPath +import io.github.freya022.botcommands.api.commands.application.CommandScope import io.github.freya022.botcommands.api.commands.application.context.annotations.JDAUserCommand import io.github.freya022.botcommands.internal.core.ClassPathFunction @@ -9,4 +10,11 @@ internal class UserContextFunctionMetadata( annotation: JDAUserCommand, path: CommandPath, commandId: String? -) : ApplicationFunctionMetadata(classPathFunction, annotation, path, commandId) \ No newline at end of file +) : ApplicationFunctionMetadata(classPathFunction, annotation, path, commandId), RootAnnotatedApplicationCommand { + override val metadata: ApplicationFunctionMetadata<*> + get() = this + override val scope: CommandScope + get() = annotation.scope + override val name: String + get() = path.name +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/AutoBuilderUtils.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/AutoBuilderUtils.kt index 5a2e1acfb..194d9ac5a 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/AutoBuilderUtils.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/AutoBuilderUtils.kt @@ -1,28 +1,13 @@ package io.github.freya022.botcommands.internal.commands.autobuilder -import io.github.freya022.botcommands.api.commands.CommandPath -import io.github.freya022.botcommands.api.commands.annotations.RateLimitReference -import io.github.freya022.botcommands.api.commands.application.ApplicationCommandFilter -import io.github.freya022.botcommands.api.commands.application.CommandScope -import io.github.freya022.botcommands.api.commands.application.annotations.DeclarationFilter -import io.github.freya022.botcommands.api.commands.application.annotations.Test -import io.github.freya022.botcommands.api.commands.application.builder.ApplicationCommandBuilder -import io.github.freya022.botcommands.api.commands.application.provider.AbstractApplicationCommandManager -import io.github.freya022.botcommands.api.commands.application.provider.GlobalApplicationCommandManager -import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager import io.github.freya022.botcommands.api.commands.builder.CommandBuilder -import io.github.freya022.botcommands.api.commands.text.annotations.NSFW -import io.github.freya022.botcommands.api.core.BContext -import io.github.freya022.botcommands.api.core.DeclarationSite -import io.github.freya022.botcommands.api.core.utils.* -import io.github.freya022.botcommands.internal.commands.SkipLogger -import io.github.freya022.botcommands.internal.commands.application.autobuilder.metadata.ApplicationFunctionMetadata -import io.github.freya022.botcommands.internal.commands.application.autobuilder.utils.ParameterAdapter +import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive +import io.github.freya022.botcommands.api.core.utils.joinAsList import io.github.freya022.botcommands.internal.commands.autobuilder.metadata.MetadataFunctionHolder -import io.github.freya022.botcommands.internal.commands.ratelimit.readRateLimit -import io.github.freya022.botcommands.internal.core.service.canCreateWrappedService -import io.github.freya022.botcommands.internal.utils.* -import kotlin.reflect.KClass +import io.github.freya022.botcommands.internal.utils.annotationRef +import io.github.freya022.botcommands.internal.utils.rethrow +import io.github.freya022.botcommands.internal.utils.shortSignature +import io.github.freya022.botcommands.internal.utils.unwrap import kotlin.reflect.KFunction //This is used so commands can't prevent other commands from being registered when an exception happens @@ -44,128 +29,6 @@ internal inline fun Iterable.forEachWithDelayedE ex?.rethrow("Exception(s) occurred while registering annotated commands") } -context(_: CommandAutoBuilder, logger: SkipLogger) -internal fun runFiltered( - manager: AbstractApplicationCommandManager, - forceGuildCommands: Boolean, - applicationFunctionMetadata: ApplicationFunctionMetadata<*>, - scope: CommandScope, - block: () -> Unit -) { - val path = applicationFunctionMetadata.path - val func = applicationFunctionMetadata.func - - // On global manager, do not register any command if forceGuildCommands is enabled, - // as none of them would be global - if (manager is GlobalApplicationCommandManager && forceGuildCommands) - return - - // If guild commands aren't forced, check the scope - if (!forceGuildCommands) { - val requiredScope = when (manager) { - is GlobalApplicationCommandManager -> CommandScope.GLOBAL - is GuildApplicationCommandManager -> CommandScope.GUILD - } - if (requiredScope != scope) return - } - - val testState = checkTestCommand(manager, func, scope, manager.context) - if (scope == CommandScope.GLOBAL && testState != TestState.NO_ANNOTATION) - throwInternal("Test commands on a global scope should have thrown in ${::checkTestCommand.shortSignatureNoSrc}") - - if (testState == TestState.EXCLUDE) - return logger.skip(path, "Is a test command while this guild isn't a test guild") - - block() -} - -context(autoBuilder: CommandAutoBuilder, logger: SkipLogger) -internal fun checkDeclarationFilter( - manager: AbstractApplicationCommandManager, - func: KFunction<*>, - path: CommandPath, - commandId: String?, -): Boolean { - func.findAllAnnotations().forEach { declarationFilter -> - checkAt(manager is GuildApplicationCommandManager, func) { - "${annotationRef()} can only be used on guild commands" - } - - declarationFilter.filters.forEach { - if (!autoBuilder.serviceContainer.getService(it).filter(manager.guild, path, commandId)) { - val commandIdStr = if (commandId != null) " (id ${commandId})" else "" - logger.skip(path, "${it.simpleNestedName} rejected this command$commandIdStr") - return false - } - } - } - return true -} - -context(_: CommandAutoBuilder) -internal inline fun > Array.toEnumSetOr(fallback: Set): Set = when { - this.isEmpty() -> fallback - else -> enumSetOf(*this) -} - -internal enum class TestState { - INCLUDE, - EXCLUDE, - NO_ANNOTATION -} - -internal fun checkTestCommand(manager: AbstractApplicationCommandManager, func: KFunction<*>, scope: CommandScope, context: BContext): TestState { - if (func.hasAnnotationRecursive()) { - requireAt(scope == CommandScope.GUILD, func) { - "Test commands must have their scope set to GUILD" - } - if (manager !is GuildApplicationCommandManager) throwInternal("GUILD scoped command was not registered with a guild command manager") - - //Returns whether the command can be registered - return when (manager.guild.idLong) { - in AnnotationUtils.getEffectiveTestGuildIds(context, func) -> TestState.INCLUDE - else -> TestState.EXCLUDE - } - } - - return TestState.NO_ANNOTATION -} - -internal fun CommandBuilder.fillCommandBuilder(functions: List>) { - declarationSite = functions.first().let(DeclarationSite::fromFunctionSignature) - - val rateLimiter = functions.singleValueOfVariants("their rate limit specification") { readRateLimit(it) } - val rateLimitRef = functions.singleAnnotationOfVariants() - - // A single one of them can be used - One of them needs to be null - check(rateLimitRef == null || rateLimiter == null) { - "You can either define a rate limit or reference one, but not both" - } - - if (rateLimiter != null) { - rateLimit(rateLimiter) { - declarationSite = this@fillCommandBuilder.declarationSite - } - } - - if (rateLimitRef != null) { - rateLimitReference(rateLimitRef.group) - } - - functions - .singleValueOfVariants("user permission") { f -> - AnnotationUtils.getUserPermissions(f).takeIf { it.isNotEmpty() } - } - ?.let { userPermissions = it } - functions - .singleValueOfVariants("bot permissions") { f -> - AnnotationUtils.getBotPermissions(f).takeIf { it.isNotEmpty() } - } - ?.let { botPermissions = it } -} - -internal fun CommandBuilder.fillCommandBuilder(func: KFunction<*>) = fillCommandBuilder(listOf(func)) - context(_: CommandBuilder) internal inline fun Iterable>.singlePresentAnnotationOfVariants(): Boolean { return singleAnnotationOfVariants() != null @@ -190,24 +53,3 @@ internal fun Iterable>.singleValueOfVariants(desc: String @Suppress("UNCHECKED_CAST") internal fun KFunction<*>.castFunction() = this as KFunction - -internal fun ApplicationCommandBuilder<*>.fillApplicationCommandBuilder(func: KFunction<*>) { - filters += AnnotationUtils.getFilters(context, func, ApplicationCommandFilter::class) - - if (func.hasAnnotationRecursive()) { - throwArgument(func, "${annotationRef()} can only be used on text commands, use the #nsfw method on your annotation instead") - } -} - -internal fun CommandAutoBuilder.requireServiceOptionOrOptional(func: KFunction<*>, parameterAdapter: ParameterAdapter, commandAnnotation: KClass) { - if (parameterAdapter.isOptionalOrNullable) return - - val serviceError = serviceContainer.canCreateWrappedService(parameterAdapter.valueParameter) ?: return - val originalParameter = parameterAdapter.originalParameter - throwArgument( - func, - "Cannot determine usage of option '${originalParameter.bestName}' (${originalParameter.type.simpleNestedName}) and service loading failed, " + - "if this is a Discord option, use @${optionAnnotation.simpleNestedName}, check @${commandAnnotation.simpleNestedName} for more details\n" + - serviceError.toDetailedString() - ) -} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/CommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/CommandAutoBuilder.kt index 0d55633c6..d2fdf036b 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/CommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/autobuilder/CommandAutoBuilder.kt @@ -1,10 +1,74 @@ package io.github.freya022.botcommands.internal.commands.autobuilder +import io.github.freya022.botcommands.api.commands.annotations.RateLimitReference +import io.github.freya022.botcommands.api.commands.builder.CommandBuilder +import io.github.freya022.botcommands.api.core.DeclarationSite import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.api.core.utils.bestName +import io.github.freya022.botcommands.api.core.utils.enumSetOf +import io.github.freya022.botcommands.api.core.utils.simpleNestedName +import io.github.freya022.botcommands.internal.commands.application.autobuilder.utils.ParameterAdapter +import io.github.freya022.botcommands.internal.commands.ratelimit.readRateLimit +import io.github.freya022.botcommands.internal.core.service.canCreateWrappedService +import io.github.freya022.botcommands.internal.utils.AnnotationUtils +import io.github.freya022.botcommands.internal.utils.throwArgument import kotlin.reflect.KClass +import kotlin.reflect.KFunction -internal interface CommandAutoBuilder { - val serviceContainer: ServiceContainer - val optionAnnotation: KClass -} +internal abstract class CommandAutoBuilder { + protected abstract val serviceContainer: ServiceContainer + protected abstract val optionAnnotation: KClass + + protected fun CommandBuilder.fillCommandBuilder(functions: List>) { + declarationSite = functions.first().let(DeclarationSite::fromFunctionSignature) + + val rateLimiter = functions.singleValueOfVariants("their rate limit specification") { readRateLimit(it) } + val rateLimitRef = functions.singleAnnotationOfVariants() + + // A single one of them can be used - One of them needs to be null + check(rateLimitRef == null || rateLimiter == null) { + "You can either define a rate limit or reference one, but not both" + } + + if (rateLimiter != null) { + rateLimit(rateLimiter) { + declarationSite = this@fillCommandBuilder.declarationSite + } + } + + if (rateLimitRef != null) { + rateLimitReference(rateLimitRef.group) + } + functions + .singleValueOfVariants("user permission") { f -> + AnnotationUtils.getUserPermissions(f).takeIf { it.isNotEmpty() } + } + ?.let { userPermissions = it } + functions + .singleValueOfVariants("bot permissions") { f -> + AnnotationUtils.getBotPermissions(f).takeIf { it.isNotEmpty() } + } + ?.let { botPermissions = it } + } + + protected fun CommandBuilder.fillCommandBuilder(func: KFunction<*>) = fillCommandBuilder(listOf(func)) + + protected fun requireServiceOptionOrOptional(func: KFunction<*>, parameterAdapter: ParameterAdapter, commandAnnotation: KClass) { + if (parameterAdapter.isOptionalOrNullable) return + + val serviceError = serviceContainer.canCreateWrappedService(parameterAdapter.valueParameter) ?: return + val originalParameter = parameterAdapter.originalParameter + throwArgument( + func, + "Cannot determine usage of option '${originalParameter.bestName}' (${originalParameter.type.simpleNestedName}) and service loading failed, " + + "if this is a Discord option, use @${optionAnnotation.simpleNestedName}, check @${commandAnnotation.simpleNestedName} for more details\n" + + serviceError.toDetailedString() + ) + } + + protected inline fun > Array.toEnumSetOr(fallback: Set): Set = when { + this.isEmpty() -> fallback + else -> enumSetOf(*this) + } +} diff --git a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/text/autobuilder/TextCommandAutoBuilder.kt b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/text/autobuilder/TextCommandAutoBuilder.kt index 5cd3a8687..a0755b46c 100644 --- a/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/text/autobuilder/TextCommandAutoBuilder.kt +++ b/BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/text/autobuilder/TextCommandAutoBuilder.kt @@ -23,7 +23,10 @@ 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.resolvers.ICustomResolver import io.github.freya022.botcommands.internal.commands.application.autobuilder.utils.ParameterAdapter -import io.github.freya022.botcommands.internal.commands.autobuilder.* +import io.github.freya022.botcommands.internal.commands.autobuilder.CommandAutoBuilder +import io.github.freya022.botcommands.internal.commands.autobuilder.castFunction +import io.github.freya022.botcommands.internal.commands.autobuilder.forEachWithDelayedExceptions +import io.github.freya022.botcommands.internal.commands.autobuilder.singlePresentAnnotationOfVariants import io.github.freya022.botcommands.internal.commands.components import io.github.freya022.botcommands.internal.commands.text.TextCommandComparator import io.github.freya022.botcommands.internal.commands.text.autobuilder.metadata.TextFunctionMetadata @@ -45,7 +48,7 @@ internal class TextCommandAutoBuilder( private val resolverContainer: ResolverContainer, functionAnnotationsMap: FunctionAnnotationsMap, override val serviceContainer: ServiceContainer -) : CommandAutoBuilder, TextCommandProvider { +) : CommandAutoBuilder(), TextCommandProvider { private class TextCommandContainer(val name: String) { var extraData: TextCommandData = defaultExtraData val hasExtraData get() = extraData !== defaultExtraData diff --git a/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/commands/application/autobuilder/SlashCommandAutoBuilderTest.kt b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/commands/application/autobuilder/SlashCommandAutoBuilderTest.kt new file mode 100644 index 000000000..5e755b71e --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/commands/application/autobuilder/SlashCommandAutoBuilderTest.kt @@ -0,0 +1,358 @@ +package io.github.freya022.botcommands.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.application.ApplicationCommand +import io.github.freya022.botcommands.api.commands.application.CommandDeclarationFilter +import io.github.freya022.botcommands.api.commands.application.CommandScope +import io.github.freya022.botcommands.api.commands.application.annotations.DeclarationFilter +import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager +import io.github.freya022.botcommands.api.commands.application.provider.GuildApplicationCommandManager.Defaults +import io.github.freya022.botcommands.api.commands.application.slash.GlobalSlashEvent +import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand +import io.github.freya022.botcommands.api.commands.application.slash.annotations.SlashCommandGroupData +import io.github.freya022.botcommands.api.commands.application.slash.annotations.TopLevelSlashCommandData +import io.github.freya022.botcommands.api.commands.application.slash.builder.TopLevelSlashCommandBuilder +import io.github.freya022.botcommands.api.core.config.BApplicationConfig +import io.github.freya022.botcommands.api.core.service.ServiceContainer +import io.github.freya022.botcommands.internal.commands.SkipLogger +import io.github.freya022.botcommands.internal.commands.application.autobuilder.SlashCommandAutoBuilder +import io.github.freya022.botcommands.internal.commands.application.slash.builder.SlashSubcommandGroupBuilderImpl +import io.github.freya022.botcommands.internal.core.ClassPathFunction +import io.github.freya022.botcommands.internal.core.service.FunctionAnnotationsMap +import io.github.freya022.botcommands.internal.parameters.ResolverContainer +import io.github.freya022.botcommands.internal.utils.annotationRef +import io.mockk.* +import net.dv8tion.jda.api.entities.Guild +import org.junit.jupiter.api.* +import kotlin.test.Test +import kotlin.test.assertContains + +class SlashCommandAutoBuilderTest { + private val serviceContainer = mockk() + private val applicationConfig = mockk { + every { forceGuildCommands } returns false + } + private val resolverContainer = mockk() + private val functionAnnotationsMap = mockk() + + @AfterEach + fun tearDown() { + try { + checkUnnecessaryStub() + } finally { + // "finally" avoids keeping unnecessary stub errors from one test to another + clearAllMocks() + } + } + + @Nested + inner class CommandsWithSamePath : ApplicationCommand() { + @JDASlashCommand(name = "test_command") + fun command1(event: GlobalSlashEvent) { consume(event) } + + @JDASlashCommand(name = "test_command") + fun command2(event: GlobalSlashEvent) { consume(event) } + + @Test + fun `Cannot have multiple commands with same path`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithSamePath, CommandsWithSamePath::command1), + ClassPathFunction(this@CommandsWithSamePath, CommandsWithSamePath::command2), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Multiple annotated commands share the same path") + } + } + + @Nested + inner class CommandAnnotations : ApplicationCommand() { + @JDASlashCommand(name = "test_command", subcommand = "sub1") + fun subcommand1(event: GlobalSlashEvent) { consume(event) } + + @JDASlashCommand(name = "test_command", subcommand = "sub2") + fun subcommand2(event: GlobalSlashEvent) { consume(event) } + + @TopLevelSlashCommandData + @JDASlashCommand(name = "test_command", subcommand = "sub3") + fun subcommand3(event: GlobalSlashEvent) { consume(event) } + + @TopLevelSlashCommandData + @SlashCommandGroupData + @JDASlashCommand(name = "test_command", group = "group", subcommand = "sub1") + fun groupSubcommand1(event: GlobalSlashEvent) { consume(event) } + + @SlashCommandGroupData + @JDASlashCommand(name = "test_command", group = "group", subcommand = "sub2") + fun groupSubcommand2(event: GlobalSlashEvent) { consume(event) } + + @Test + fun `Check subcommands are missing top level annotation`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::subcommand1), + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::subcommand2), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Subcommands must have at least one function be annotated with ${annotationRef()}") + } + + @Test + fun `Check subcommands have top level annotation`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::subcommand2), + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::subcommand3), + ) + } + + assertDoesNotThrow { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + } + + @Test + fun `Check group subcommands cannot have more than one group annotation`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::groupSubcommand1), + ClassPathFunction(this@CommandAnnotations, CommandAnnotations::groupSubcommand2), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Cannot have multiple ${annotationRef()} on a same subcommand group") + } + } + + @Nested + inner class CommandsWithMixedPathLengths : ApplicationCommand() { + @JDASlashCommand(name = "test_command") + fun topLevel(event: GlobalSlashEvent) { consume(event) } + + @TopLevelSlashCommandData + @JDASlashCommand(name = "test_command", subcommand = "subcommand") + fun subcommand(event: GlobalSlashEvent) { consume(event) } + + @JDASlashCommand(name = "test_command", group = "group") + fun incompleteGroupSubcommand(event: GlobalSlashEvent) { consume(event) } + + @JDASlashCommand(name = "test_command", group = "group", subcommand = "subcommand") + fun groupSubcommand(event: GlobalSlashEvent) { consume(event) } + + @Test + fun `Cannot have command with group set but subcommands unset`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::incompleteGroupSubcommand), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Slash commands with groups need to have their subcommand name set") + } + + @Test + fun `Cannot have top level and subcommand`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::topLevel), + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::subcommand), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Cannot have both top level commands with subcommands") + } + + @Test + fun `Cannot have top level and group subcommand`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::topLevel), + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::groupSubcommand), + ) + } + + val exception = assertThrows { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + + assertContains(exception.message!!, "Cannot have both top level commands with subcommands") + } + + @Test + fun `Allow subcommand with group subcommand`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::subcommand), + ClassPathFunction(this@CommandsWithMixedPathLengths, CommandsWithMixedPathLengths::groupSubcommand), + ) + } + + assertDoesNotThrow { + SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + } + } + } + + @Nested + inner class CommandsWithDeclarationFilters : ApplicationCommand() { + // TODO test that DeclarationFilter only works on guild commands (throw otherwise) + + inner class DoNotDeclare : CommandDeclarationFilter { + override fun filter(guild: Guild, path: CommandPath, commandId: String?): Boolean = false + } + + @BeforeEach + fun init() { + every { serviceContainer.getService(DoNotDeclare::class) } returns DoNotDeclare() + } + + @TopLevelSlashCommandData(scope = CommandScope.GUILD) + @JDASlashCommand(name = "test_command") + @DeclarationFilter(DoNotDeclare::class) + fun topLevel(event: GlobalSlashEvent) { consume(event) } + + @TopLevelSlashCommandData(scope = CommandScope.GUILD) + @JDASlashCommand(name = "test_command", subcommand = "subcommand") + @DeclarationFilter(DoNotDeclare::class) + fun subcommand(event: GlobalSlashEvent) { consume(event) } + + + @DeclarationFilter(DoNotDeclare::class) + @TopLevelSlashCommandData(scope = CommandScope.GUILD) + @JDASlashCommand(name = "test_command", group = "group1", subcommand = "add") + fun group1Subcommand(event: GlobalSlashEvent) { consume(event) } + + @JDASlashCommand(name = "test_command", group = "group2", subcommand = "get") +// @DeclarationFilter(DoNotDeclare::class) // Do not filter out! + fun group2Subcommand(event: GlobalSlashEvent) { consume(event) } + + @Test + fun `Top-level command is filtered`() { + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithDeclarationFilters, CommandsWithDeclarationFilters::topLevel), + ) + } + + val autoBuilder = spyk(SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap)) + val manager = mockk { + every { guild } returns mockk() + every { context } returns mockk() + } + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 1) { context(any()) { autoBuilder.checkDeclarationFilter(manager, any()) } } + verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + } + + @Test + fun `All subcommands being filtered also disables the top-level command`() { + // 1 subcommand, filter it, check top level isn't declared + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithDeclarationFilters, CommandsWithDeclarationFilters::subcommand), + ) + } + + val autoBuilder = spyk(SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap)) + val manager = mockk { + every { guild } returns mockk() + every { context } returns mockk() + } + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + verify(exactly = 1) { context(any()) { autoBuilder.checkDeclarationFilter(manager, any()) } } + } + + @Test + fun `All group subcommands being filtered also disables the group and top-level command`() { + // 1 group, 1 subcommand, filter subcommand, check nothing is declared + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithDeclarationFilters, CommandsWithDeclarationFilters::group1Subcommand), + ) + } + + val autoBuilder = spyk(SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap)) + + val manager = mockk { + every { guild } returns mockk() + every { context } returns mockk() + } + + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + verify(exactly = 1) { context(any()) { autoBuilder.checkDeclarationFilter(manager, any()) } } + } + + @Test + fun `A subcommand being filtered off a single-child group also disables the group`() { + // 2 groups, 1 subcommand each, only one of them gets filtered out, check group is removed and other is kept + every { functionAnnotationsMap.getWithClassAnnotation(Command::class, JDASlashCommand::class) } answers { + listOf( + ClassPathFunction(this@CommandsWithDeclarationFilters, CommandsWithDeclarationFilters::group1Subcommand), + ClassPathFunction(this@CommandsWithDeclarationFilters, CommandsWithDeclarationFilters::group2Subcommand), + ) + } + + val autoBuilder = SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + + // Mock builders and run the real lambdas against them, + // so we can record the calls they do + val groupBuilder = mockk(relaxed = true) + val builder = mockk(relaxed = true) { + every { subcommandGroup(any(), any()) } answers { + groupBuilder.apply(lastArg()) + return@answers + } + } + + val manager = mockk{ + every { slashCommand(any(), any(), any()) } answers { + builder.apply(lastArg()) + return@answers + } + every { guild } returns mockk() + every { context } returns mockk() + every { defaultContexts } returns Defaults.contexts + every { defaultIntegrationTypes } returns Defaults.integrationTypes + } + + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 0) { builder.subcommand(any(), any(), any()) } + verify(exactly = 1) { builder.subcommandGroup("group2", any()) } + verify(exactly = 1) { groupBuilder.subcommand(any(), any(), any()) } + } + } + + // TODO test that Test only works on guild commands (throw otherwise) + + @Suppress("NOTHING_TO_INLINE", "unused") + private inline fun consume(e: Any) {} +} diff --git a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/commands/slash/SlashDeclarationFilter.kt b/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/commands/slash/SlashDeclarationFilter.kt deleted file mode 100644 index ab2ac4f65..000000000 --- a/test-bot/src/test/kotlin/dev/freya02/botcommands/bot/commands/slash/SlashDeclarationFilter.kt +++ /dev/null @@ -1,53 +0,0 @@ -package dev.freya02.botcommands.bot.commands.slash - -import dev.freya02.botcommands.jda.ktx.coroutines.await -import dev.freya02.botcommands.jda.ktx.messages.reply_ -import io.github.freya022.botcommands.api.commands.CommandPath -import io.github.freya022.botcommands.api.commands.annotations.Command -import io.github.freya022.botcommands.api.commands.application.ApplicationCommand -import io.github.freya022.botcommands.api.commands.application.CommandDeclarationFilter -import io.github.freya022.botcommands.api.commands.application.CommandScope -import io.github.freya022.botcommands.api.commands.application.annotations.DeclarationFilter -import io.github.freya022.botcommands.api.commands.application.slash.GuildSlashEvent -import io.github.freya022.botcommands.api.commands.application.slash.annotations.JDASlashCommand -import io.github.freya022.botcommands.api.commands.application.slash.annotations.TopLevelSlashCommandData -import io.github.freya022.botcommands.api.core.conditions.RequiredIntents -import io.github.freya022.botcommands.api.core.service.annotations.BService -import net.dv8tion.jda.api.entities.Guild -import net.dv8tion.jda.api.requests.GatewayIntent - -@BService -@RequiredIntents(GatewayIntent.GUILD_MEMBERS) -class BigGuildDeclarationFilter : CommandDeclarationFilter { - override fun filter(guild: Guild, path: CommandPath, commandId: String?): Boolean { - return guild.memberCount > 10 // not big, for testing yk - } -} - -@BService -object ImpossibleDeclarationFilter : CommandDeclarationFilter { - override fun filter(guild: Guild, path: CommandPath, commandId: String?): Boolean = false -} - -@Command -@RequiredIntents(GatewayIntent.GUILD_MEMBERS) -class SlashDeclarationFilter : ApplicationCommand() { - @JDASlashCommand(name = "declaration_filter") - @DeclarationFilter(BigGuildDeclarationFilter::class) - @TopLevelSlashCommandData(scope = CommandScope.GUILD) - suspend fun onSlashDeclarationFilter(event: GuildSlashEvent) { - event.reply_("Works, guild members: ${event.guild.memberCount}", ephemeral = true).await() - } - - @DeclarationFilter(ImpossibleDeclarationFilter::class) - @JDASlashCommand(name = "declaration_filter_subcommand", subcommand = "subcommand") - @TopLevelSlashCommandData(scope = CommandScope.GUILD) - fun onSlashDeclarationFilterSubcommand(event: GuildSlashEvent) { - throw AssertionError("Cannot run") - } - - @JDASlashCommand(name = "declaration_filter_subcommand", group = "group", subcommand = "subcommand") - suspend fun onSlashDeclarationFilterSubcommandGroupSubcommand(event: GuildSlashEvent) { - event.reply_("Works", ephemeral = true).await() - } -}