From 87dde2651b1ac9d8921a0ac320e1ed54a89759ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20S=C3=B6derberg?= Date: Wed, 17 Jan 2024 14:55:37 +0100 Subject: [PATCH] feat: add kord implementation --- .gitignore | 2 +- README.md | 1 + cloud-discord-common/build.gradle.kts | 1 + .../cloud/discord/slash/CommandScope.java | 2 +- .../discord/slash}/CommandScopePredicate.java | 3 +- .../cloud/discord/slash/DiscordChoices.java | 13 +- .../discord/slash/DiscordPermission.java | 30 ++- .../slash/StandardDiscordCommandFactory.java | 9 +- .../slash}/annotations/CommandScope.java | 2 +- .../CommandScopeBuilderModifier.java | 2 +- .../slash/annotations/package-info.java | 4 + .../cloud/discord/jda5/JDACommandFactory.java | 1 + .../jda5/StandardJDACommandFactory.java | 3 + cloud-kord/README.md | 6 + cloud-kord/build.gradle.kts | 16 ++ .../cloud/discord/kord/CommandScopeExt.kt | 34 +++ .../cloud/discord/kord/KordCommandFactory.kt | 64 +++++ .../cloud/discord/kord/KordCommandManager.kt | 165 ++++++++++++ .../cloud/discord/kord/KordEventListener.kt | 199 ++++++++++++++ .../cloud/discord/kord/KordInteraction.kt | 87 +++++++ .../cloud/discord/kord/KordOptionType.kt | 70 +++++ .../incendo/cloud/discord/kord/KordParser.kt | 134 ++++++++++ .../incendo/cloud/discord/kord/KordSetting.kt | 48 ++++ .../cloud/discord/kord/PermissionsExt.kt | 63 +++++ .../kord/StandardKordCommandFactory.kt | 246 ++++++++++++++++++ .../example/commands/AggregateCommand.java | 2 +- .../example/commands/AnnotatedCommands.java | 2 +- .../jda5/example/commands/PingCommand.java | 2 + examples/example-kord/README.md | 12 + examples/example-kord/build.gradle.kts | 26 ++ .../discord/kord/example/BotConfiguration.kt | 49 ++++ .../cloud/discord/kord/example/Example.kt | 38 +++ .../cloud/discord/kord/example/ExampleBot.kt | 65 +++++ .../cloud/discord/kord/example/Examples.kt | 58 +++++ .../kord/example/commands/AggregateCommand.kt | 92 +++++++ .../example/commands/AnnotatedCommands.kt | 135 ++++++++++ .../kord/example/commands/PingCommand.kt | 72 +++++ .../src/main/resources/logback.xml | 10 + gradle/build-logic/build.gradle.kts | 1 + ...loud-discord.kotlin-conventions.gradle.kts | 33 +++ gradle/libs.versions.toml | 18 ++ settings.gradle.kts | 3 + 42 files changed, 1805 insertions(+), 18 deletions(-) rename {cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5 => cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash}/CommandScopePredicate.java (96%) rename {cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5 => cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash}/annotations/CommandScope.java (97%) rename {cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5 => cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash}/annotations/CommandScopeBuilderModifier.java (97%) create mode 100644 cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/package-info.java create mode 100644 cloud-kord/README.md create mode 100644 cloud-kord/build.gradle.kts create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/CommandScopeExt.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandFactory.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandManager.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordEventListener.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordInteraction.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordOptionType.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordParser.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordSetting.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/PermissionsExt.kt create mode 100644 cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/StandardKordCommandFactory.kt create mode 100644 examples/example-kord/README.md create mode 100644 examples/example-kord/build.gradle.kts create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/BotConfiguration.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Example.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/ExampleBot.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Examples.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AggregateCommand.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AnnotatedCommands.kt create mode 100644 examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/PingCommand.kt create mode 100644 examples/example-kord/src/main/resources/logback.xml create mode 100644 gradle/build-logic/src/main/kotlin/cloud-discord.kotlin-conventions.gradle.kts diff --git a/.gitignore b/.gitignore index cec0a44..1c20f2b 100644 --- a/.gitignore +++ b/.gitignore @@ -190,4 +190,4 @@ gradle-app.setting # End of https://www.toptal.com/developers/gitignore/api/intellij+all,java,maven,gradle,kotlin,git # Example files -examples/example-jda5/bot.properties +examples/example-*/bot.properties diff --git a/README.md b/README.md index 9734116..4f9daa1 100644 --- a/README.md +++ b/README.md @@ -14,3 +14,4 @@ Discord integrations for [Cloud v2](https://github.com/incendo/cloud). - cloud-jda: integration for JDA4 - cloud-jda5: integration for JDA5 slash commands - cloud-javacord: integration for javacord +- cloud-kord: integration for kord diff --git a/cloud-discord-common/build.gradle.kts b/cloud-discord-common/build.gradle.kts index 614af4a..883b6a4 100644 --- a/cloud-discord-common/build.gradle.kts +++ b/cloud-discord-common/build.gradle.kts @@ -5,4 +5,5 @@ plugins { dependencies { api(libs.cloud.core) + implementation(libs.cloud.annotations) } diff --git a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScope.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScope.java index 1c70553..020ef2b 100644 --- a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScope.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScope.java @@ -104,7 +104,7 @@ private Global() { @Override public boolean overlaps(final @NonNull CommandScope scope) { - return true; + return scope instanceof Global; } } diff --git a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/CommandScopePredicate.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScopePredicate.java similarity index 96% rename from cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/CommandScopePredicate.java rename to cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScopePredicate.java index 2966c05..19f2f55 100644 --- a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/CommandScopePredicate.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/CommandScopePredicate.java @@ -21,13 +21,12 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // -package org.incendo.cloud.discord.jda5; +package org.incendo.cloud.discord.slash; import cloud.commandframework.internal.CommandNode; import java.util.function.BiPredicate; import org.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; -import org.incendo.cloud.discord.slash.CommandScope; /** * Predicate that determines whether a command scope should receive a certain command. diff --git a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordChoices.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordChoices.java index bd4bce1..2a95d96 100644 --- a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordChoices.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordChoices.java @@ -26,6 +26,7 @@ import java.util.Arrays; import java.util.Collection; import java.util.stream.Collectors; +import java.util.stream.StreamSupport; import org.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; import org.immutables.value.Value; @@ -69,9 +70,9 @@ public interface DiscordChoices extends DiscordChoiceProvider { * @param choices choices * @return the created instance */ - static @NonNull DiscordChoices integers(final @NonNull Collection<@NonNull Integer> choices) { + static @NonNull DiscordChoices integers(final @NonNull Iterable<@NonNull Integer> choices) { return DiscordChoicesImpl.of( - choices.stream() + StreamSupport.stream(choices.spliterator(), false) .map(integer -> DiscordOptionChoice.of(Integer.toString(integer), integer)) .collect(Collectors.toList()) ); @@ -99,9 +100,9 @@ public interface DiscordChoices extends DiscordChoiceProvider { * @param choices choices * @return the created instance */ - static @NonNull DiscordChoices doubles(final @NonNull Collection<@NonNull Double> choices) { + static @NonNull DiscordChoices doubles(final @NonNull Iterable<@NonNull Double> choices) { return DiscordChoicesImpl.of( - choices.stream() + StreamSupport.stream(choices.spliterator(), false) .map(number -> DiscordOptionChoice.of(Double.toString(number), number)) .collect(Collectors.toList()) ); @@ -129,9 +130,9 @@ public interface DiscordChoices extends DiscordChoiceProvider { * @param choices choices * @return the created instance */ - static @NonNull DiscordChoices strings(final @NonNull Collection<@NonNull String> choices) { + static @NonNull DiscordChoices strings(final @NonNull Iterable<@NonNull String> choices) { return DiscordChoicesImpl.of( - choices.stream() + StreamSupport.stream(choices.spliterator(), false) .map(string -> DiscordOptionChoice.of(string, string)) .collect(Collectors.toList()) ); diff --git a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordPermission.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordPermission.java index 541084e..76736a6 100644 --- a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordPermission.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/DiscordPermission.java @@ -50,15 +50,39 @@ public final class DiscordPermission implements Permission { return new DiscordPermission(permission); } - private final long permission; + /** + * Creates a new Discord permission. + * + * @param permission permission + * @return the created permission + */ + public static @NonNull DiscordPermission of(final @NonNull String permission) { + return new DiscordPermission(permission); + } + + /** + * Creates a new Discord permission. + * + * @param permission permission + * @return the created permission + */ + public static @NonNull DiscordPermission discordPermission(final @NonNull String permission) { + return new DiscordPermission(permission); + } + + private final String permission; private DiscordPermission(final long permission) { + this.permission = Long.toString(permission); + } + + private DiscordPermission(final @NonNull String permission) { this.permission = permission; } @Override public @NonNull String permissionString() { - return Long.toString(this.permission); + return this.permission; } /** @@ -67,6 +91,6 @@ private DiscordPermission(final long permission) { * @return permission */ public long permission() { - return this.permission; + return Long.parseUnsignedLong(this.permission); } } diff --git a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/StandardDiscordCommandFactory.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/StandardDiscordCommandFactory.java index d3583c8..7a97580 100644 --- a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/StandardDiscordCommandFactory.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/StandardDiscordCommandFactory.java @@ -232,8 +232,15 @@ public > void registerRangeMapp autoComplete = false; } + final String innerDescription; + if (innerComponent.description().isEmpty()) { + innerDescription = innerComponent.name(); + } else { + innerDescription = innerComponent.description().textDescription(); + } + return (DiscordOption) ImmutableVariable.builder().name(innerComponent.name()) - .description(description) + .description(innerDescription) .type(optionType) .required(innerComponent.required()) .autocomplete(autoComplete) diff --git a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScope.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScope.java similarity index 97% rename from cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScope.java rename to cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScope.java index bfc8080..5f675fd 100644 --- a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScope.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScope.java @@ -21,7 +21,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // -package org.incendo.cloud.discord.jda5.annotations; +package org.incendo.cloud.discord.slash.annotations; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; diff --git a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScopeBuilderModifier.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScopeBuilderModifier.java similarity index 97% rename from cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScopeBuilderModifier.java rename to cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScopeBuilderModifier.java index 320db75..fc3df17 100644 --- a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/annotations/CommandScopeBuilderModifier.java +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/CommandScopeBuilderModifier.java @@ -21,7 +21,7 @@ // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. // -package org.incendo.cloud.discord.jda5.annotations; +package org.incendo.cloud.discord.slash.annotations; import cloud.commandframework.Command; import cloud.commandframework.annotations.AnnotationParser; diff --git a/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/package-info.java b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/package-info.java new file mode 100644 index 0000000..7db8c0d --- /dev/null +++ b/cloud-discord-common/src/main/java/org/incendo/cloud/discord/slash/annotations/package-info.java @@ -0,0 +1,4 @@ +/** + * Utilities for using cloud-discord together with cloud-annotations. + */ +package org.incendo.cloud.discord.slash.annotations; diff --git a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/JDACommandFactory.java b/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/JDACommandFactory.java index eda2c4d..ffe6226 100644 --- a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/JDACommandFactory.java +++ b/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/JDACommandFactory.java @@ -28,6 +28,7 @@ import org.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; import org.incendo.cloud.discord.slash.CommandScope; +import org.incendo.cloud.discord.slash.CommandScopePredicate; @API(status = API.Status.STABLE, since = "1.0.0") public interface JDACommandFactory { diff --git a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/StandardJDACommandFactory.java b/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/StandardJDACommandFactory.java index 1d58d2f..3ebe3cd 100644 --- a/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/StandardJDACommandFactory.java +++ b/cloud-jda5/src/main/java/org/incendo/cloud/discord/jda5/StandardJDACommandFactory.java @@ -43,6 +43,7 @@ import org.apiguardian.api.API; import org.checkerframework.checker.nullness.qual.NonNull; import org.incendo.cloud.discord.slash.CommandScope; +import org.incendo.cloud.discord.slash.CommandScopePredicate; import org.incendo.cloud.discord.slash.DiscordCommand; import org.incendo.cloud.discord.slash.DiscordCommandFactory; import org.incendo.cloud.discord.slash.DiscordOption; @@ -130,6 +131,8 @@ public void commandScopePredicate(final @NonNull CommandScopePredicate predic data.setDefaultPermissions(DefaultMemberPermissions.enabledFor(((DiscordPermission) permission).permission())); } + data.setGuildOnly(rootScope instanceof CommandScope.Guilds); + commands.add(data); } return commands; diff --git a/cloud-kord/README.md b/cloud-kord/README.md new file mode 100644 index 0000000..2811fd0 --- /dev/null +++ b/cloud-kord/README.md @@ -0,0 +1,6 @@ +# cloud-kord + +> [!NOTE] +> This implementation requires Java 17+. + +Cloud integration for Kord slash commands. diff --git a/cloud-kord/build.gradle.kts b/cloud-kord/build.gradle.kts new file mode 100644 index 0000000..67b6123 --- /dev/null +++ b/cloud-kord/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("cloud-discord.kotlin-conventions") + id("cloud-discord.publishing-conventions") +} + +dependencies { + api(projects.cloudDiscordCommon) + api(libs.cloud.kotlin.coroutines) + api(libs.cloud.kotlin.extensions) + api(libs.bundles.coroutines) + + implementation(libs.cloud.annotations) + implementation(libs.kord) + + testImplementation(libs.mockito.kotlin) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/CommandScopeExt.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/CommandScopeExt.kt new file mode 100644 index 0000000..265bbbd --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/CommandScopeExt.kt @@ -0,0 +1,34 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.kotlin.MutableCommandBuilder +import org.incendo.cloud.discord.slash.CommandScope + +/** + * Sets the command scope of the command builder. + */ +public fun MutableCommandBuilder.commandScope(commandScope: CommandScope) { + meta(CommandScope.META_COMMAND_SCOPE, commandScope) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandFactory.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandFactory.kt new file mode 100644 index 0000000..4359727 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandFactory.kt @@ -0,0 +1,64 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import dev.kord.core.Kord +import dev.kord.core.entity.Guild +import org.apiguardian.api.API +import org.incendo.cloud.discord.slash.CommandScopePredicate + +/** + * Factory that creates commands. + * + * @param C command sender type + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public interface KordCommandFactory { + + /** + * Predicate that determines whether a command is applicable in a given scope. + */ + public var commandScopePredicate: CommandScopePredicate + + /** + * Creates the commands for the given [guild]. + */ + public suspend fun createGuildCommands(guild: Guild) + + /** + * Deletes the commands from the given [guild]. + */ + public suspend fun deleteGuildCommands(guild: Guild) + + /** + * Creates global commands using the given [kord] instance. + */ + public suspend fun createGlobalCommands(kord: Kord) + + /** + * Deletes global commands from the given [kord] instance. + */ + public suspend fun deleteGlobalCommands(kord: Kord) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandManager.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandManager.kt new file mode 100644 index 0000000..58d08b6 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordCommandManager.kt @@ -0,0 +1,165 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.CommandManager +import cloud.commandframework.exceptions.CommandExecutionException +import cloud.commandframework.exceptions.InvalidCommandSenderException +import cloud.commandframework.exceptions.InvalidSyntaxException +import cloud.commandframework.exceptions.NoPermissionException +import cloud.commandframework.exceptions.NoSuchCommandException +import cloud.commandframework.exceptions.handling.ExceptionContext +import cloud.commandframework.exceptions.handling.ExceptionController +import cloud.commandframework.execution.ExecutionCoordinator +import cloud.commandframework.internal.CommandRegistrationHandler +import cloud.commandframework.keys.CloudKey +import cloud.commandframework.setting.Configurable +import dev.kord.core.Kord +import dev.kord.core.entity.Member +import dev.kord.core.entity.User +import dev.kord.core.entity.interaction.GuildInteraction +import kotlinx.coroutines.runBlocking +import org.apiguardian.api.API + +/** + * Command manager for Kord. + * + * @param C command sender type + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public class KordCommandManager( + executionCoordinator: ExecutionCoordinator, + public val senderMapper: (KordInteraction) -> C +) : CommandManager( + executionCoordinator, + CommandRegistrationHandler.nullCommandRegistrationHandler() +) { + + public companion object { + /** + * Stores the interaction. This should be accessed using [cloud.commandframework.context.CommandContext.interaction]. + */ + public val CONTEXT_INTERACTION: CloudKey = CloudKey.of( + "cloud:interaction", + KordInteraction::class.java + ) + } + + /** + * Kord-specific settings. + */ + public val kordSettings: Configurable = Configurable.enumConfigurable(KordSetting::class.java) + + /** + * Factory that creates Kord commands from Cloud commands. + */ + public var commandFactory: KordCommandFactory = StandardKordCommandFactory(this.commandTree()) + + /** + * Predicate used to evaluate sender permissions. + */ + public var permissionPredicate: (C, String) -> Boolean = { _, _ -> true } + + init { + kordSettings.set(KordSetting.AUTO_REGISTER_GLOBAL, true) + kordSettings.set(KordSetting.AUTO_REGISTER_GUILD, true) + kordSettings.set(KordSetting.CLEAR_EXISTING, true) + + parserRegistry() + .registerParser(KordParser.userParser()) + .registerParser(KordParser.roleParser()) + .registerParser(KordParser.channelParser()) + .registerParser(KordParser.mentionableParser()) + .registerParser(KordParser.attachmentParser()) + + // Common parameter injections. + parameterInjectorRegistry().registerInjector(KordInteraction::class.java) { ctx, _ -> + ctx.interaction + } + parameterInjectorRegistry().registerInjector(User::class.java) { ctx, _ -> + ctx.interaction.interactionEvent.interaction.user + } + parameterInjectorRegistry().registerInjector(Member::class.java) { ctx, _ -> + (ctx.interaction.interactionEvent.interaction as GuildInteraction).user + } + parameterInjectorRegistry().registerInjector(Kord::class.java) { ctx, _ -> + ctx.interaction.interactionEvent.kord + } + + registerDefaultExceptionHandlers() + } + + /** + * Installs the event listener that handles command registration, execution and autocompletion. + */ + public fun installListener(kord: Kord) { + KordEventListener(this).registerEvents(kord) + } + + override fun hasPermission(sender: C, permission: String): Boolean = permissionPredicate(sender, permission) + + private fun registerDefaultExceptionHandlers() { + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = it.exception().message + } + } + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = "Invalid Command Argument: ${it.exception().cause?.message}" + } + } + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = "Unknown command" + } + } + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = "Insufficient permissions" + } + } + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = it.exception().message + } + } + exceptionController().registerSuspending { + it.context().interaction.respondEphemeral { + content = "Invalid Command Syntax. Correct command syntax is: /${it.exception().correctSyntax()}" + } + } + } + + private inline fun ExceptionController.registerSuspending( + crossinline handler: suspend (ExceptionContext) -> Unit + ) { + exceptionController().registerHandler(T::class.java) { exceptionContext -> + runBlocking { + handler(exceptionContext) + } + } + } +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordEventListener.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordEventListener.kt new file mode 100644 index 0000000..8441a64 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordEventListener.kt @@ -0,0 +1,199 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.context.CommandContextFactory +import cloud.commandframework.context.StandardCommandContextFactory +import cloud.commandframework.util.StringUtils +import dev.kord.common.entity.Snowflake +import dev.kord.core.Kord +import dev.kord.core.behavior.interaction.suggestInteger +import dev.kord.core.behavior.interaction.suggestNumber +import dev.kord.core.behavior.interaction.suggestString +import dev.kord.core.entity.interaction.GroupCommand +import dev.kord.core.entity.interaction.IntegerOptionValue +import dev.kord.core.entity.interaction.InteractionCommand +import dev.kord.core.entity.interaction.NumberOptionValue +import dev.kord.core.entity.interaction.OptionValue +import dev.kord.core.entity.interaction.SubCommand +import dev.kord.core.event.gateway.ReadyEvent +import dev.kord.core.event.guild.GuildCreateEvent +import dev.kord.core.event.interaction.AutoCompleteInteractionCreateEvent +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.on +import kotlinx.coroutines.future.await +import org.apiguardian.api.API + +/** + * Kord event listener which handles command registration, execution and autocompletion. + * + * @param C command sender type + * @since 1.0.0 + */ +@API(status = API.Status.INTERNAL, since = "1.0.0") +internal class KordEventListener(private val commandManager: KordCommandManager) { + + private val contextFactory: CommandContextFactory = StandardCommandContextFactory(commandManager) + + internal fun registerEvents(kord: Kord) { + kord.on { + listen() + } + kord.on { + listen() + } + kord.on { + listen() + } + kord.on { + listen() + } + } + + private suspend fun ReadyEvent.listen() { + if (commandManager.kordSettings[KordSetting.CLEAR_EXISTING]) { + commandManager.commandFactory.deleteGlobalCommands(kord) + } + if (commandManager.kordSettings[KordSetting.AUTO_REGISTER_GLOBAL]) { + commandManager.commandFactory.createGlobalCommands(kord) + } + } + + private suspend fun GuildCreateEvent.listen() { + if (commandManager.kordSettings[KordSetting.CLEAR_EXISTING]) { + commandManager.commandFactory.deleteGuildCommands(guild) + } + if (commandManager.kordSettings[KordSetting.AUTO_REGISTER_GUILD]) { + commandManager.commandFactory.createGuildCommands(guild) + } + } + + private suspend fun ChatInputCommandInteractionCreateEvent.listen() { + val command = interaction.command + val fullCommand = command.buildCommand() + + val kordInteraction = KordInteraction(command, this) + + try { + commandManager.commandExecutor().executeCommand( + commandManager.senderMapper(kordInteraction), + fullCommand, + ) { context -> context[KordCommandManager.CONTEXT_INTERACTION] = kordInteraction }.await() + } catch (_: Exception) { + // Exceptions are handled by the exception controller. + } + } + + private suspend fun AutoCompleteInteractionCreateEvent.listen() { + val command = interaction.command + + var fullCommand = command.buildCommand() + if (this.interaction.focusedOption.value.isEmpty()) { + fullCommand = "$fullCommand " + } + + val kordInteraction = KordInteraction(command, this) + + val commandContext = contextFactory.create( + true, + commandManager.senderMapper(kordInteraction) + ) + commandContext[KordCommandManager.CONTEXT_INTERACTION] = kordInteraction + + val type = command.options.values.first(OptionValue<*>::focused) + + val suggestions = commandManager.suggestionFactory().suggest(commandContext, fullCommand).await().let { suggestions -> + suggestions.list() + .asSequence() + .map { suggestion -> + if (" " in suggestion.suggestion()) { + suggestion.withSuggestion( + StringUtils.trimBeforeLastSpace( + suggestion.suggestion(), + suggestions.commandInput() + )!! + ) + } else { + suggestion + } + }.filterNot { it.suggestion().isEmpty() } + } + + when (type) { + is IntegerOptionValue -> { + interaction.suggestInteger { + suggestions.forEach { + choice(it.suggestion(), it.suggestion().toLong()) { + } + } + } + } + is NumberOptionValue -> { + interaction.suggestNumber { + suggestions.forEach { + choice(it.suggestion(), it.suggestion().toDouble()) { + } + } + } + } + else -> { + interaction.suggestString { + suggestions.forEach { + choice(it.suggestion(), it.suggestion()) { + } + } + } + } + } + } + + private fun InteractionCommand.buildCommand(): String = buildString { + append(rootName) + when (this@buildCommand) { + is GroupCommand -> { + append(" ").append(groupName).append(" ").append(name) + } + is SubCommand -> { + append(" ").append(name) + } + else -> {} + } + + options.forEach { (name, value) -> + append(" ") + when (value.value) { + is Snowflake -> { + append(name) + } + else -> { + append(value.value) + } + } + } + + if (toString().endsWith(" ")) { + return substring(0, length - 1) + } + } +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordInteraction.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordInteraction.kt new file mode 100644 index 0000000..5cf0ec1 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordInteraction.kt @@ -0,0 +1,87 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.context.CommandContext +import dev.kord.core.behavior.interaction.respondEphemeral +import dev.kord.core.behavior.interaction.respondPublic +import dev.kord.core.behavior.interaction.response.DeferredEphemeralMessageInteractionResponseBehavior +import dev.kord.core.behavior.interaction.response.DeferredPublicMessageInteractionResponseBehavior +import dev.kord.core.entity.interaction.InteractionCommand +import dev.kord.core.event.interaction.AutoCompleteInteractionCreateEvent +import dev.kord.core.event.interaction.ChatInputCommandInteractionCreateEvent +import dev.kord.core.event.interaction.InteractionCreateEvent +import dev.kord.rest.builder.message.create.InteractionResponseCreateBuilder +import org.apiguardian.api.API + +@API(status = API.Status.STABLE, since = "1.0.0") +public data class KordInteraction( + public val command: InteractionCommand, + public val interactionEvent: InteractionCreateEvent +) { + + /** + * Returns the command event, if this interaction is part of a command execution. + */ + public val commandEvent: ChatInputCommandInteractionCreateEvent? + get() = interactionEvent as? ChatInputCommandInteractionCreateEvent + + /** + * Returns the suggestion event, if this interaction is part of a command completion. + */ + public val suggestionEvent: AutoCompleteInteractionCreateEvent? + get() = interactionEvent as? AutoCompleteInteractionCreateEvent + + /** + * Responds to the interaction with an ephemeral message. + */ + public suspend inline fun respondEphemeral(builder: InteractionResponseCreateBuilder.() -> Unit) { + requireNotNull(commandEvent).interaction.respondEphemeral(builder) + } + + /** + * Responds to the interaction with a public message. + */ + public suspend inline fun respondPublic(builder: InteractionResponseCreateBuilder.() -> Unit) { + requireNotNull(commandEvent).interaction.respondPublic(builder) + } + + /** + * Responds to the interaction with an ephemeral message. + */ + public suspend inline fun deferEphemeralResponse(): DeferredEphemeralMessageInteractionResponseBehavior = + requireNotNull(commandEvent).interaction.deferEphemeralResponse() + + /** + * Responds to the interaction with a public message. + */ + public suspend inline fun deferPublicResponse(): DeferredPublicMessageInteractionResponseBehavior = + requireNotNull(commandEvent).interaction.deferPublicResponse() +} + +/** + * Returns the [KordInteraction] for this context. + */ +public val CommandContext<*>.interaction: KordInteraction + get() = get(KordCommandManager.CONTEXT_INTERACTION) diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordOptionType.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordOptionType.kt new file mode 100644 index 0000000..844b684 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordOptionType.kt @@ -0,0 +1,70 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import dev.kord.core.entity.Attachment +import dev.kord.core.entity.Entity +import dev.kord.core.entity.Role +import dev.kord.core.entity.User +import dev.kord.core.entity.channel.Channel +import io.leangen.geantyref.TypeToken +import org.apiguardian.api.API +import org.incendo.cloud.discord.slash.DiscordOptionType + +/** + * Extension of [DiscordOptionType] for Kotlin-specific classes. + * + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public object KordOptionType { + + public val USER: DiscordOptionType = DiscordOptionType.of( + "USER", + 6, + TypeToken.get(User::class.java) + ) + public val CHANNEL: DiscordOptionType = DiscordOptionType.of( + "CHANNEL", + 7, + TypeToken.get(Channel::class.java) + ) + public val ROLE: DiscordOptionType = DiscordOptionType.of( + "ROLE", + 8, + TypeToken.get(Role::class.java) + ) + public val MENTIONABLE: DiscordOptionType = DiscordOptionType.of( + "MENTIONABLE", + 9, + TypeToken.get(Entity::class.java) + ) + public val ATTACHMENT: DiscordOptionType = DiscordOptionType.of( + "ATTACHMENT", + 11, + TypeToken.get(Attachment::class.java) + ) + + public val KORD_TYPES: Collection> = setOf(USER, CHANNEL, ROLE, MENTIONABLE, ATTACHMENT) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordParser.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordParser.kt new file mode 100644 index 0000000..23ac15b --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordParser.kt @@ -0,0 +1,134 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.arguments.parser.ArgumentParseResult +import cloud.commandframework.arguments.parser.ParserDescriptor +import cloud.commandframework.context.CommandContext +import cloud.commandframework.context.CommandInput +import cloud.commandframework.kotlin.coroutines.SuspendingArgumentParser +import cloud.commandframework.kotlin.coroutines.asParserDescriptor +import dev.kord.core.entity.Attachment +import dev.kord.core.entity.Entity +import dev.kord.core.entity.Role +import dev.kord.core.entity.User +import dev.kord.core.entity.channel.Channel +import dev.kord.core.entity.interaction.GuildInteraction +import dev.kord.core.entity.interaction.InteractionCommand +import kotlinx.coroutines.flow.firstOrNull +import org.apiguardian.api.API +import kotlin.NullPointerException + +/** + * A parser which wraps a Kord option value. + * + * @param C command sender type + * @param T kord type + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public fun interface KordParser : SuspendingArgumentParser { + + public companion object { + + /** + * Returns a parser which extracts a [User]. + */ + public fun userParser(): ParserDescriptor = createParser { name, command, context -> + command.users[name]?.let { ArgumentParseResult.success(it) } + ?: context.ifSuggestion(name) { + ArgumentParseResult.success(context.interaction.interactionEvent.interaction.user) + } + } + + /** + * Returns a parser which extracts a [Channel]. + */ + public fun channelParser(): ParserDescriptor = createParser { name, command, context -> + command.channels[name]?.let { ArgumentParseResult.success(it) } + ?: context.ifSuggestion(name) { + (context.interaction.interactionEvent as? GuildInteraction)?.channel?.asChannel() + ?.let { ArgumentParseResult.success(it) } + } + } + + /** + * Returns a parser which extracts a [Role]. + */ + public fun roleParser(): ParserDescriptor = createParser { name, command, context -> + command.roles[name]?.let { ArgumentParseResult.success(it) } + ?: context.ifSuggestion(name) { + (context.interaction.interactionEvent as? GuildInteraction)?.user?.roles?.firstOrNull() + ?.let { ArgumentParseResult.success(it) } + } + } + + /** + * Returns a parser which extracts an [Entity]. + */ + public fun mentionableParser(): ParserDescriptor = + createParser { name, command, context -> + command.users[name]?.let { ArgumentParseResult.success(it) } + ?: context.ifSuggestion(name) { + ArgumentParseResult.success(context.interaction.interactionEvent.interaction.user) + } + } + + /** + * Returns a parser which extracts an [Attachment]. + */ + public fun attachmentParser(): ParserDescriptor = + createParser { name, command, _ -> + command.attachments[name]?.let { ArgumentParseResult.success(it) } + ?: ArgumentParseResult.failure(NullPointerException(name)) + } + + private inline fun createParser(parser: KordParser): ParserDescriptor = + parser.asParserDescriptor() + + // TODO(City): This is a terrible hack and should be removed. + private suspend inline fun CommandContext.ifSuggestion( + name: String, + crossinline body: suspend () -> ArgumentParseResult? + ): ArgumentParseResult { + val result = if (isSuggestions) { + body() + } else { + null + } + return result ?: ArgumentParseResult.failure(NullPointerException(name)) + } + } + + /** + * Returns the result of extracting the argument from the given mapping. + */ + public suspend fun extract(name: String, command: InteractionCommand, context: CommandContext): ArgumentParseResult + + override suspend fun invoke(commandContext: CommandContext, commandInput: CommandInput): ArgumentParseResult = extract( + commandInput.readString(), + commandContext.interaction.command, + commandContext + ) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordSetting.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordSetting.kt new file mode 100644 index 0000000..fae8f0e --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/KordSetting.kt @@ -0,0 +1,48 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.setting.Setting +import org.apiguardian.api.API + +/** + * Kord-specific settings. + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public enum class KordSetting : Setting { + /** + * Whether commands should be automatically registered per-guild. Defaults to `true`. + */ + AUTO_REGISTER_GUILD, + + /** + * Whether commands should be automatically registered globally. Defaults to `true`. + */ + AUTO_REGISTER_GLOBAL, + + /** + * Whether existing commands should be cleared. Defaults to `true`. + */ + CLEAR_EXISTING +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/PermissionsExt.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/PermissionsExt.kt new file mode 100644 index 0000000..a329ac6 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/PermissionsExt.kt @@ -0,0 +1,63 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.Command +import cloud.commandframework.kotlin.MutableCommandBuilder +import cloud.commandframework.permission.Permission +import dev.kord.common.entity.Permissions +import org.incendo.cloud.discord.slash.DiscordPermission + +/** + * Returns the Cloud equivalent of this instance. + */ +public fun Permissions.asCloudPermission(): Permission = DiscordPermission.discordPermission(this.code.value) + +/** + * Sets the command [permissions]. + */ +public fun MutableCommandBuilder.permissions(permissions: Permissions) { + permission(permissions.asCloudPermission()) +} + +/** + * Sets the command [permissions]. + */ +public fun MutableCommandBuilder.permissions(vararg permission: dev.kord.common.entity.Permission) { + permissions(Permissions(permission.asList())) +} + +/** + * Sets the command [permissions]. + */ +public fun Command.Builder.permissions(permissions: Permissions) { + permission(permissions.asCloudPermission()) +} + +/** + * Sets the command [permissions]. + */ +public fun Command.Builder.permissions(vararg permission: dev.kord.common.entity.Permission) { + permissions(Permissions(permission.asList())) +} diff --git a/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/StandardKordCommandFactory.kt b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/StandardKordCommandFactory.kt new file mode 100644 index 0000000..2ec5345 --- /dev/null +++ b/cloud-kord/src/main/kotlin/org/incendo/cloud/discord/kord/StandardKordCommandFactory.kt @@ -0,0 +1,246 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord + +import cloud.commandframework.CommandTree +import cloud.commandframework.internal.CommandNode +import cloud.commandframework.permission.Permission +import dev.kord.common.DiscordBitSet +import dev.kord.common.entity.Permissions.Builder +import dev.kord.core.Kord +import dev.kord.core.behavior.createApplicationCommands +import dev.kord.core.entity.Guild +import dev.kord.rest.builder.interaction.BaseChoiceBuilder +import dev.kord.rest.builder.interaction.BaseInputChatBuilder +import dev.kord.rest.builder.interaction.ChatInputCreateBuilder +import dev.kord.rest.builder.interaction.MultiApplicationCommandBuilder +import dev.kord.rest.builder.interaction.NumericOptionBuilder +import dev.kord.rest.builder.interaction.OptionsBuilder +import dev.kord.rest.builder.interaction.SubCommandBuilder +import dev.kord.rest.builder.interaction.attachment +import dev.kord.rest.builder.interaction.boolean +import dev.kord.rest.builder.interaction.channel +import dev.kord.rest.builder.interaction.group +import dev.kord.rest.builder.interaction.input +import dev.kord.rest.builder.interaction.integer +import dev.kord.rest.builder.interaction.mentionable +import dev.kord.rest.builder.interaction.number +import dev.kord.rest.builder.interaction.role +import dev.kord.rest.builder.interaction.string +import dev.kord.rest.builder.interaction.subCommand +import dev.kord.rest.builder.interaction.user +import kotlinx.coroutines.flow.forEach +import org.apiguardian.api.API +import org.incendo.cloud.discord.slash.CommandScope +import org.incendo.cloud.discord.slash.CommandScopePredicate +import org.incendo.cloud.discord.slash.DiscordCommand +import org.incendo.cloud.discord.slash.DiscordCommandFactory +import org.incendo.cloud.discord.slash.DiscordOption +import org.incendo.cloud.discord.slash.DiscordOption.SubCommand +import org.incendo.cloud.discord.slash.DiscordOption.Variable +import org.incendo.cloud.discord.slash.DiscordOptionType +import org.incendo.cloud.discord.slash.DiscordPermission +import org.incendo.cloud.discord.slash.NodeProcessor +import org.incendo.cloud.discord.slash.OptionRegistry +import org.incendo.cloud.discord.slash.StandardDiscordCommandFactory +import org.incendo.cloud.discord.slash.StandardOptionRegistry + +@API(status = API.Status.STABLE, since = "1.0.0") +internal class StandardKordCommandFactory( + private val commandTree: CommandTree, + private val optionRegistry: OptionRegistry = StandardOptionRegistry(), + private val discordCommandFactory: DiscordCommandFactory = StandardDiscordCommandFactory(optionRegistry), + private val nodeProcessor: NodeProcessor = NodeProcessor(commandTree), + override var commandScopePredicate: CommandScopePredicate = CommandScopePredicate.alwaysTrue() +) : KordCommandFactory { + + init { + optionRegistry + .registerMapping(KordOptionType.USER, KordParser.userParser()) + .registerMapping(KordOptionType.CHANNEL, KordParser.channelParser()) + .registerMapping(KordOptionType.ROLE, KordParser.roleParser()) + .registerMapping(KordOptionType.MENTIONABLE, KordParser.mentionableParser()) + .registerMapping(KordOptionType.ATTACHMENT, KordParser.attachmentParser()) + } + + override suspend fun createGuildCommands(guild: Guild) { + guild.createApplicationCommands { + createCommands(CommandScope.guilds(-1, guild.id.value.toLong())) + } + } + + override suspend fun deleteGuildCommands(guild: Guild) { + guild.getApplicationCommands().collect { + it.delete() + } + } + + override suspend fun createGlobalCommands(kord: Kord) { + kord.createGlobalApplicationCommands { + createCommands(CommandScope.global()) + } + } + + override suspend fun deleteGlobalCommands(kord: Kord) { + kord.getGlobalApplicationCommands().collect { + it.delete() + } + } + + private fun MultiApplicationCommandBuilder.createCommands(scope: CommandScope) { + nodeProcessor.prepareTree() + + commandTree.rootNodes().forEach { rootNode -> + val rootScope = rootNode.nodeMeta()[NodeProcessor.NODE_META_SCOPE] as CommandScope + if (!rootScope.overlaps(scope)) { + return@forEach + } + + if (!commandScopePredicate.test(rootNode, scope)) { + return@forEach + } + + val discordCommand = discordCommandFactory.create(rootNode) + input(discordCommand.name(), discordCommand.description()) { + createCommand(discordCommand) + + (rootNode.nodeMeta()[CommandNode.META_KEY_PERMISSION] as? Permission) + ?.let { it as? DiscordPermission } + ?.permissionString() + ?.let { DiscordBitSet(it) } + ?.let(::Builder) + ?.let(Builder::build) + ?.apply(this@input::defaultMemberPermissions::set) + } + } + } + + private fun ChatInputCreateBuilder.createCommand(discordCommand: DiscordCommand) { + discordCommand.options().forEach { option -> + createOption(option) + } + } + + private fun ChatInputCreateBuilder.createOption(discordOption: DiscordOption) { + if (discordOption is SubCommand) { + createSubCommand(discordOption) + } else if (discordOption is Variable) { + createVariable(discordOption) + } + } + + private fun ChatInputCreateBuilder.createSubCommand(subCommand: SubCommand) { + if (subCommand.type() == DiscordOptionType.SUB_COMMAND) { + subCommand(subCommand.name(), subCommand.description()) { + configureSubCommand(subCommand) + } + } else { + group(subCommand.name(), subCommand.description()) { + subCommand.options().forEach { child -> + require(child is SubCommand) { + "Cannot add variable option ${child.name()} as a child if group ${subCommand.name()}" + } + subCommand(child.name(), child.description()) { + configureSubCommand(child) + } + } + } + } + } + + private fun SubCommandBuilder.configureSubCommand(subCommand: SubCommand) { + subCommand.options().forEach { child -> + require(child is Variable) { + "Cannot add subcommand ${child.name()} as a child of subcommand ${subCommand.name()}" + } + createVariable(child) + } + } + + private fun BaseInputChatBuilder.createVariable(variable: Variable) { + when (variable.type()) { + DiscordOptionType.INTEGER -> integer(variable.name(), variable.description()) { + configureNumericVariable(variable) + } + DiscordOptionType.NUMBER -> number(variable.name(), variable.description()) { + configureNumericVariable(variable) + } + DiscordOptionType.STRING -> string(variable.name(), variable.description()) { + configureChoiceVariable(variable) + } + DiscordOptionType.BOOLEAN -> boolean(variable.name(), variable.description()) { + configureVariable(variable) + } + KordOptionType.ROLE -> role(variable.name(), variable.description()) { + configureVariable(variable) + } + KordOptionType.CHANNEL -> channel(variable.name(), variable.description()) { + configureVariable(variable) + } + KordOptionType.USER -> user(variable.name(), variable.description()) { + configureVariable(variable) + } + KordOptionType.MENTIONABLE -> mentionable(variable.name(), variable.description()) { + configureVariable(variable) + } + KordOptionType.ATTACHMENT -> attachment(variable.name(), variable.description()) { + configureVariable(variable) + } + } + } + + private inline fun NumericOptionBuilder.configureNumericVariable(variable: Variable) { + variable.range()?.let { + if (T::class == Long::class) { + minValue = it.min().toLong() as T + maxValue = it.max().toLong() as T + } else { + minValue = it.min().toDouble() as T + maxValue = it.max().toDouble() as T + } + } + configureChoiceVariable(variable) + } + + private inline fun BaseChoiceBuilder.configureChoiceVariable(variable: Variable) { + if (variable.autocomplete()) { + autocomplete = true + } else if (variable.choices().isNotEmpty()) { + variable.choices().forEach { choice -> + if (T::class == Long::class) { + choice(choice.name(), (choice.value() as Number).toLong() as T) + } else if (T::class == Double::class) { + choice(choice.name(), (choice.value() as Number).toDouble() as T) + } else { + choice(choice.name(), choice.value() as T) + } + } + } + configureVariable(variable) + } + + private fun OptionsBuilder.configureVariable(variable: Variable) { + required = variable.required() + } +} diff --git a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AggregateCommand.java b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AggregateCommand.java index 0f4fcfd..3d9bc04 100644 --- a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AggregateCommand.java +++ b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AggregateCommand.java @@ -58,7 +58,7 @@ public void register(final @NonNull JDA5CommandManager commandMa final Hug hug = context.get("hug"); interaction.replyCallback().reply("You hug " + hug.recipient().getAsMention() - + " " + hug.number() + " times!").queue(); + + " " + hug.number() + " time(s)!").queue(); }) ); } diff --git a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AnnotatedCommands.java b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AnnotatedCommands.java index ed549d0..6e41914 100644 --- a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AnnotatedCommands.java +++ b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/AnnotatedCommands.java @@ -42,10 +42,10 @@ import org.incendo.cloud.discord.immutables.ImmutableImpl; import org.incendo.cloud.discord.jda5.JDA5CommandManager; import org.incendo.cloud.discord.jda5.JDAInteraction; -import org.incendo.cloud.discord.jda5.annotations.CommandScopeBuilderModifier; import org.incendo.cloud.discord.jda5.annotations.ReplySetting; import org.incendo.cloud.discord.jda5.annotations.ReplySettingBuilderModifier; import org.incendo.cloud.discord.jda5.example.Example; +import org.incendo.cloud.discord.slash.annotations.CommandScopeBuilderModifier; /** * Example showcasing how to use cloud-annotations with cloud-jda5. diff --git a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/PingCommand.java b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/PingCommand.java index 8ca99e2..fd4b1b9 100644 --- a/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/PingCommand.java +++ b/examples/example-jda5/src/main/java/org/incendo/cloud/discord/jda5/example/commands/PingCommand.java @@ -30,6 +30,7 @@ import org.incendo.cloud.discord.jda5.JDAInteraction; import org.incendo.cloud.discord.jda5.ReplySetting; import org.incendo.cloud.discord.jda5.example.Example; +import org.incendo.cloud.discord.slash.CommandScope; import static cloud.commandframework.arguments.standard.StringParser.greedyStringParser; @@ -48,6 +49,7 @@ public void register(final @NonNull JDA5CommandManager commandMa commandManager.command( commandManager.commandBuilder("ping", Description.of("A ping command")) .apply(ReplySetting.defer(true)) // Defer the response & make the response ephemeral. + .apply(CommandScope.guilds()) // You may only ping in guilds! .required(COMPONENT_MESSAGE, greedyStringParser(), Description.of("The message")) .handler(context -> { final JDAInteraction interaction = context.sender(); diff --git a/examples/example-kord/README.md b/examples/example-kord/README.md new file mode 100644 index 0000000..bd9040b --- /dev/null +++ b/examples/example-kord/README.md @@ -0,0 +1,12 @@ +# example-kord + +Examples for Kord. + +## Run + +Create a file in this directory called `bot.properties`, and add your bot token: +```properties +token=YOUR_TOKEN +``` + +Run the bot using `./gradlew :example-kord:run`. diff --git a/examples/example-kord/build.gradle.kts b/examples/example-kord/build.gradle.kts new file mode 100644 index 0000000..74c1b44 --- /dev/null +++ b/examples/example-kord/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("cloud-discord.kotlin-conventions") + application +} + +indra { + javaVersions { + minimumToolchain(17) + target(17) + testWith().set(setOf(17)) + } +} + +dependencies { + implementation(projects.cloudKord) + implementation(libs.cloud.annotations) + implementation(libs.cloud.kotlin.coroutines.annotations) + implementation(libs.kord) + implementation(libs.kotlin.logging) + implementation(libs.logback.core) + implementation(libs.logback.classic) +} + +application { + mainClass = "org.incendo.cloud.discord.kord.example.ExampleBotKt" +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/BotConfiguration.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/BotConfiguration.kt new file mode 100644 index 0000000..c6f561c --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/BotConfiguration.kt @@ -0,0 +1,49 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example + +import java.io.File +import java.io.FileReader +import java.util.Properties + +/** + * Configuration for the example bot. + */ +public interface BotConfiguration { + + /** + * The bot token. + */ + public val token: String +} + +internal class PropertiesBotConfiguration private constructor(private val properties: Properties) : BotConfiguration { + + internal constructor(file: File) : this(Properties()) { + FileReader(file).use(properties::load) + } + + override val token: String + get() = properties.getProperty("token") +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Example.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Example.kt new file mode 100644 index 0000000..d74dc8a --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Example.kt @@ -0,0 +1,38 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example + +import org.incendo.cloud.discord.kord.KordCommandManager +import org.incendo.cloud.discord.kord.KordInteraction + +/** + * An example. + */ +public interface Example { + + /** + * Registers the example using the given [commandManager]. + */ + public fun register(commandManager: KordCommandManager) +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/ExampleBot.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/ExampleBot.kt new file mode 100644 index 0000000..56c94ea --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/ExampleBot.kt @@ -0,0 +1,65 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example + +import cloud.commandframework.execution.ExecutionCoordinator +import dev.kord.core.Kord +import io.github.oshai.kotlinlogging.KotlinLogging +import org.incendo.cloud.discord.kord.KordCommandManager +import java.io.File + +private val logger = KotlinLogging.logger {} + +/** + * Example kord bot. + */ +public class ExampleBot(public val configuration: BotConfiguration) { + + /** + * Starts the bot. + */ + public suspend fun start() { + logger.info { "Starting the example bot..." } + val commandManager = KordCommandManager(ExecutionCoordinator.simpleCoordinator()) { + it + } + + Examples(commandManager).registerExamples() + + logger.info { "Logging into Kord..." } + val kord = Kord(configuration.token) + + logger.info { "Installing the event listener..." } + commandManager.installListener(kord) + + kord.login() + } +} + +/** + * Main method. + */ +public suspend fun main() { + ExampleBot(PropertiesBotConfiguration(File("./bot.properties"))).start() +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Examples.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Examples.kt new file mode 100644 index 0000000..9fb34a4 --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/Examples.kt @@ -0,0 +1,58 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.incendo.cloud.discord.kord.KordCommandManager +import org.incendo.cloud.discord.kord.KordInteraction +import org.incendo.cloud.discord.kord.example.commands.AggregateCommand +import org.incendo.cloud.discord.kord.example.commands.AnnotatedCommands +import org.incendo.cloud.discord.kord.example.commands.PingCommand + +private val logger = KotlinLogging.logger {} + +/** + * Class that registers the examples. + * + * You can find the active examples in [examples]. + */ +public class Examples(private val commandManager: KordCommandManager) { + + private val examples: List = listOf( + AggregateCommand(), + AnnotatedCommands(), + PingCommand() + ) + + /** + * Registers the example commands. + */ + public fun registerExamples() { + logger.info { "Registering examples:" } + examples.forEach { example -> + logger.info { "- Registering example: ${example::class.simpleName}" } + example.register(commandManager) + } + } +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AggregateCommand.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AggregateCommand.kt new file mode 100644 index 0000000..385900b --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AggregateCommand.kt @@ -0,0 +1,92 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example.commands + +import cloud.commandframework.Description +import cloud.commandframework.arguments.aggregate.AggregateCommandParser +import cloud.commandframework.arguments.parser.ArgumentParseResult +import cloud.commandframework.arguments.standard.IntegerParser.integerParser +import cloud.commandframework.kotlin.coroutines.extension.suspendingHandler +import cloud.commandframework.kotlin.extension.buildAndRegister +import cloud.commandframework.kotlin.extension.name +import cloud.commandframework.kotlin.extension.parser +import cloud.commandframework.kotlin.extension.suggestionProvider +import cloud.commandframework.kotlin.extension.textDescription +import cloud.commandframework.kotlin.extension.withComponent +import dev.kord.core.entity.User +import org.incendo.cloud.discord.kord.KordCommandManager +import org.incendo.cloud.discord.kord.KordInteraction +import org.incendo.cloud.discord.kord.KordParser.Companion.userParser +import org.incendo.cloud.discord.kord.example.Example +import org.incendo.cloud.discord.kord.interaction +import org.incendo.cloud.discord.slash.DiscordChoices + +/** + * Example showcasing aggregate parsers. + */ +public class AggregateCommand : Example { + + override fun register(commandManager: KordCommandManager) { + val hugParser = AggregateCommandParser.builder() + .withComponent { + parser = userParser() + name = "recipient" + textDescription = "The recipient of the hugs" + } + .withComponent { + parser = integerParser(1, 20) + name = "number" + textDescription = "The number of hugs" + suggestionProvider = DiscordChoices.integers(1..20) + } + .withDirectMapper(Hug::class.java) { _, ctx -> + ArgumentParseResult.success( + Hug(ctx.get("recipient"), ctx.get("number")) + ) + }.build() + + commandManager.buildAndRegister("hug", Description.of("Hug someone")) { + required("hug", hugParser) + + suspendingHandler { context -> + val hug = context.get("hug") + + context.interaction.respondPublic { + content = "You hug ${hug.recipient.mention} ${hug.number} time(s)!" + } + } + } + } + + public data class Hug( + /** + * The recipient of the hugs. + */ + public val recipient: User, + /** + * The number of hugs. + */ + public val number: Int + ) +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AnnotatedCommands.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AnnotatedCommands.kt new file mode 100644 index 0000000..3c78753 --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/AnnotatedCommands.kt @@ -0,0 +1,135 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example.commands + +import cloud.commandframework.annotations.AnnotationParser +import cloud.commandframework.annotations.Argument +import cloud.commandframework.annotations.Command +import cloud.commandframework.annotations.specifier.Completions +import cloud.commandframework.annotations.specifier.Range +import cloud.commandframework.annotations.suggestions.Suggestions +import cloud.commandframework.arguments.suggestion.Suggestion +import cloud.commandframework.kotlin.coroutines.annotations.installCoroutineSupport +import dev.kord.core.behavior.interaction.response.respond +import dev.kord.core.entity.Entity +import org.incendo.cloud.discord.kord.KordCommandManager +import org.incendo.cloud.discord.kord.KordInteraction +import org.incendo.cloud.discord.kord.example.Example +import org.incendo.cloud.discord.slash.annotations.CommandScopeBuilderModifier + +public data class AnnotatedCommands( + private val catRepository: CatRepository = CatRepositoryImpl() +) : Example { + + override fun register(commandManager: KordCommandManager) { + val annotationParser = AnnotationParser( + commandManager, + KordInteraction::class.java + ) + + // Adds support for suspending functions. + annotationParser.installCoroutineSupport() + + // Adds support for @CommandScope + CommandScopeBuilderModifier.install(annotationParser) + + // Parses @Command, @Parser, @Suggestions & @ExceptionHandler... + annotationParser.parse(this) + } + + @Command("cat add ") + public suspend fun addCat( + interaction: KordInteraction, + @Completions("Cat,Benny,Meowy") name: String, + @Range(min = "0", max = "20") age: Int + ) { + catRepository.addCat(Cat(name, age)) + interaction.respondEphemeral { + content = "Added the cat named $name with age $age" + } + } + + @Command("cat remove ") + public suspend fun removeCat(interaction: KordInteraction, @Argument(suggestions = "cats") name: String) { + catRepository.removeCat(name) + interaction.respondEphemeral { + content = "Removed the cat named $name" + } + } + + @Command("cat list") + public suspend fun listCats(interaction: KordInteraction) { + val cats = catRepository.cats.asSequence().map(Cat::name).joinToString(", ") + interaction.respondEphemeral { + content = "Cats: $cats" + } + } + + @Command("cat meow ") + public suspend fun meow(interaction: KordInteraction, @Argument(suggestions = "cats") cat: String, target: Entity) { + interaction.deferPublicResponse().respond { + content = "$cat meows at <@${target.id}>" + } + } + + @Suggestions("cats") + public fun catNames(): Sequence = catRepository.cats.asSequence().map(Cat::name).map(Suggestion::simple) + + public data class Cat( + public val name: String, + public val age: Int + ) + + public interface CatRepository { + + /** + * Adds the [cat]. + */ + public fun addCat(cat: Cat) + + /** + * Removes the cat with the given [name]. + */ + public fun removeCat(name: String) + + /** + * An immutable view of the cats. + */ + public val cats: Iterable + } + + private data class CatRepositoryImpl(private val catMap: MutableMap = mutableMapOf()) : CatRepository { + + override val cats: Iterable + get() = catMap.values + + override fun addCat(cat: Cat) { + catMap[cat.name] = cat + } + + override fun removeCat(name: String) { + catMap -= name + } + } +} diff --git a/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/PingCommand.kt b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/PingCommand.kt new file mode 100644 index 0000000..ffc77af --- /dev/null +++ b/examples/example-kord/src/main/kotlin/org/incendo/cloud/discord/kord/example/commands/PingCommand.kt @@ -0,0 +1,72 @@ +// +// MIT License +// +// Copyright (c) 2024 Incendo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. +// +package org.incendo.cloud.discord.kord.example.commands + +import cloud.commandframework.Description +import cloud.commandframework.arguments.standard.StringParser.greedyStringParser +import cloud.commandframework.keys.CloudKey +import cloud.commandframework.kotlin.coroutines.extension.suspendingHandler +import cloud.commandframework.kotlin.extension.buildAndRegister +import cloud.commandframework.kotlin.extension.textDescription +import dev.kord.common.entity.Permission +import org.incendo.cloud.discord.kord.KordCommandManager +import org.incendo.cloud.discord.kord.KordInteraction +import org.incendo.cloud.discord.kord.commandScope +import org.incendo.cloud.discord.kord.example.Example +import org.incendo.cloud.discord.kord.interaction +import org.incendo.cloud.discord.kord.permissions +import org.incendo.cloud.discord.slash.CommandScope + +/** + * Example of a command that responds with the original input. + */ +public class PingCommand : Example { + + private companion object { + private val COMPONENT_MESSAGE = CloudKey.of( + "message", + String::class.java + ) + } + + override fun register(commandManager: KordCommandManager) { + commandManager.buildAndRegister("ping", Description.of("A ping command")) { + commandScope(CommandScope.guilds()) // You may only ping in guilds! + + permissions(Permission.Administrator) + + required(COMPONENT_MESSAGE, greedyStringParser()) { + textDescription = "Hello world!" + } + + suspendingHandler { context -> + val message = context.get(COMPONENT_MESSAGE) + + context.interaction.respondEphemeral { + content = message + } + } + } + } +} diff --git a/examples/example-kord/src/main/resources/logback.xml b/examples/example-kord/src/main/resources/logback.xml new file mode 100644 index 0000000..78f3e89 --- /dev/null +++ b/examples/example-kord/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + [%d{HH:mm:ss}][%-5level][%-30logger{0}]: %msg%n + + + + + + diff --git a/gradle/build-logic/build.gradle.kts b/gradle/build-logic/build.gradle.kts index 1ad12cc..f7a5a35 100644 --- a/gradle/build-logic/build.gradle.kts +++ b/gradle/build-logic/build.gradle.kts @@ -10,6 +10,7 @@ repositories { dependencies { implementation(libs.cloud.build.logic) implementation(libs.gradleKotlinJvm) + implementation(libs.gradleDokka) implementation(files(libs.javaClass.superclass.protectionDomain.codeSource.location)) } diff --git a/gradle/build-logic/src/main/kotlin/cloud-discord.kotlin-conventions.gradle.kts b/gradle/build-logic/src/main/kotlin/cloud-discord.kotlin-conventions.gradle.kts new file mode 100644 index 0000000..ff9b970 --- /dev/null +++ b/gradle/build-logic/src/main/kotlin/cloud-discord.kotlin-conventions.gradle.kts @@ -0,0 +1,33 @@ +import gradle.kotlin.dsl.accessors._4170a67d0be8a515d9becde6b6ee87f3.api +import org.jetbrains.kotlin.gradle.dsl.KotlinJvmProjectExtension + +plugins { + id("cloud-discord.base-conventions") + id("org.jetbrains.kotlin.jvm") + id("org.jetbrains.dokka") +} + +configure { + explicitApi() + jvmToolchain { + languageVersion.set(JavaLanguageVersion.of(17)) + } + coreLibrariesVersion = libs.versions.kotlin.get() + target { + compilations.configureEach { + kotlinOptions { + jvmTarget = "17" + languageVersion = libs.versions.kotlin.get().split(".").take(2).joinToString(".") + javaParameters = true + } + } + } + + dependencies { + api(kotlin("stdlib-jdk8")) + } + + tasks.named("javadocJar", AbstractArchiveTask::class) { + from(tasks.named("dokkaHtml")) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 51b3e3e..a49d3e0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,7 +7,11 @@ cloud-buildLogic-rootProject-spotless = { id = "org.incendo.cloud-build-logic.sp cloud-build-logic = "0.0.3" ktlint = "1.0.1" checkstyle = "10.12.5" + +# Kotlin kotlin = "1.9.22" +dokka = "1.9.10" +coroutines = "1.7.3" # Cloud cloud = "2.0.0-SNAPSHOT" @@ -18,20 +22,27 @@ javacord = "3.8.0" jda = "4.4.1_353" jda5 = "5.0.0-beta.19" logback = "1.4.14" +kord = "0.13.0" +kotlinLogging = "6.0.3" # Test jupiterEngine = "5.10.1" mockitoCore = "4.11.0" mockitoJupiter = "4.11.0" +mockitoKotlin = "4.1.0" truth = "1.2.0" [libraries] cloud-build-logic = { module = "org.incendo:cloud-build-logic", version.ref = "cloud-build-logic" } gradleKotlinJvm = { group = "org.jetbrains.kotlin.jvm", name = "org.jetbrains.kotlin.jvm.gradle.plugin", version.ref = "kotlin" } +gradleDokka = { group = "org.jetbrains.dokka", name = "dokka-gradle-plugin", version.ref = "dokka" } # Cloud cloud-core = { group = "cloud.commandframework", name = "cloud-core", version.ref = "cloud" } cloud-annotations = { group = "cloud.commandframework", name = "cloud-annotations", version.ref = "cloud" } +cloud-kotlin-coroutines = { group = "cloud.commandframework", name = "cloud-kotlin-coroutines", version.ref = "cloud" } +cloud-kotlin-coroutines-annotations = { group = "cloud.commandframework", name = "cloud-kotlin-coroutines-annotations", version.ref = "cloud"} +cloud-kotlin-extensions = { group = "cloud.commandframework", name = "cloud-kotlin-extensions", version.ref = "cloud" } # External immutables = { group = "org.immutables", name = "value", version.ref = "immutables" } @@ -40,14 +51,21 @@ jda = { group = "net.dv8tion", name = "JDA", version.ref = "jda" } jda5 = { group = "net.dv8tion", name = "JDA", version.ref = "jda5" } logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = "logback" } logback-classic = { group = "ch.qos.logback", name = "logback-classic", version.ref = "logback" } +kord = { group = "dev.kord", name = "kord-core", version.ref = "kord" } +kotlin-logging = { group = "io.github.oshai", name = "kotlin-logging-jvm", version.ref = "kotlinLogging" } +# Kotlin +coroutinesCore = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } +coroutinesJdk8 = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-jdk8", version.ref = "coroutines" } # Test jupiter-engine = { group = "org.junit.jupiter", name = "junit-jupiter-engine", version.ref = "jupiterEngine" } jupiter-params = { group = "org.junit.jupiter", name = "junit-jupiter-params", version.ref = "jupiterEngine" } mockito-core = { group = "org.mockito", name = "mockito-core", version.ref = "mockitoCore" } mockito-jupiter = { group = "org.mockito", name = "mockito-junit-jupiter", version.ref = "mockitoJupiter" } +mockito-kotlin = { group = "org.mockito.kotlin", name = "mockito-kotlin", version.ref = "mockitoKotlin" } truth = { group = "com.google.truth", name = "truth", version.ref = "truth" } truth-java8 = { group = "com.google.truth.extensions", name = "truth-java8-extension", version.ref = "truth" } [bundles] +coroutines = ["coroutinesCore", "coroutinesJdk8"] diff --git a/settings.gradle.kts b/settings.gradle.kts index 0f120fa..0081de5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -34,6 +34,9 @@ include(":cloud-discord-common") include(":cloud-javacord") include(":cloud-jda") include(":cloud-jda5") +include(":cloud-kord") include("examples/example-jda5") findProject(":examples/example-jda5")?.name = "example-jda5" +include("examples/example-kord") +findProject(":examples/example-kord")?.name = "example-kord"