From 587ea232db31f1c8f6cfa59f75581bbf4766f418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20S=C3=B6derberg?= Date: Sun, 21 Jan 2024 14:19:42 +0100 Subject: [PATCH] feat: discord4j implementation --- .idea/codeStyles/codeStyleConfig.xml | 5 + README.md | 1 + cloud-discord4j/README.md | 3 + cloud-discord4j/build.gradle.kts | 11 + .../Discord4JCommandExecutionHandler.java | 72 ++++++ .../discord4j/Discord4JCommandFactory.java | 50 ++++ .../discord4j/Discord4JCommandManager.java | 191 +++++++++++++++ .../discord4j/Discord4JEventListener.java | 186 ++++++++++++++ .../discord4j/Discord4JInteraction.java | 96 ++++++++ .../discord4j/Discord4JOptionType.java | 79 ++++++ .../discord/discord4j/Discord4JParser.java | 156 ++++++++++++ .../StandardDiscord4JCommandFactory.java | 141 +++++++++++ .../cloud/discord/discord4j/package-info.java | 4 + examples/example-discord4j/README.md | 12 + examples/example-discord4j/build.gradle.kts | 16 ++ .../discord4j/example/BotConfiguration.java | 39 +++ .../discord/discord4j/example/Example.java | 41 ++++ .../discord/discord4j/example/ExampleBot.java | 71 ++++++ .../discord/discord4j/example/Examples.java | 72 ++++++ .../example/PropertiesBotConfiguration.java | 70 ++++++ .../example/commands/AggregateCommand.java | 88 +++++++ .../example/commands/AnnotatedCommands.java | 228 ++++++++++++++++++ .../example/commands/PingCommand.java | 63 +++++ .../example/commands/package-info.java | 4 + .../discord4j/example/package-info.java | 4 + .../src/main/resources/logback.xml | 10 + gradle/libs.versions.toml | 2 + settings.gradle.kts | 4 + 28 files changed, 1719 insertions(+) create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 cloud-discord4j/README.md create mode 100644 cloud-discord4j/build.gradle.kts create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandExecutionHandler.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandFactory.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandManager.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JEventListener.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JInteraction.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JOptionType.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JParser.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/StandardDiscord4JCommandFactory.java create mode 100644 cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/package-info.java create mode 100644 examples/example-discord4j/README.md create mode 100644 examples/example-discord4j/build.gradle.kts create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/BotConfiguration.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Example.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/ExampleBot.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Examples.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/PropertiesBotConfiguration.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AggregateCommand.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AnnotatedCommands.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/PingCommand.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/package-info.java create mode 100644 examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/package-info.java create mode 100644 examples/example-discord4j/src/main/resources/logback.xml diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..a55e7a1 --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/README.md b/README.md index 4f9daa1..41f9379 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ Discord integrations for [Cloud v2](https://github.com/incendo/cloud). ## modules - cloud-discord-common: shared utilities +- cloud-discord4j: interaction for Discord4J slash commands. - cloud-jda: integration for JDA4 - cloud-jda5: integration for JDA5 slash commands - cloud-javacord: integration for javacord diff --git a/cloud-discord4j/README.md b/cloud-discord4j/README.md new file mode 100644 index 0000000..4f5d60b --- /dev/null +++ b/cloud-discord4j/README.md @@ -0,0 +1,3 @@ +# cloud-discord4j + +Cloud integration for Discord4J slash commands. diff --git a/cloud-discord4j/build.gradle.kts b/cloud-discord4j/build.gradle.kts new file mode 100644 index 0000000..82a85bd --- /dev/null +++ b/cloud-discord4j/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("cloud-discord.base-conventions") + id("cloud-discord.publishing-conventions") +} + +dependencies { + api(projects.cloudDiscordCommon) + implementation(libs.cloud.annotations) + + implementation(libs.discord4j) +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandExecutionHandler.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandExecutionHandler.java new file mode 100644 index 0000000..8376926 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandExecutionHandler.java @@ -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.discord4j; + +import cloud.commandframework.context.CommandContext; +import cloud.commandframework.execution.CommandExecutionHandler; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +@FunctionalInterface +@API(status = API.Status.STABLE, since = "1.0.0") +public interface Discord4JCommandExecutionHandler extends CommandExecutionHandler.FutureCommandExecutionHandler { + + /** + * Returns a new execution handler that wraps the given {@code function}. + * + * @param command sender type + * @param function function that consumes the {@link CommandContext} and returns a {@link Publisher} that publishes the + * result of the interaction + * @return the command execution handler + */ + static @NonNull CommandExecutionHandler reactiveHandler( + final @NonNull Function<@NonNull CommandContext, @NonNull Publisher> function + ) { + return new Discord4JCommandExecutionHandler() { + + @Override + public @NonNull Publisher executeReactively(final @NonNull CommandContext commandContext) { + return function.apply(commandContext); + } + }; + } + + @Override + default CompletableFuture<@Nullable Void> executeFuture(final @NonNull CommandContext commandContext) { + return Mono.from(this.executeReactively(commandContext)).then().toFuture(); + } + + /** + * Executes the command and returns a publisher that publishes the result. + * + * @param commandContext command context + * @return the publisher + */ + @NonNull Publisher executeReactively(@NonNull CommandContext commandContext); +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandFactory.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandFactory.java new file mode 100644 index 0000000..79b7106 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandFactory.java @@ -0,0 +1,50 @@ +// +// 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.discord4j; + +import discord4j.discordjson.json.ApplicationCommandRequest; +import java.util.List; +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 Discord4JCommandFactory { + + /** + * Creates the Discord4J commands. + * + * @param scope current scope + * @return created commands + */ + @NonNull List<@NonNull ApplicationCommandRequest> createCommands(@NonNull CommandScope scope); + + /** + * Sets the command scope predicate of the instance. + * + * @param predicate new predicate + */ + void commandScopePredicate(@NonNull CommandScopePredicate predicate); +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandManager.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandManager.java new file mode 100644 index 0000000..e6e3ec8 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JCommandManager.java @@ -0,0 +1,191 @@ +// +// 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.discord4j; + +import cloud.commandframework.CommandManager; +import cloud.commandframework.context.CommandContext; +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.execution.ExecutionCoordinator; +import cloud.commandframework.internal.CommandRegistrationHandler; +import cloud.commandframework.keys.CloudKey; +import cloud.commandframework.setting.Configurable; +import discord4j.core.GatewayDiscordClient; +import java.util.Objects; +import java.util.function.BiConsumer; +import java.util.function.BiPredicate; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.slash.DiscordSetting; +import reactor.core.publisher.Mono; + +/** + * Command manager for Discord4J. + * + * @param command sender type + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public class Discord4JCommandManager extends CommandManager { + + public static final CloudKey CONTEXT_DISCORD4J_INTERACTION = CloudKey.of( + "cloud:discord4j_interaction", + Discord4JInteraction.class + ); + + private final Discord4JInteraction.InteractionMapper senderMapper; + private final Configurable discordSettings = Configurable.enumConfigurable(DiscordSetting.class); + + private Discord4JCommandFactory commandFactory; + private BiPredicate permissionPredicate; + + /** + * Creates a new command manager. + * + * @param executionCoordinator execution coordinator instance + * @param senderMapper mapper from {@link Discord4JInteraction} to {@link C} + */ + public Discord4JCommandManager( + final @NonNull ExecutionCoordinator executionCoordinator, + final Discord4JInteraction.@NonNull InteractionMapper senderMapper + ) { + super(executionCoordinator, CommandRegistrationHandler.nullCommandRegistrationHandler()); + this.commandFactory = new StandardDiscord4JCommandFactory<>(this); + this.permissionPredicate = (sender, permission) -> true; + this.senderMapper = Objects.requireNonNull(senderMapper, "senderMapper"); + + this.registerDefaultExceptionHandlers(); + + this.parserRegistry() + .registerParser(Discord4JParser.userParser()) + .registerParser(Discord4JParser.roleParser()) + .registerParser(Discord4JParser.channelParser()) + .registerParser(Discord4JParser.mentionableParser()) + .registerParser(Discord4JParser.attachmentParser()); + + // Common parameter injections. + this.parameterInjectorRegistry().registerInjector( + Discord4JInteraction.class, + (ctx, annotations) -> ctx.get(CONTEXT_DISCORD4J_INTERACTION) + ); + + this.discordSettings.set(DiscordSetting.EPHEMERAL_ERROR_MESSAGES, true); + } + + @Override + public boolean hasPermission(final @NonNull C sender, final @NonNull String permission) { + return this.permissionPredicate.test(sender, permission); + } + + /** + * Sets the permission predicate. + * + * @param permissionPredicate permission predicate + */ + public final void permissionPredicate(final @NonNull BiPredicate permissionPredicate) { + this.permissionPredicate = Objects.requireNonNull(permissionPredicate, "permissionPredicate"); + } + + /** + * Returns the sender mapper. + * + * @return sender mapper + */ + public final Discord4JInteraction.@NonNull InteractionMapper senderMapper() { + return this.senderMapper; + } + + /** + * Returns the command factory. + * + * @return command factory + */ + public final @NonNull Discord4JCommandFactory commandFactory() { + return this.commandFactory; + } + + /** + * Sets the command factory. + * + * @param commandFactory command factory + */ + public final void commandFactory(final @NonNull Discord4JCommandFactory commandFactory) { + this.commandFactory = Objects.requireNonNull(commandFactory, "commandFactory"); + } + + /** + * Installs the event listener using the given {@code gateway} instance. + * + *

The event listener is responsible for command synchronization.

+ * + * @param gateway gateway instance + * @return mono that represents the termination of the installation + */ + public final @NonNull Mono installEventListener(final @NonNull GatewayDiscordClient gateway) { + Objects.requireNonNull(gateway, "gateway"); + final Discord4JEventListener eventListener = new Discord4JEventListener<>(this); + return eventListener.install(gateway); + } + + /** + * Returns the Discord settings. + * + * @return discord settings + */ + public final @NonNull Configurable discordSettings() { + return this.discordSettings; + } + + private void registerDefaultExceptionHandlers() { + final BiConsumer, String> sendMessage = (context, message) -> { + final Discord4JInteraction interaction = context.get(CONTEXT_DISCORD4J_INTERACTION); + interaction.commandEvent().ifPresent(event -> event.reply(message) + .withEphemeral(this.discordSettings().get(DiscordSetting.EPHEMERAL_ERROR_MESSAGES)) + .subscribe()); + }; + + this.exceptionController().registerHandler( + Throwable.class, + ctx -> sendMessage.accept(ctx.context(), ctx.exception().getMessage()) + ).registerHandler( + CommandExecutionException.class, + ctx -> sendMessage.accept(ctx.context(), "Invalid Command Argument: " + ctx.exception().getCause().getMessage()) + ).registerHandler( + NoSuchCommandException.class, + ctx -> sendMessage.accept(ctx.context(), "Unknown command") + ).registerHandler( + NoPermissionException.class, + ctx -> sendMessage.accept(ctx.context(), "Insufficient permissions") + ).registerHandler( + InvalidCommandSenderException.class, + ctx -> sendMessage.accept(ctx.context(), ctx.exception().getMessage()) + ).registerHandler( + InvalidSyntaxException.class, + ctx -> sendMessage.accept(ctx.context(), + "Invalid Command Syntax. Correct command syntax is: /" + ctx.exception().correctSyntax())); + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JEventListener.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JEventListener.java new file mode 100644 index 0000000..b4cd5d0 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JEventListener.java @@ -0,0 +1,186 @@ +// +// 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.discord4j; + +import cloud.commandframework.context.CommandContext; +import cloud.commandframework.context.CommandContextFactory; +import cloud.commandframework.context.StandardCommandContextFactory; +import cloud.commandframework.util.StringUtils; +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.guild.GuildCreateEvent; +import discord4j.core.event.domain.interaction.ChatInputAutoCompleteEvent; +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.event.domain.lifecycle.ReadyEvent; +import discord4j.core.object.command.ApplicationCommandInteraction; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import discord4j.core.object.command.ApplicationCommandInteractionOptionValue; +import discord4j.core.object.command.ApplicationCommandOption; +import discord4j.discordjson.json.ApplicationCommandOptionChoiceData; +import discord4j.discordjson.json.ImmutableApplicationCommandOptionChoiceData; +import discord4j.rest.RestClient; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.slash.CommandScope; +import reactor.core.publisher.Mono; + +@API(status = API.Status.INTERNAL, since = "1.0.0") +final class Discord4JEventListener { + + private final Discord4JCommandManager commandManager; + private final CommandContextFactory contextFactory; + + Discord4JEventListener(final @NonNull Discord4JCommandManager commandManager) { + this.commandManager = commandManager; + this.contextFactory = new StandardCommandContextFactory<>(commandManager); + } + + @NonNull Mono install(final @NonNull GatewayDiscordClient gateway) { + return gateway.on(ReadyEvent.class, this::handleReadyEvent) + .then() + .and(gateway.on(GuildCreateEvent.class, this::handleGuildCreateEvent)) + .then() + .and(gateway.on(ChatInputInteractionEvent.class, this::handleChatInputInteractionEvent)) + .then() + .and(gateway.on(ChatInputAutoCompleteEvent.class, this::handleChatInputAutoCompleteEvent)); + } + + private @NonNull Mono handleReadyEvent(final @NonNull ReadyEvent event) { + final RestClient restClient = event.getClient().getRestClient(); + return restClient.getApplicationId().flatMap(applicationId -> + restClient.getApplicationService() + .bulkOverwriteGlobalApplicationCommand( + applicationId, + this.commandManager.commandFactory().createCommands(CommandScope.global()) + ).then() + ); + } + + private @NonNull Mono handleGuildCreateEvent(final @NonNull GuildCreateEvent event) { + final RestClient restClient = event.getClient().getRestClient(); + final long guildId = event.getGuild().getId().asLong(); + return restClient.getApplicationId().flatMap(applicationId -> + restClient.getApplicationService() + .bulkOverwriteGuildApplicationCommand( + applicationId, + guildId, + this.commandManager.commandFactory().createCommands(CommandScope.guilds(-1, guildId)) + ).then() + ); + } + + private @NonNull Mono handleChatInputInteractionEvent(final @NonNull ChatInputInteractionEvent event) { + return Mono.fromFuture(event.getInteraction().getCommandInteraction().map(interaction -> { + final Discord4JInteraction discord4JInteraction = Discord4JInteraction.builder() + .commandInteraction(interaction) + .interactionEvent(event) + .build(); + return this.commandManager.commandExecutor().executeCommand( + this.commandManager.senderMapper().map(discord4JInteraction), + this.extractCommandName(interaction), + context -> context.store(Discord4JCommandManager.CONTEXT_DISCORD4J_INTERACTION, discord4JInteraction) + ); + }).orElse(CompletableFuture.completedFuture(null))); + } + + private @NonNull Mono handleChatInputAutoCompleteEvent(final @NonNull ChatInputAutoCompleteEvent event) { + return Mono.fromFuture(event.getInteraction().getCommandInteraction().map(interaction -> { + String commandName = this.extractCommandName(interaction); + + final Optional value = event.getFocusedOption().getValue(); + if (!value.isPresent()) { + commandName = commandName + ' '; + } + + final Discord4JInteraction discord4JInteraction = Discord4JInteraction.builder() + .commandInteraction(interaction) + .interactionEvent(event) + .build(); + final CommandContext context = this.contextFactory.create( + true, + this.commandManager.senderMapper().map(discord4JInteraction) + ); + context.store(Discord4JCommandManager.CONTEXT_DISCORD4J_INTERACTION, discord4JInteraction); + + return this.commandManager.suggestionFactory() + .suggest(context, commandName) + .thenApply(suggestions -> suggestions.list() + .stream() + .map(suggestion -> { + if (suggestion.suggestion().contains(" ")) { + return suggestion.withSuggestion(StringUtils.trimBeforeLastSpace( + suggestion.suggestion(), + suggestions.commandInput() + )); + } + return suggestion; + }) + .filter(suggestion -> !suggestion.suggestion().isEmpty()) + .map(suggestion -> { + final ImmutableApplicationCommandOptionChoiceData.Builder builder = + ApplicationCommandOptionChoiceData.builder().name(suggestion.suggestion()); + switch (event.getFocusedOption().getType()) { + case INTEGER: + return builder.value(Integer.parseInt(suggestion.suggestion())).build(); + case NUMBER: + return builder.value(Double.parseDouble(suggestion.suggestion())).build(); + default: + return builder.value(suggestion.suggestion()).build(); + } + }) + .collect(Collectors.toList())); + }) + .orElseGet(() -> CompletableFuture.completedFuture(Collections.emptyList()))) + .>map(ArrayList::new) + .flatMap(event::respondWithSuggestions); + } + + private @NonNull String extractCommandName(final @NonNull ApplicationCommandInteraction interaction) { + final StringBuilder command = new StringBuilder(); + interaction.getName().ifPresent(command::append); + interaction.getOptions().forEach(option -> command.append(" ").append(this.extractOptionString(option))); + return command.toString(); + } + + private @NonNull String extractOptionString(final @NonNull ApplicationCommandInteractionOption option) { + final StringBuilder string = new StringBuilder(); + if (option.getType() == ApplicationCommandOption.Type.SUB_COMMAND + || option.getType() == ApplicationCommandOption.Type.SUB_COMMAND_GROUP) { + string.append(option.getName()); + option.getOptions().forEach(inner -> string.append(" ").append(this.extractOptionString(inner))); + } else { + if (Discord4JOptionType.DISCORD4J_OPTION_TYPES.stream() + .anyMatch(type -> type.value() == option.getType().getValue())) { + string.append(option.getName()); + } else { + option.getValue().map(ApplicationCommandInteractionOptionValue::getRaw).ifPresent(string::append); + } + } + return string.toString(); + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JInteraction.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JInteraction.java new file mode 100644 index 0000000..5cb4849 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JInteraction.java @@ -0,0 +1,96 @@ +// +// 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.discord4j; + +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.event.domain.interaction.InteractionCreateEvent; +import discord4j.core.object.command.ApplicationCommandInteraction; +import java.util.Optional; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.immutables.value.Value; +import org.incendo.cloud.discord.immutables.StagedImmutableBuilder; + +@StagedImmutableBuilder +@Value.Immutable +@API(status = API.Status.STABLE, since = "1.0.0") +public interface Discord4JInteraction { + + /** + * Returns a new builder. + * + * @return the builder + */ + static ImmutableDiscord4JInteraction.@NonNull CommandInteractionBuildStage builder() { + return ImmutableDiscord4JInteraction.builder(); + } + + /** + * Returns the command interaction. + * + * @return command interaction + */ + @NonNull ApplicationCommandInteraction commandInteraction(); + + /** + * Returns the interaction event. + * + * @return interaction event + */ + @NonNull InteractionCreateEvent interactionEvent(); + + /** + * Returns the command event. This will be empty during suggestion generation. + * + * @return command event + */ + default @NonNull Optional<@NonNull ChatInputInteractionEvent> commandEvent() { + if (this.interactionEvent() instanceof ChatInputInteractionEvent) { + return Optional.of((ChatInputInteractionEvent) this.interactionEvent()); + } + return Optional.empty(); + } + + @FunctionalInterface + @API(status = API.Status.STABLE, since = "1.0.0") + interface InteractionMapper { + + /** + * Returns a mapper that maps the interaction to itself. + * + * @return identity mapper + */ + static @NonNull InteractionMapper identity() { + return interaction -> interaction; + } + + /** + * Maps the interaction to the custom sender. + * + * @param interaction interaction to map + * @return the mapped sender + */ + @NonNull C map(@NonNull Discord4JInteraction interaction); + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JOptionType.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JOptionType.java new file mode 100644 index 0000000..e017bf9 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JOptionType.java @@ -0,0 +1,79 @@ +// +// 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.discord4j; + +import discord4j.common.util.Snowflake; +import discord4j.core.object.entity.Attachment; +import discord4j.core.object.entity.Role; +import discord4j.core.object.entity.User; +import discord4j.core.object.entity.channel.Channel; +import io.leangen.geantyref.TypeToken; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.slash.DiscordOptionType; + +/** + * Extension of {@link DiscordOptionType} for Discord4J-specific classes. + * + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public final class Discord4JOptionType { + + public static final @NonNull DiscordOptionType USER = DiscordOptionType.of( + "USER", + 6, + TypeToken.get(User.class) + ); + public static final @NonNull DiscordOptionType CHANNEL = DiscordOptionType.of( + "CHANNEL", + 7, + TypeToken.get(Channel.class) + ); + public static final @NonNull DiscordOptionType ROLE = DiscordOptionType.of( + "ROLE", + 8, + TypeToken.get(Role.class) + ); + public static final @NonNull DiscordOptionType MENTIONABLE = DiscordOptionType.of( + "MENTIONABLE", + 9, + TypeToken.get(Snowflake.class) + ); + public static final @NonNull DiscordOptionType ATTACHMENT = DiscordOptionType.of( + "ATTACHMENT", + 11, + TypeToken.get(Attachment.class) + ); + + public static final Collection<@NonNull DiscordOptionType> DISCORD4J_OPTION_TYPES = Collections.unmodifiableCollection( + Arrays.asList(USER, CHANNEL, ROLE, MENTIONABLE, ATTACHMENT) + ); + + private Discord4JOptionType() { + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JParser.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JParser.java new file mode 100644 index 0000000..ff7cf94 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/Discord4JParser.java @@ -0,0 +1,156 @@ +// +// 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.discord4j; + +import cloud.commandframework.arguments.parser.ArgumentParseResult; +import cloud.commandframework.arguments.parser.ParserDescriptor; +import cloud.commandframework.context.CommandContext; +import cloud.commandframework.context.CommandInput; +import discord4j.common.util.Snowflake; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import discord4j.core.object.command.ApplicationCommandInteractionOptionValue; +import discord4j.core.object.entity.Attachment; +import discord4j.core.object.entity.Role; +import discord4j.core.object.entity.User; +import discord4j.core.object.entity.channel.Channel; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import org.apiguardian.api.API; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.incendo.cloud.discord.slash.NullableParser; +import reactor.core.publisher.Mono; + +/** + * A parser which wraps Discord4J options. + * + * @param command sender type + * @param Discord4J type + * @since 1.0.0 + */ +@API(status = API.Status.STABLE, since = "1.0.0") +public final class Discord4JParser extends NullableParser { + + /** + * Creates a new {@link User} parser. + * + * @param command sender type + * @return user parser + */ + public static @NonNull ParserDescriptor userParser() { + return createParser(ApplicationCommandInteractionOptionValue::asUser, User.class); + } + + /** + * Creates a new {@link Role} parser. + * + * @param command sender type + * @return role parser + */ + public static @NonNull ParserDescriptor roleParser() { + return createParser(ApplicationCommandInteractionOptionValue::asRole, Role.class); + } + + /** + * Creates a new {@link Channel} parser. + * + * @param command sender type + * @return channel parser + */ + public static @NonNull ParserDescriptor channelParser() { + return createParser(ApplicationCommandInteractionOptionValue::asChannel, Channel.class); + } + + /** + * Creates a new {@link Snowflake} parser. + * + * @param command sender type + * @return snowflake parser + */ + public static @NonNull ParserDescriptor mentionableParser() { + return createParser( + value -> Mono.fromSupplier(value::asSnowflake), + Snowflake.class + ); + } + + /** + * Creates a new {@link Attachment} parser. + * + * @param command sender type + * @return snowflake parser + */ + public static @NonNull ParserDescriptor attachmentParser() { + return createParser( + value -> Mono.fromSupplier(value::asAttachment), + Attachment.class + ); + } + + private static @NonNull ParserDescriptor createParser( + final @NonNull Function<@NonNull ApplicationCommandInteractionOptionValue, @NonNull Mono> extractor, + final @NonNull Class clazz + ) { + return ParserDescriptor.of(new Discord4JParser<>(extractor), clazz); + } + + private final Function<@NonNull ApplicationCommandInteractionOptionValue, @NonNull Mono> extractor; + + private Discord4JParser( + final @NonNull Function<@NonNull ApplicationCommandInteractionOptionValue, @NonNull Mono> extractor + ) { + this.extractor = extractor; + } + + @Override + public @NonNull CompletableFuture<@Nullable ArgumentParseResult> parseNullable( + final @NonNull CommandContext<@NonNull C> commandContext, + final @NonNull CommandInput commandInput + ) { + final Discord4JInteraction interaction = commandContext.get(Discord4JCommandManager.CONTEXT_DISCORD4J_INTERACTION); + return this.findOption(interaction.commandInteraction().getOptions(), commandInput.readString()) + .flatMap(ApplicationCommandInteractionOption::getValue) + .map(this.extractor) + .map(mono -> mono.map(ArgumentParseResult::success).toFuture()) + .orElseGet(() -> CompletableFuture.completedFuture(null)); + } + + private @NonNull Optional findOption( + final @NonNull List<@NonNull ApplicationCommandInteractionOption> options, + final @NonNull String name + ) { + for (final ApplicationCommandInteractionOption option : options) { + if (option.getName().equalsIgnoreCase(name)) { + return Optional.of(option); + } + final Optional childOption = this.findOption(option.getOptions(), name); + if (childOption.isPresent()) { + return childOption; + } + } + return Optional.empty(); + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/StandardDiscord4JCommandFactory.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/StandardDiscord4JCommandFactory.java new file mode 100644 index 0000000..655afd3 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/StandardDiscord4JCommandFactory.java @@ -0,0 +1,141 @@ +// +// 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.discord4j; + +import cloud.commandframework.CommandTree; +import cloud.commandframework.internal.CommandNode; +import discord4j.discordjson.json.ApplicationCommandOptionChoiceData; +import discord4j.discordjson.json.ApplicationCommandOptionData; +import discord4j.discordjson.json.ApplicationCommandRequest; +import discord4j.discordjson.json.ImmutableApplicationCommandOptionData; +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.stream.Collectors; +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; +import org.incendo.cloud.discord.slash.DiscordOptionChoice; +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") +final class StandardDiscord4JCommandFactory implements Discord4JCommandFactory { + + private final CommandTree commandTree; + private final DiscordCommandFactory discordCommandFactory; + private final NodeProcessor nodeProcessor; + + private CommandScopePredicate commandScopePredicate = CommandScopePredicate.alwaysTrue(); + + StandardDiscord4JCommandFactory(final @NonNull Discord4JCommandManager commandManager) { + this.commandTree = commandManager.commandTree(); + + final OptionRegistry optionRegistry = new StandardOptionRegistry<>(); + optionRegistry + .registerMapping(Discord4JOptionType.USER, Discord4JParser.userParser()) + .registerMapping(Discord4JOptionType.CHANNEL, Discord4JParser.channelParser()) + .registerMapping(Discord4JOptionType.ROLE, Discord4JParser.roleParser()) + .registerMapping(Discord4JOptionType.MENTIONABLE, Discord4JParser.mentionableParser()) + .registerMapping(Discord4JOptionType.ATTACHMENT, Discord4JParser.attachmentParser()); + + this.discordCommandFactory = new StandardDiscordCommandFactory<>(optionRegistry); + + this.nodeProcessor = new NodeProcessor<>(this.commandTree); + } + + @Override + public @NonNull List<@NonNull ApplicationCommandRequest> createCommands(final @NonNull CommandScope scope) { + this.nodeProcessor.prepareTree(); + + final List commands = new ArrayList<>(); + for (final CommandNode rootNode : this.commandTree.rootNodes()) { + final CommandScope rootScope = (CommandScope) rootNode.nodeMeta().get(NodeProcessor.NODE_META_SCOPE); + if (!rootScope.overlaps(scope)) { + continue; + } + + if (!this.commandScopePredicate.test(rootNode, scope)) { + continue; + } + + final DiscordCommand command = this.discordCommandFactory.create(rootNode); + final ApplicationCommandRequest request = ApplicationCommandRequest.builder() + .name(command.name()) + .description(command.description()) + .addAllOptions(this.createOptions(command.options())) + .build(); + commands.add(request); + } + + return commands; + } + + @Override + public void commandScopePredicate(final @NonNull CommandScopePredicate predicate) { + this.commandScopePredicate = Objects.requireNonNull(predicate, "predicate"); + } + + private @NonNull List<@NonNull ApplicationCommandOptionData> createOptions( + final @NonNull List<@NonNull DiscordOption> options + ) { + return options.stream() + .map(this::createOption) + .collect(Collectors.toList()); + } + + private @NonNull ApplicationCommandOptionData createOption(final @NonNull DiscordOption option) { + final ImmutableApplicationCommandOptionData.Builder builder = ApplicationCommandOptionData.builder() + .name(option.name()) + .description(option.description()) + .type(option.type().value()); + + if (option instanceof DiscordOption.SubCommand) { + builder.options(this.createOptions(((DiscordOption.SubCommand) option).options())); + } else if (option instanceof DiscordOption.Variable) { + final DiscordOption.Variable variable = (DiscordOption.Variable) option; + builder.required(variable.required()) + .autocomplete(variable.autocomplete()); + + for (final DiscordOptionChoice choice : variable.choices()) { + builder.addChoice(ApplicationCommandOptionChoiceData.builder() + .name(choice.name()) + .value(choice.value()) + .build()); + } + + if (variable.range() != null) { + builder.minValue(variable.range().min().doubleValue()).maxValue(variable.range().max().doubleValue()); + } + } + + return builder.build(); + } +} diff --git a/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/package-info.java b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/package-info.java new file mode 100644 index 0000000..848e6f2 --- /dev/null +++ b/cloud-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/package-info.java @@ -0,0 +1,4 @@ +/** + * Cloud implementation for Discord4J + */ +package org.incendo.cloud.discord.discord4j; diff --git a/examples/example-discord4j/README.md b/examples/example-discord4j/README.md new file mode 100644 index 0000000..5cc2821 --- /dev/null +++ b/examples/example-discord4j/README.md @@ -0,0 +1,12 @@ +# example-discord4j + +Examples for Discord4J. + +## 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-discord4j:run`. diff --git a/examples/example-discord4j/build.gradle.kts b/examples/example-discord4j/build.gradle.kts new file mode 100644 index 0000000..61a6827 --- /dev/null +++ b/examples/example-discord4j/build.gradle.kts @@ -0,0 +1,16 @@ +plugins { + id("cloud-discord.base-conventions") + application +} + +dependencies { + implementation(projects.cloudDiscord4j) + implementation(libs.cloud.annotations) + implementation(libs.logback.core) + implementation(libs.logback.classic) + implementation(libs.discord4j) +} + +application { + mainClass = "org.incendo.cloud.discord.discord4j.example.ExampleBot" +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/BotConfiguration.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/BotConfiguration.java new file mode 100644 index 0000000..7034fb8 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/BotConfiguration.java @@ -0,0 +1,39 @@ +// +// 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.discord4j.example; + +import org.checkerframework.checker.nullness.qual.NonNull; + +/** + * Configuration for the example bot. + */ +public interface BotConfiguration { + + /** + * Returns the bot token. + * + * @return the token + */ + @NonNull String token(); +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Example.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Example.java new file mode 100644 index 0000000..636a421 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Example.java @@ -0,0 +1,41 @@ +// +// 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.discord4j.example; + +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; + +/** + * An example. + */ +public interface Example { + + /** + * Registers the example. + * + * @param commandManager command manager + */ + void register(@NonNull Discord4JCommandManager commandManager); +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/ExampleBot.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/ExampleBot.java new file mode 100644 index 0000000..9e06554 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/ExampleBot.java @@ -0,0 +1,71 @@ +// +// 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.discord4j.example; + +import cloud.commandframework.execution.ExecutionCoordinator; +import discord4j.core.DiscordClient; +import java.io.File; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Example Discord bot using cloud-discord4j + */ +public final class ExampleBot { + + private static final Logger LOGGER = LoggerFactory.getLogger(ExampleBot.class); + + /** + * Launches the bot. + * + * @param args ignored args + */ + public static void main(final @NonNull String@NonNull[] args) { + new ExampleBot(); + } + + private final Discord4JCommandManager commandManager; + private final BotConfiguration botConfiguration; + private final DiscordClient discordClient; + + private ExampleBot() { + LOGGER.info("Loading configuration..."); + this.botConfiguration = new PropertiesBotConfiguration(new File("bot.properties")); + + LOGGER.info("Creating command manager..."); + this.commandManager = new Discord4JCommandManager<>( + ExecutionCoordinator.simpleCoordinator(), + Discord4JInteraction.InteractionMapper.identity() + ); + + new Examples(this.commandManager).registerExamples(); + + LOGGER.info("Starting Discord4J..."); + this.discordClient = DiscordClient.create(this.botConfiguration.token()); + this.discordClient.withGateway(this.commandManager::installEventListener).block(); + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Examples.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Examples.java new file mode 100644 index 0000000..34c27d2 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/Examples.java @@ -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.discord4j.example; + +import java.util.Arrays; +import java.util.List; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; +import org.incendo.cloud.discord.discord4j.example.commands.AggregateCommand; +import org.incendo.cloud.discord.discord4j.example.commands.AnnotatedCommands; +import org.incendo.cloud.discord.discord4j.example.commands.PingCommand; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Class that registers the examples. + * + *

You can find the active examples in {@link #examples}.

+ */ +public final class Examples { + + private static final Logger LOGGER = LoggerFactory.getLogger(Examples.class); + + private final Discord4JCommandManager commandManager; + private final List examples = Arrays.asList( + new AggregateCommand(), + new AnnotatedCommands(), + new PingCommand() + ); + + /** + * Creates a new example instance. + * + * @param commandManager command manager + */ + public Examples(final @NonNull Discord4JCommandManager commandManager) { + this.commandManager = commandManager; + } + + /** + * Registers the example commands. + */ + public void registerExamples() { + LOGGER.info("Registering examples:"); + for (final Example example : this.examples) { + LOGGER.info("- Registering example: {}", example.getClass().getSimpleName()); + example.register(this.commandManager); + } + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/PropertiesBotConfiguration.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/PropertiesBotConfiguration.java new file mode 100644 index 0000000..aea00e3 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/PropertiesBotConfiguration.java @@ -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.discord4j.example; + +import java.io.File; +import java.io.FileReader; +import java.util.Objects; +import java.util.Properties; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of {@link BotConfiguration} that is backed by {@link Properties}. + */ +final class PropertiesBotConfiguration implements BotConfiguration { + + private static final Logger LOGGER = LoggerFactory.getLogger(PropertiesBotConfiguration.class); + + private final Properties properties; + + /** + * Creates a new properties instance. + * + * @param properties properties + */ + PropertiesBotConfiguration(final @NonNull Properties properties) { + this.properties = properties; + } + + /** + * Creates a new configuration instance. + * + * @param file file to read from + */ + PropertiesBotConfiguration(final @NonNull File file) { + this(new Properties()); + try (FileReader reader = new FileReader(file)) { + this.properties.load(reader); + } catch (final Exception e) { + LOGGER.error("Failed to load bot.properties", e); + } + } + + @Override + public @NonNull String token() { + return Objects.requireNonNull(this.properties.getProperty("token"), "missing property: token"); + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AggregateCommand.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AggregateCommand.java new file mode 100644 index 0000000..e15b0a2 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AggregateCommand.java @@ -0,0 +1,88 @@ +// +// 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.discord4j.example.commands; + +import cloud.commandframework.CommandManager; +import cloud.commandframework.Description; +import cloud.commandframework.arguments.aggregate.AggregateCommandParser; +import cloud.commandframework.arguments.parser.ArgumentParseResult; +import discord4j.core.object.entity.User; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.immutables.value.Value; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; +import org.incendo.cloud.discord.discord4j.Discord4JParser; +import org.incendo.cloud.discord.discord4j.example.Example; +import org.incendo.cloud.discord.immutables.ImmutableImpl; + +import static cloud.commandframework.arguments.standard.IntegerParser.integerParser; +import static org.incendo.cloud.discord.discord4j.Discord4JCommandExecutionHandler.reactiveHandler; + +/** + * Example showcasing aggregate parsers. + */ +public final class AggregateCommand implements Example { + + @Override + public void register(final @NonNull Discord4JCommandManager commandManager) { + final AggregateCommandParser hugParser = AggregateCommandParser.builder() + .withComponent("recipient", Discord4JParser.userParser()) + .withComponent("number", integerParser(1, 100)) + .withDirectMapper(Hug.class, (cmdCtx, ctx) -> ArgumentParseResult.success( + HugImpl.of(ctx.get("recipient"), ctx.get("number")) + )).build(); + final CommandManager command = commandManager.command( + commandManager.commandBuilder("hug", Description.of("Hug someone")) + .required("hug", hugParser) + .handler(reactiveHandler(context -> { + final Discord4JInteraction interaction = context.sender(); + final Hug hug = context.get("hug"); + + return interaction.commandEvent() + .get() + .reply("You hug " + hug.recipient().getMention() + " " + hug.number() + " time(s)!"); + })) + ); + } + + + @ImmutableImpl + @Value.Immutable + interface Hug { + + /** + * Returns the recipient of the hugs. + * + * @return hug recipient + */ + @NonNull User recipient(); + + /** + * Returns the number of hugs. + * + * @return the number of hugs + */ + int number(); + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AnnotatedCommands.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AnnotatedCommands.java new file mode 100644 index 0000000..7472762 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/AnnotatedCommands.java @@ -0,0 +1,228 @@ +// +// 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.discord4j.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 discord4j.common.util.Snowflake; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.immutables.value.Value; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; +import org.incendo.cloud.discord.discord4j.example.Example; +import org.incendo.cloud.discord.immutables.ImmutableImpl; +import org.incendo.cloud.discord.slash.annotations.CommandScopeBuilderModifier; + +/** + * Example showcasing how to use cloud-annotations with cloud-Discord4J. + */ +public final class AnnotatedCommands implements Example { + + private final CatRepository catRepository = new CatRepositoryImpl(); + + @Override + public void register(final @NonNull Discord4JCommandManager commandManager) { + final AnnotationParser annotationParser = new AnnotationParser<>( + commandManager, + Discord4JInteraction.class + ); + + // Adds support for the Discord4J-specific annotations. + CommandScopeBuilderModifier.install(annotationParser); + + // Parses @Command, @Parser, @Suggestions & @ExceptionHandler... + annotationParser.parse(this); + } + + /** + * Adds a cat to the cat registry. + * + * @param interaction command trigger + * @param name name of the cat to add + * @param age age of the cat + * @return future that completes when the interaction is done + */ + @Command("cat add ") + public @NonNull CompletableFuture addCat( + final @NonNull Discord4JInteraction interaction, + @Completions("Cat,Benny,Meowy") final @NonNull String name, + @Range(min = "0", max = "20") final int age + ) { + this.catRepository.addCat(CatImpl.of(name, age)); + return interaction.commandEvent() + .get() + .reply(String.format("Added the cat named %s with age %d", name, age)) + .withEphemeral(true) + .toFuture(); + } + + /** + * Removes the cat from the cat registry. + * + * @param interaction command trigger + * @param name name of the cat to remove + * @return future that completes when the interaction is done + */ + @Command("cat remove ") + public @NonNull CompletableFuture removeCat( + final @NonNull Discord4JInteraction interaction, + @Argument(suggestions = "cats") final @NonNull String name + ) { + this.catRepository.removeCat(name); + return interaction.commandEvent() + .get() + .reply(String.format("Removed the cat named %s", name)) + .withEphemeral(true) + .toFuture(); + } + + /** + * Lists the cats in the repository. + * + * @param interaction command trigger + * @return future that completes when the interaction is done + */ + @Command("cat list") + public @NonNull CompletableFuture listCats(final @NonNull Discord4JInteraction interaction) { + final String cats = this.catRepository.cats() + .stream() + .map(Cat::name) + .collect(Collectors.joining(", ")); + return interaction.commandEvent() + .get() + .reply(String.format("Cats: %s", cats)) + .withEphemeral(true) + .toFuture(); + } + + /** + * Makes a cat meow at someone. + * + * @param interaction command trigger + * @param cat cat that should meow + * @param target meow target + * @return future that completes when the interaction is done + */ + @Command("cat meow ") + public @NonNull CompletableFuture meow( + final @NonNull Discord4JInteraction interaction, + @Argument(suggestions = "cats") final @NonNull String cat, + final @NonNull Snowflake target + ) { + return interaction.commandEvent() + .get() + .reply(cat + " meows at <@" + target.asLong() + ">") + .toFuture(); + } + + /** + * Returns suggestions based on the cats in the cat registry. + * + * @return the suggestions + */ + @Suggestions("cats") + public @NonNull Stream<@NonNull Suggestion> catNames() { + return this.catRepository.cats() + .stream() + .map(Cat::name) + .map(Suggestion::simple); + } + + + @ImmutableImpl + @Value.Immutable + interface Cat { + + /** + * Returns the name of the cat. + * + * @return cat name + */ + @NonNull String name(); + + /** + * Returns the age of the cat. + * + * @return cat age + */ + int age(); + } + + + private interface CatRepository { + + /** + * Adds the cat. + * + * @param cat cat to add + */ + void addCat(@NonNull Cat cat); + + /** + * Removes the cat with the given {@code name}. + * + * @param name name of the cat to remove + */ + void removeCat(@NonNull String name); + + /** + * Returns an immutable view of the cats. + * + * @return the cats + */ + @NonNull Collection<@NonNull Cat> cats(); + } + + private static final class CatRepositoryImpl implements CatRepository { + + private final Map catMap = new HashMap<>(); + + @Override + public void addCat(final @NonNull Cat cat) { + this.catMap.put(cat.name(), cat); + } + + @Override + public void removeCat(final @NonNull String name) { + this.catMap.remove(name); + } + + @Override + public @NonNull Collection<@NonNull Cat> cats() { + return Collections.unmodifiableCollection(this.catMap.values()); + } + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/PingCommand.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/PingCommand.java new file mode 100644 index 0000000..447e4b1 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/PingCommand.java @@ -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.discord4j.example.commands; + +import cloud.commandframework.Description; +import cloud.commandframework.keys.CloudKey; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.incendo.cloud.discord.discord4j.Discord4JCommandManager; +import org.incendo.cloud.discord.discord4j.Discord4JInteraction; +import org.incendo.cloud.discord.discord4j.example.Example; +import org.incendo.cloud.discord.slash.CommandScope; +import reactor.core.publisher.Mono; + +import static cloud.commandframework.arguments.standard.StringParser.greedyStringParser; +import static org.incendo.cloud.discord.discord4j.Discord4JCommandExecutionHandler.reactiveHandler; + +/** + * Example of a command that responds with the original input. + */ +public final class PingCommand implements Example { + + private static final CloudKey COMPONENT_MESSAGE = CloudKey.of( + "message", + String.class + ); + + @Override + public void register(final @NonNull Discord4JCommandManager commandManager) { + commandManager.command( + commandManager.commandBuilder("ping", Description.of("A ping command")) + .apply(CommandScope.guilds()) // You may only ping in guilds! + .required(COMPONENT_MESSAGE, greedyStringParser(), Description.of("The message")) + .handler(reactiveHandler(context -> { + final Discord4JInteraction interaction = context.sender(); + final String message = context.get(COMPONENT_MESSAGE); + return interaction.commandEvent() + .map(event -> (Mono) event.reply(message).withEphemeral(true)) + .orElseGet(Mono::empty); + })) + ); + } +} diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/package-info.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/package-info.java new file mode 100644 index 0000000..0c0dc18 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/commands/package-info.java @@ -0,0 +1,4 @@ +/** + * Example commands. + */ +package org.incendo.cloud.discord.discord4j.example.commands; diff --git a/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/package-info.java b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/package-info.java new file mode 100644 index 0000000..37cafe0 --- /dev/null +++ b/examples/example-discord4j/src/main/java/org/incendo/cloud/discord/discord4j/example/package-info.java @@ -0,0 +1,4 @@ +/** + * Example showcasing different cloud-discord4j features. + */ +package org.incendo.cloud.discord.discord4j.example; diff --git a/examples/example-discord4j/src/main/resources/logback.xml b/examples/example-discord4j/src/main/resources/logback.xml new file mode 100644 index 0000000..12da827 --- /dev/null +++ b/examples/example-discord4j/src/main/resources/logback.xml @@ -0,0 +1,10 @@ + + + + [%d{HH:mm:ss}][%-5level][%-30logger{0}]: %msg%n + + + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37f70c9..47411d9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ jda5 = "5.0.0-beta.20" logback = "1.4.14" kord = "0.13.1" kotlinLogging = "6.0.3" +discord4j = "3.2.6" # Test jupiterEngine = "5.10.1" @@ -53,6 +54,7 @@ logback-core = { group = "ch.qos.logback", name = "logback-core", version.ref = 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" } +discord4j = { group = "com.discord4j", name = "discord4j-core", version.ref = "discord4j" } # Kotlin coroutinesCore = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "coroutines" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 0081de5..e3e573c 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -31,11 +31,15 @@ dependencyResolutionManagement { rootProject.name = "cloud-discord" include(":cloud-discord-common") + +include(":cloud-discord4j") include(":cloud-javacord") include(":cloud-jda") include(":cloud-jda5") include(":cloud-kord") +include("examples/example-discord4j") +findProject(":examples/example-discord4j")?.name = "example-discord4j" include("examples/example-jda5") findProject(":examples/example-jda5")?.name = "example-jda5" include("examples/example-kord")