From c63e348cd27c10b6c379f3118498e0cde99d8cdc Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 27 Dec 2025 13:13:01 +0100 Subject: [PATCH 1/6] Refactor annotated command declarations to pre-filter declaration of subcommands --- .../autobuilder/SlashCommandAutoBuilder.kt | 69 ++++++++++--------- 1 file changed, 38 insertions(+), 31 deletions(-) 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..0a0bcdabd 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 @@ -72,7 +72,7 @@ internal class SlashCommandAutoBuilder( lateinit var properties: Properties - val subcommands: MutableMap> = hashMapOf() + val subcommands: MutableList = arrayListOf() } override val optionAnnotation: KClass = SlashOption::class @@ -181,7 +181,6 @@ internal class SlashCommandAutoBuilder( .subcommandGroups .getOrPut(metadata.path.group!!) { SlashSubcommandGroupMetadata(metadata.path.group!!) } .subcommands - .getOrPut(metadata.path.subname!!) { arrayListOf() } .add(metadata) } } @@ -189,7 +188,7 @@ internal class SlashCommandAutoBuilder( // 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 groupSubcommands = slashSubcommandGroupMetadata.subcommands val annotation = groupSubcommands .mapNotNull { metadata -> metadata.func.findAnnotationRecursive() } .also { annotations -> @@ -237,15 +236,33 @@ internal class SlashCommandAutoBuilder( 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) { + val filteredSubcommands = topLevelMetadata.subcommands.filter { subMetadata -> + checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } + // Filter subcommands from groups + topLevelMetadata.subcommandGroups.values.forEach { subGroupMetadata -> + subGroupMetadata.subcommands.removeIf { subMetadata -> + !checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } + } + // Remove groups with no subcommands + val filteredSubcommandGroups = topLevelMetadata.subcommandGroups.values.filter { it.subcommands.isNotEmpty() } + + val isTopLevelOnly = topLevelMetadata.subcommands.isEmpty() && topLevelMetadata.subcommandGroups.isEmpty() + // If we don't have a top level command and no subcommands then abort + if (!isTopLevelOnly && filteredSubcommands.isEmpty() && filteredSubcommandGroups.isEmpty()) + return + + // The top level command may not be executable, but it may still be declared for its subcommands + val topLevelFunction = when { + // Top level is filtered but has subcommands + !checkDeclarationFilter(manager, metadata.func, path, metadata.commandId) -> null + // Has no top-level declaration but has subcommands + !isTopLevelOnly -> null + // Not filtered, has top-level declaration and possibly subcommands + else -> metadata.func.castFunction() + } + manager.slashCommand(name, topLevelFunction) { contexts = if (forceGuildCommands) { setOf(InteractionContextType.GUILD) } else { @@ -258,13 +275,13 @@ internal class SlashCommandAutoBuilder( // Prioritize [[TopLevelSlashCommandData]] as this is top level description = topLevelMetadata.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() - addSubcommands(manager, subcommandsMetadata, metadata.commandId) + addSubcommands(manager, filteredSubcommands) - addSubcommandGroups(manager, subcommandGroupsMetadata, metadata.commandId) + addSubcommandGroups(manager, filteredSubcommandGroups) configureBuilder(metadata) - if (isTopLevelOnly) { + if (topLevelFunction != null) { processOptions((manager as? GuildApplicationCommandManager)?.guild, metadata) } } @@ -273,21 +290,15 @@ internal class SlashCommandAutoBuilder( 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 +308,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) } From 3c83c9ec7e5425c33eec3c6420a53338aa14e673 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 27 Dec 2025 15:42:10 +0100 Subject: [PATCH 2/6] Refactor slash command auto builder to use immutable objects --- .../autobuilder/SlashCommandAutoBuilder.kt | 88 ++++++++++++------- 1 file changed, 55 insertions(+), 33 deletions(-) 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 0a0bcdabd..8dd16bd29 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 @@ -22,10 +22,7 @@ import io.github.freya022.botcommands.api.core.options.builder.inlineClassAggreg import io.github.freya022.botcommands.api.core.reflect.wrap import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive -import io.github.freya022.botcommands.api.core.utils.hasAnnotationRecursive -import io.github.freya022.botcommands.api.core.utils.joinAsList -import io.github.freya022.botcommands.api.core.utils.nullIfBlank +import io.github.freya022.botcommands.api.core.utils.* 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.SlashFunctionMetadata @@ -48,40 +45,61 @@ import kotlin.reflect.jvm.jvmErasure private val logger = KotlinLogging.logger { } private val defaultTopLevelMetadata = TopLevelSlashCommandData() -@BService -@RequiresApplicationCommands -internal class SlashCommandAutoBuilder( - override val serviceContainer: ServiceContainer, - applicationConfig: BApplicationConfig, - private val resolverContainer: ResolverContainer, - functionAnnotationsMap: FunctionAnnotationsMap -) : CommandAutoBuilder, GlobalApplicationCommandProvider, GuildApplicationCommandProvider { - private class TopLevelSlashCommandMetadata( +private class TopLevelSlashCommandMetadata private constructor( + val name: String, + val annotation: TopLevelSlashCommandData, + val metadata: SlashFunctionMetadata, + val subcommands: List, + val subcommandGroups: Map, +) : MetadataFunctionHolder { + override val func: KFunction<*> get() = metadata.func + + class Builder( val name: String, val annotation: TopLevelSlashCommandData, - val metadata: SlashFunctionMetadata - ) : MetadataFunctionHolder { - override val func: KFunction<*> get() = metadata.func - + val metadata: SlashFunctionMetadata, + ) { val subcommands: MutableList = arrayListOf() - val subcommandGroups: MutableMap = hashMapOf() + val subcommandGroups: MutableMap = hashMapOf() + + fun build() = TopLevelSlashCommandMetadata(name, annotation, metadata, subcommands.toImmutableList(), subcommandGroups.mapValues { (_, builder) -> builder.build() }) + } +} + +private class SlashSubcommandGroupMetadata private constructor(val name: String, val properties: Properties, val subcommands: List) { + inline fun filterSubcommands(block: (SlashFunctionMetadata) -> Boolean): SlashSubcommandGroupMetadata { + return SlashSubcommandGroupMetadata(name, properties, subcommands.filter(block)) } - private class SlashSubcommandGroupMetadata(val name: String) { - class Properties(val description: String) + class Properties(val description: String) + class Builder(val name: String) { lateinit var properties: Properties val subcommands: MutableList = arrayListOf() + + fun build() = SlashSubcommandGroupMetadata(name, properties, subcommands.toImmutableList()) } +} + +@BService +@RequiresApplicationCommands +internal class SlashCommandAutoBuilder( + override val serviceContainer: ServiceContainer, + applicationConfig: BApplicationConfig, + private val resolverContainer: ResolverContainer, + functionAnnotationsMap: FunctionAnnotationsMap +) : CommandAutoBuilder, GlobalApplicationCommandProvider, GuildApplicationCommandProvider { override val optionAnnotation: KClass = SlashOption::class private val forceGuildCommands = applicationConfig.forceGuildCommands - private val topLevelMetadata: MutableMap = hashMapOf() + private val topLevelMetadata: Map init { + val topLevelBuilders: MutableMap = hashMapOf() + val functions: List = functionAnnotationsMap .getWithClassAnnotation() @@ -140,7 +158,7 @@ internal class SlashCommandAutoBuilder( } missingTopLevels.remove(name) - topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(name, annotation, slashFunctionMetadata)) + topLevelBuilders.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata.Builder(name, annotation, slashFunctionMetadata)) } } @@ -155,7 +173,7 @@ internal class SlashCommandAutoBuilder( .forEach { slashFunctionMetadata -> val name = slashFunctionMetadata.path.name missingTopLevels.remove(name) - topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(name, defaultTopLevelMetadata, slashFunctionMetadata)) + topLevelBuilders.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata.Builder(name, defaultTopLevelMetadata, slashFunctionMetadata)) } // Check if all commands have their metadata @@ -172,21 +190,21 @@ internal class SlashCommandAutoBuilder( functions.forEachWithDelayedExceptions { metadata -> if (metadata.path.nameCount < 2) return@forEachWithDelayedExceptions - val topLevelMetadata = topLevelMetadata[metadata.path.name] + val topLevelMetadata = topLevelBuilders[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!!) { SlashSubcommandGroupMetadata(metadata.path.group!!) } + .getOrPut(metadata.path.group!!) { SlashSubcommandGroupMetadata.Builder(metadata.path.group!!) } .subcommands .add(metadata) } } // For each subcommand group, find the SlashCommandGroupData from its subcommands - topLevelMetadata.values.forEach { topLevelSlashCommandMetadata -> + topLevelBuilders.values.forEach { topLevelSlashCommandMetadata -> topLevelSlashCommandMetadata.subcommandGroups.values.forEach { slashSubcommandGroupMetadata -> val groupSubcommands = slashSubcommandGroupMetadata.subcommands val annotation = groupSubcommands @@ -204,6 +222,8 @@ internal class SlashCommandAutoBuilder( slashSubcommandGroupMetadata.properties = SlashSubcommandGroupMetadata.Properties(annotation.description) } } + + this.topLevelMetadata = topLevelBuilders.mapValues { (_, builder) -> builder.build() } } override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declare(manager) @@ -239,14 +259,16 @@ internal class SlashCommandAutoBuilder( val filteredSubcommands = topLevelMetadata.subcommands.filter { subMetadata -> checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) } - // Filter subcommands from groups - topLevelMetadata.subcommandGroups.values.forEach { subGroupMetadata -> - subGroupMetadata.subcommands.removeIf { subMetadata -> - !checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + // Filter subcommands from groups and remove groups with no subcommands + val filteredSubcommandGroups = topLevelMetadata.subcommandGroups.values + // Make a copy of subcommand groups but with subcommands filtered + .map { subGroupMetadata -> + subGroupMetadata.filterSubcommands { subMetadata -> + checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } } - } - // Remove groups with no subcommands - val filteredSubcommandGroups = topLevelMetadata.subcommandGroups.values.filter { it.subcommands.isNotEmpty() } + // Remove groups without subcommands + .filter { it.subcommands.isNotEmpty() } val isTopLevelOnly = topLevelMetadata.subcommands.isEmpty() && topLevelMetadata.subcommandGroups.isEmpty() // If we don't have a top level command and no subcommands then abort From eaabed0e09ebbdcd5abdbeee0536372ff59beb6c Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sat, 27 Dec 2025 19:05:59 +0100 Subject: [PATCH 3/6] Separate top level and grouped (subcommands) metadata --- .../autobuilder/SlashCommandAutoBuilder.kt | 221 ++++++++++-------- 1 file changed, 122 insertions(+), 99 deletions(-) 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 8dd16bd29..8f2e227a7 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 @@ -45,13 +45,24 @@ import kotlin.reflect.jvm.jvmErasure private val logger = KotlinLogging.logger { } private val defaultTopLevelMetadata = TopLevelSlashCommandData() -private class TopLevelSlashCommandMetadata private constructor( - val name: String, - val annotation: TopLevelSlashCommandData, - val metadata: SlashFunctionMetadata, +private sealed interface SlashCommandMetadata : MetadataFunctionHolder { + val annotation: TopLevelSlashCommandData + val metadata: SlashFunctionMetadata +} + +private class TopLevelSlashCommandMetadata( + override val annotation: TopLevelSlashCommandData, + override val metadata: SlashFunctionMetadata, +) : SlashCommandMetadata { + override val func: KFunction<*> get() = metadata.func +} + +private class GroupedSlashCommandMetadata private constructor( + override val annotation: TopLevelSlashCommandData, + override val metadata: SlashFunctionMetadata, val subcommands: List, val subcommandGroups: Map, -) : MetadataFunctionHolder { +) : SlashCommandMetadata { override val func: KFunction<*> get() = metadata.func class Builder( @@ -62,7 +73,7 @@ private class TopLevelSlashCommandMetadata private constructor( val subcommands: MutableList = arrayListOf() val subcommandGroups: MutableMap = hashMapOf() - fun build() = TopLevelSlashCommandMetadata(name, annotation, metadata, subcommands.toImmutableList(), subcommandGroups.mapValues { (_, builder) -> builder.build() }) + fun build() = GroupedSlashCommandMetadata(annotation, metadata, subcommands.toImmutableList(), subcommandGroups.mapValues { (_, builder) -> builder.build() }) } } @@ -95,10 +106,11 @@ internal class SlashCommandAutoBuilder( private val forceGuildCommands = applicationConfig.forceGuildCommands - private val topLevelMetadata: Map + private val metadata: Map init { - val topLevelBuilders: MutableMap = hashMapOf() + val topLevelMetadata: MutableMap = hashMapOf() + val groupedBuilders: MutableMap = hashMapOf() val functions: List = functionAnnotationsMap @@ -129,9 +141,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) { @@ -145,57 +156,65 @@ 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) - topLevelBuilders.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata.Builder(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) - topLevelBuilders.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata.Builder(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() } } - // 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 findTopLevelAnnotation(): TopLevelSlashCommandData? { + return metadataList.firstNotNullOfOrNull { it.func.findAnnotationRecursive() } } - "At least one top-level slash command must be annotated with ${annotationRef()}:\n$missingTopLevelRefs" + fun throwMissingTopLevelAnnotation(): Nothing { + throwInternal("${annotationRef()} should have been checked present for command '$name'") + } + + if (metadataList.size == 1) { + val metadata = metadataList.single() + if (metadata.path.nameCount == 1) { + topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(findTopLevelAnnotation() ?: defaultTopLevelMetadata, metadata)) + } else { + val topLevelAnnotation = findTopLevelAnnotation() ?: throwMissingTopLevelAnnotation() + groupedBuilders.putIfAbsentOrThrowInternal(name, GroupedSlashCommandMetadata.Builder(name, topLevelAnnotation, metadata)) + } + } else if (metadataList.size >= 2) { + val topLevelAnnotation = findTopLevelAnnotation() ?: throwMissingTopLevelAnnotation() + val topLevelMetadata = findTopLevelMetadata() ?: throwMissingTopLevelAnnotation() + groupedBuilders.putIfAbsentOrThrowInternal(name, GroupedSlashCommandMetadata.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 = topLevelBuilders[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.Builder(metadata.path.group!!) } .subcommands @@ -204,26 +223,27 @@ internal class SlashCommandAutoBuilder( } // For each subcommand group, find the SlashCommandGroupData from its subcommands - topLevelBuilders.values.forEach { topLevelSlashCommandMetadata -> + groupedBuilders.values.forEach { topLevelSlashCommandMetadata -> topLevelSlashCommandMetadata.subcommandGroups.values.forEach { slashSubcommandGroupMetadata -> val groupSubcommands = slashSubcommandGroupMetadata.subcommands - 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 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() + + annotations.firstOrNull() ?: SlashCommandGroupData() + } slashSubcommandGroupMetadata.properties = SlashSubcommandGroupMetadata.Properties(annotation.description) } } - this.topLevelMetadata = topLevelBuilders.mapValues { (_, builder) -> builder.build() } + this.metadata = topLevelMetadata + groupedBuilders.mapValues { (_, builder) -> builder.build() } } override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declare(manager) @@ -232,7 +252,7 @@ internal class SlashCommandAutoBuilder( private fun declare(manager: AbstractApplicationCommandManager) { with(SkipLogger(logger)) { - topLevelMetadata + metadata .values .forEachWithDelayedExceptions loop@{ topLevelMetadata -> val metadata = topLevelMetadata.metadata @@ -250,62 +270,65 @@ internal class SlashCommandAutoBuilder( } context(_: SkipLogger) - private fun processCommand(manager: AbstractApplicationCommandManager, topLevelMetadata: TopLevelSlashCommandMetadata) { - val metadata = topLevelMetadata.metadata + private fun processCommand(manager: AbstractApplicationCommandManager, rootMetadata: SlashCommandMetadata) { + val metadata = rootMetadata.metadata val annotation = metadata.annotation val path = metadata.path val name = path.name - val filteredSubcommands = topLevelMetadata.subcommands.filter { subMetadata -> - checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) - } - // Filter subcommands from groups and remove groups with no subcommands - val filteredSubcommandGroups = topLevelMetadata.subcommandGroups.values - // Make a copy of subcommand groups but with subcommands filtered - .map { subGroupMetadata -> - subGroupMetadata.filterSubcommands { subMetadata -> - checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) - } - } - // Remove groups without subcommands - .filter { it.subcommands.isNotEmpty() } - - val isTopLevelOnly = topLevelMetadata.subcommands.isEmpty() && topLevelMetadata.subcommandGroups.isEmpty() - // If we don't have a top level command and no subcommands then abort - if (!isTopLevelOnly && filteredSubcommands.isEmpty() && filteredSubcommandGroups.isEmpty()) - return - - // The top level command may not be executable, but it may still be declared for its subcommands - val topLevelFunction = when { - // Top level is filtered but has subcommands - !checkDeclarationFilter(manager, metadata.func, path, metadata.commandId) -> null - // Has no top-level declaration but has subcommands - !isTopLevelOnly -> null - // Not filtered, has top-level declaration and possibly subcommands - else -> metadata.func.castFunction() - } - manager.slashCommand(name, topLevelFunction) { + + fun TopLevelSlashCommandBuilder.configureTopLevelCommons() { contexts = if (forceGuildCommands) { setOf(InteractionContextType.GUILD) } else { - topLevelMetadata.annotation.contexts.toEnumSetOr(manager.defaultContexts) + rootMetadata.annotation.contexts.toEnumSetOr(manager.defaultContexts) } - integrationTypes = topLevelMetadata.annotation.integrationTypes.toEnumSetOr(manager.defaultIntegrationTypes) - isDefaultLocked = topLevelMetadata.annotation.defaultLocked - nsfw = topLevelMetadata.annotation.nsfw + integrationTypes = rootMetadata.annotation.integrationTypes.toEnumSetOr(manager.defaultIntegrationTypes) + isDefaultLocked = rootMetadata.annotation.defaultLocked + nsfw = rootMetadata.annotation.nsfw // Prioritize [[TopLevelSlashCommandData]] as this is top level - description = topLevelMetadata.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() + description = rootMetadata.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() + } - addSubcommands(manager, filteredSubcommands) + if (rootMetadata is TopLevelSlashCommandMetadata) { + if (!checkDeclarationFilter(manager, metadata.func, path, metadata.commandId)) + return - addSubcommandGroups(manager, filteredSubcommandGroups) + manager.slashCommand(name, metadata.func.castFunction()) { + configureTopLevelCommons() - configureBuilder(metadata) + configureBuilder(metadata) - if (topLevelFunction != null) { processOptions((manager as? GuildApplicationCommandManager)?.guild, metadata) } + } else if (rootMetadata is GroupedSlashCommandMetadata) { + val filteredSubcommands = rootMetadata.subcommands.filter { subMetadata -> + checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } + // Filter subcommands from groups and remove groups with no subcommands + val filteredSubcommandGroups = rootMetadata.subcommandGroups.values + // Make a copy of subcommand groups but with subcommands filtered + .map { subGroupMetadata -> + subGroupMetadata.filterSubcommands { subMetadata -> + checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } + } + // 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) + } } } From 959f55cd50c13225e89c71457b05aa8eb7cd9a53 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:41:25 +0100 Subject: [PATCH 4/6] Add a few unit tests for annotated commands --- .../SlashCommandAutoBuilderTest.kt | 365 ++++++++++++++++++ 1 file changed, 365 insertions(+) create mode 100644 BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/commands/application/autobuilder/SlashCommandAutoBuilderTest.kt 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..fa973a812 --- /dev/null +++ b/BotCommands-core/src/test/kotlin/io/github/freya022/botcommands/commands/application/autobuilder/SlashCommandAutoBuilderTest.kt @@ -0,0 +1,365 @@ +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.commands.autobuilder.checkDeclarationFilter +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), + ) + } + + mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { + val autoBuilder = SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + val manager = mockk { + every { guild } returns mockk() + every { context } returns mockk() + } + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 1) { context(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), 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), + ) + } + + mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { + val autoBuilder = 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(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), 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 = SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + + val manager = mockk { + every { guild } returns mockk() + every { context } returns mockk() + } + + mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { + autoBuilder.declareGuildApplicationCommands(manager) + + verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + verify(exactly = 1) { context(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), 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) {} +} From abc217db4636e3ae1693bfc7c306e74af43495ea Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Sun, 28 Dec 2025 17:42:00 +0100 Subject: [PATCH 5/6] tests: Remove SlashDeclarationFilter --- .../commands/slash/SlashDeclarationFilter.kt | 53 ------------------- 1 file changed, 53 deletions(-) delete mode 100644 test-bot/src/test/kotlin/dev/freya02/botcommands/bot/commands/slash/SlashDeclarationFilter.kt 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() - } -} From b9f8ab74fbb9187a16475cfbefeed6ad2f8d1195 Mon Sep 17 00:00:00 2001 From: freya02 <41875020+freya022@users.noreply.github.com> Date: Wed, 31 Dec 2025 22:58:35 +0100 Subject: [PATCH 6/6] Move auto builder "utils" to respective auto builder abstractions Also refactor the top-level filtering --- .../ApplicationCommandAutoBuilder.kt | 139 ++++++++++++++ .../autobuilder/ContextCommandAutoBuilder.kt | 13 +- .../MessageContextCommandAutoBuilder.kt | 73 +++----- .../autobuilder/SlashCommandAutoBuilder.kt | 137 ++++---------- .../UserContextCommandAutoBuilder.kt | 71 +++----- .../MessageContextFunctionMetadata.kt | 10 +- .../RootAnnotatedApplicationCommand.kt | 10 ++ .../metadata/SlashCommandMetadata.kt | 59 ++++++ .../metadata/UserContextFunctionMetadata.kt | 10 +- .../commands/autobuilder/AutoBuilderUtils.kt | 170 +----------------- .../autobuilder/CommandAutoBuilder.kt | 72 +++++++- .../autobuilder/TextCommandAutoBuilder.kt | 7 +- .../SlashCommandAutoBuilderTest.kt | 47 +++-- 13 files changed, 412 insertions(+), 406 deletions(-) create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/ApplicationCommandAutoBuilder.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/RootAnnotatedApplicationCommand.kt create mode 100644 BotCommands-core/src/main/kotlin/io/github/freya022/botcommands/internal/commands/application/autobuilder/metadata/SlashCommandMetadata.kt 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 8f2e227a7..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 @@ -22,77 +23,29 @@ import io.github.freya022.botcommands.api.core.options.builder.inlineClassAggreg import io.github.freya022.botcommands.api.core.reflect.wrap import io.github.freya022.botcommands.api.core.service.ServiceContainer import io.github.freya022.botcommands.api.core.service.annotations.BService -import io.github.freya022.botcommands.api.core.utils.* +import io.github.freya022.botcommands.api.core.utils.findAnnotationRecursive +import io.github.freya022.botcommands.api.core.utils.hasAnnotationRecursive +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() -private sealed interface SlashCommandMetadata : MetadataFunctionHolder { - val annotation: TopLevelSlashCommandData - val metadata: SlashFunctionMetadata -} - -private class TopLevelSlashCommandMetadata( - override val annotation: TopLevelSlashCommandData, - override val metadata: SlashFunctionMetadata, -) : SlashCommandMetadata { - override val func: KFunction<*> get() = metadata.func -} - -private class GroupedSlashCommandMetadata private constructor( - override val annotation: TopLevelSlashCommandData, - override val metadata: SlashFunctionMetadata, - val subcommands: List, - val subcommandGroups: Map, -) : SlashCommandMetadata { - override val func: KFunction<*> get() = metadata.func - - class Builder( - val name: String, - val annotation: TopLevelSlashCommandData, - val metadata: SlashFunctionMetadata, - ) { - val subcommands: MutableList = arrayListOf() - val subcommandGroups: MutableMap = hashMapOf() - - fun build() = GroupedSlashCommandMetadata(annotation, metadata, subcommands.toImmutableList(), subcommandGroups.mapValues { (_, builder) -> builder.build() }) - } -} - -private class SlashSubcommandGroupMetadata private constructor(val name: String, val properties: Properties, val subcommands: List) { - inline fun filterSubcommands(block: (SlashFunctionMetadata) -> Boolean): SlashSubcommandGroupMetadata { - return SlashSubcommandGroupMetadata(name, properties, subcommands.filter(block)) - } - - class Properties(val description: String) - - class Builder(val name: String) { - lateinit var properties: Properties - - val subcommands: MutableList = arrayListOf() - - fun build() = SlashSubcommandGroupMetadata(name, properties, subcommands.toImmutableList()) - } -} - @BService @RequiresApplicationCommands internal class SlashCommandAutoBuilder( @@ -100,17 +53,18 @@ internal class SlashCommandAutoBuilder( applicationConfig: BApplicationConfig, private val resolverContainer: ResolverContainer, functionAnnotationsMap: FunctionAnnotationsMap -) : CommandAutoBuilder, GlobalApplicationCommandProvider, GuildApplicationCommandProvider { +) : ApplicationCommandAutoBuilder(applicationConfig) { override val optionAnnotation: KClass = SlashOption::class - - private val forceGuildCommands = applicationConfig.forceGuildCommands + override val commandType: JDACommand.Type get() = JDACommand.Type.SLASH private val metadata: Map + override val rootAnnotatedCommands: Collection + get() = metadata.values init { - val topLevelMetadata: MutableMap = hashMapOf() - val groupedBuilders: MutableMap = hashMapOf() + val topLevelMetadata: MutableMap = hashMapOf() + val groupedBuilders: MutableMap = hashMapOf() val functions: List = functionAnnotationsMap @@ -191,15 +145,15 @@ internal class SlashCommandAutoBuilder( if (metadataList.size == 1) { val metadata = metadataList.single() if (metadata.path.nameCount == 1) { - topLevelMetadata.putIfAbsentOrThrowInternal(name, TopLevelSlashCommandMetadata(findTopLevelAnnotation() ?: defaultTopLevelMetadata, metadata)) + topLevelMetadata.putIfAbsentOrThrowInternal(name, SlashCommandMetadata.TopLevel(findTopLevelAnnotation() ?: defaultTopLevelMetadata, metadata)) } else { val topLevelAnnotation = findTopLevelAnnotation() ?: throwMissingTopLevelAnnotation() - groupedBuilders.putIfAbsentOrThrowInternal(name, GroupedSlashCommandMetadata.Builder(name, topLevelAnnotation, metadata)) + 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, GroupedSlashCommandMetadata.Builder(name, topLevelAnnotation, topLevelMetadata)) + groupedBuilders.putIfAbsentOrThrowInternal(name, SlashCommandMetadata.Grouped.Builder(name, topLevelAnnotation, topLevelMetadata)) } else { throwInternal("No functions for '$name'") } @@ -216,7 +170,7 @@ internal class SlashCommandAutoBuilder( } else if (metadata.path.nameCount == 3) { builder .subcommandGroups - .getOrPut(metadata.path.group!!) { SlashSubcommandGroupMetadata.Builder(metadata.path.group!!) } + .getOrPut(metadata.path.group!!) { SlashCommandMetadata.Grouped.SubcommandGroup.Builder(metadata.path.group!!) } .subcommands .add(metadata) } @@ -239,39 +193,16 @@ internal class SlashCommandAutoBuilder( annotations.firstOrNull() ?: SlashCommandGroupData() } - slashSubcommandGroupMetadata.properties = SlashSubcommandGroupMetadata.Properties(annotation.description) + slashSubcommandGroupMetadata.properties = SlashCommandMetadata.Grouped.SubcommandGroup.Properties(annotation.description) } } this.metadata = topLevelMetadata + groupedBuilders.mapValues { (_, builder) -> builder.build() } } - override fun declareGlobalApplicationCommands(manager: GlobalApplicationCommandManager) = declare(manager) - - override fun declareGuildApplicationCommands(manager: GuildApplicationCommandManager) = declare(manager) - - private fun declare(manager: AbstractApplicationCommandManager) { - with(SkipLogger(logger)) { - metadata - .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) - } - } - context(_: SkipLogger) - private fun processCommand(manager: AbstractApplicationCommandManager, rootMetadata: SlashCommandMetadata) { - val metadata = rootMetadata.metadata + override fun declareTopLevel(manager: AbstractApplicationCommandManager, rootCommand: SlashCommandMetadata) { + val metadata = rootCommand.metadata val annotation = metadata.annotation val path = metadata.path @@ -281,18 +212,18 @@ internal class SlashCommandAutoBuilder( contexts = if (forceGuildCommands) { setOf(InteractionContextType.GUILD) } else { - rootMetadata.annotation.contexts.toEnumSetOr(manager.defaultContexts) + rootCommand.annotation.contexts.toEnumSetOr(manager.defaultContexts) } - integrationTypes = rootMetadata.annotation.integrationTypes.toEnumSetOr(manager.defaultIntegrationTypes) - isDefaultLocked = rootMetadata.annotation.defaultLocked - nsfw = rootMetadata.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 = rootMetadata.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() + description = rootCommand.annotation.description.nullIfBlank() ?: annotation.description.nullIfBlank() } - if (rootMetadata is TopLevelSlashCommandMetadata) { - if (!checkDeclarationFilter(manager, metadata.func, path, metadata.commandId)) + if (rootCommand is SlashCommandMetadata.TopLevel) { + if (!checkDeclarationFilter(manager, metadata)) return manager.slashCommand(name, metadata.func.castFunction()) { @@ -302,16 +233,16 @@ internal class SlashCommandAutoBuilder( processOptions((manager as? GuildApplicationCommandManager)?.guild, metadata) } - } else if (rootMetadata is GroupedSlashCommandMetadata) { - val filteredSubcommands = rootMetadata.subcommands.filter { subMetadata -> - checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + } 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 = rootMetadata.subcommandGroups.values + val filteredSubcommandGroups = rootCommand.subcommandGroups.values // Make a copy of subcommand groups but with subcommands filtered .map { subGroupMetadata -> subGroupMetadata.filterSubcommands { subMetadata -> - checkDeclarationFilter(manager, subMetadata.func, subMetadata.path, subMetadata.commandId) + checkDeclarationFilter(manager, subMetadata) } } // Remove groups without subcommands @@ -335,7 +266,7 @@ internal class SlashCommandAutoBuilder( context(_: SkipLogger) private fun TopLevelSlashCommandBuilder.addSubcommandGroups( manager: AbstractApplicationCommandManager, - subcommandGroupsMetadata: Collection, + subcommandGroupsMetadata: Collection, ) { subcommandGroupsMetadata.forEach { groupMetadata -> subcommandGroup(groupMetadata.name) { 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 index fa973a812..5e755b71e 100644 --- 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 @@ -18,7 +18,6 @@ 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.commands.autobuilder.checkDeclarationFilter 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 @@ -258,17 +257,15 @@ class SlashCommandAutoBuilderTest { ) } - mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { - val autoBuilder = SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) - val manager = mockk { - every { guild } returns mockk() - every { context } returns mockk() - } - autoBuilder.declareGuildApplicationCommands(manager) - - verify(exactly = 1) { context(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), any()) } } - verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + 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 @@ -280,17 +277,15 @@ class SlashCommandAutoBuilderTest { ) } - mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { - val autoBuilder = 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(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), any()) } } + 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 @@ -302,19 +297,17 @@ class SlashCommandAutoBuilderTest { ) } - val autoBuilder = SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap) + val autoBuilder = spyk(SlashCommandAutoBuilder(serviceContainer, applicationConfig, resolverContainer, functionAnnotationsMap)) val manager = mockk { every { guild } returns mockk() every { context } returns mockk() } - mockkStatic("io.github.freya022.botcommands.internal.commands.autobuilder.AutoBuilderUtilsKt") { - autoBuilder.declareGuildApplicationCommands(manager) + autoBuilder.declareGuildApplicationCommands(manager) - verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } - verify(exactly = 1) { context(autoBuilder, any()) { checkDeclarationFilter(manager, any(), any(), any()) } } - } + verify(exactly = 0) { manager.slashCommand(any(), any(), any()) } + verify(exactly = 1) { context(any()) { autoBuilder.checkDeclarationFilter(manager, any()) } } } @Test