From ad19ed6adac5d92aa62a18c5e9d255cfcffe70b6 Mon Sep 17 00:00:00 2001 From: tueem Date: Mon, 17 Feb 2025 20:13:31 +0100 Subject: [PATCH] add First prototype of Discord4J wrapper --- gradle/libs.versions.toml | 2 + settings.gradle.kts | 4 + wrapper/discord4j/build.gradle.kts | 41 ++++ .../Discord4JContextObjectProvider.java | 102 +++++++++ .../wrapper/discord4j/Discord4JWrapper.java | 201 ++++++++++++++++++ 5 files changed, 350 insertions(+) create mode 100644 wrapper/discord4j/build.gradle.kts create mode 100644 wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JContextObjectProvider.java create mode 100644 wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JWrapper.java diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index de79cc7..0d4720c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,10 +5,12 @@ junit-jupiter = "5.10.2" log4j = "2.24.1" javacord = "3.8.0" +discord4j = "3.2.7" geantyref = "2.0.0" [libraries] junit-jupiter = { module = "org.junit.jupiter:junit-jupiter", version.ref = "junit-jupiter" } log4j = { module = "org.apache.logging.log4j:log4j-api", version.ref = "log4j"} javacord = { module = "org.javacord:javacord", version.ref = "javacord"} +discord4j = { module = "com.discord4j:discord4j-core", version.ref = "discord4j"} geantyref = { module = "io.leangen.geantyref:geantyref", version.ref = "geantyref"} diff --git a/settings.gradle.kts b/settings.gradle.kts index 06c8680..d885dc5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -13,5 +13,9 @@ plugins { rootProject.name = "Marinara" include(":lib") include(":wrapper-javacord") +include(":wrapper-discord4j") + project(":wrapper-javacord").projectDir = file("wrapper/javacord") +project(":wrapper-discord4j").projectDir = file("wrapper/discord4j") + diff --git a/wrapper/discord4j/build.gradle.kts b/wrapper/discord4j/build.gradle.kts new file mode 100644 index 0000000..49da9dc --- /dev/null +++ b/wrapper/discord4j/build.gradle.kts @@ -0,0 +1,41 @@ +/* + * This file was generated by the Gradle 'init' task. + * + * This generated file contains a sample Java library project to get you started. + * For more details on building Java & JVM projects, please refer to https://docs.gradle.org/8.8/userguide/building_java_projects.html in the Gradle documentation. + */ + +plugins { + // Apply the java-library plugin for API and implementation separation. + `java-library` +} + +repositories { + // Use Maven Central for resolving dependencies. + mavenCentral() +} + +dependencies { + // Use JUnit Jupiter for testing. + testImplementation(libs.junit.jupiter) + + testRuntimeOnly("org.junit.platform:junit-platform-launcher") + implementation(libs.log4j) + implementation(libs.discord4j) { + exclude(module="discord4j-voice") + } + implementation(libs.geantyref) + implementation(project(":lib")) +} + +// Apply a specific Java toolchain to ease working on different environments. +java { + toolchain { + languageVersion = JavaLanguageVersion.of(23) + } +} + +tasks.named("test") { + // Use JUnit Platform for unit tests. + useJUnitPlatform() +} diff --git a/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JContextObjectProvider.java b/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JContextObjectProvider.java new file mode 100644 index 0000000..82b42ff --- /dev/null +++ b/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JContextObjectProvider.java @@ -0,0 +1,102 @@ +package net.tomatentum.marinara.wrapper.discord4j; + +import java.util.List; + +import discord4j.core.event.domain.interaction.ChatInputAutoCompleteEvent; +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.event.domain.interaction.ComponentInteractionEvent; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import net.tomatentum.marinara.interaction.commands.option.SlashCommandOptionType; +import net.tomatentum.marinara.wrapper.ContextObjectProvider; + +public class Discord4JContextObjectProvider implements ContextObjectProvider { + + @Override + public Object convertCommandOption(Object context, String optionName) { + if (!(context instanceof ChatInputInteractionEvent)) + return null; + ChatInputInteractionEvent interactionEvent = (ChatInputInteractionEvent) context; + + List subOptions = Discord4JWrapper.SUB_FILTER.apply(interactionEvent.getOptions()); + + if (subOptions.isEmpty()) + return getOptionValue(interactionEvent.getOption(optionName).get()); + + + ApplicationCommandInteractionOption subCommandOption = interactionEvent.getOptions().getFirst(); + subOptions = Discord4JWrapper.SUB_FILTER.apply(subCommandOption.getOptions()); + + if (!subOptions.isEmpty()) + subCommandOption = subOptions.getFirst(); + + return getOptionValue(interactionEvent.getOption(optionName).get()); + + } + + private Object getOptionValue(ApplicationCommandInteractionOption option) { + if (!option.getValue().isPresent()) + return null; + SlashCommandOptionType type = getOptionType(option); + + switch (type) { + case ATTACHMENT: + return option.getValue().get().asAttachment(); + case BOOLEAN: + return option.getValue().get().asBoolean(); + case CHANNEL: + return option.getValue().get().asChannel(); + case DOUBLE: + return option.getValue().get().asDouble(); + case INTEGER: + return option.getValue().get().asLong(); + case MENTIONABLE: + return option.getValue().get().asSnowflake(); + case ROLE: + return option.getValue().get().asRole(); + case STRING: + return option.getValue().get().asString(); + case USER: + return option.getValue().get().asUser(); + default: + return null; + } + } + + private SlashCommandOptionType getOptionType(ApplicationCommandInteractionOption option) { + return SlashCommandOptionType.fromValue(option.getType().getValue()); + } + + @Override + public Object getComponentContextObject(Object context, Class type) { + ComponentInteractionEvent componentInteractionEvent = (ComponentInteractionEvent) context; + switch (type.getName()) { + case "discord4j.core.object.entity.Message": + return componentInteractionEvent.getMessage(); + default: + return getInteractionContextObject(context, type); + } + } + + @Override + public Object getInteractionContextObject(Object context, Class type) { + ComponentInteractionEvent componentInteractionEvent = (ComponentInteractionEvent) context; + switch (type.getName()) { + case "discord4j.core.object.entity.channel.MessageChannel": + return componentInteractionEvent.getInteraction().getChannel().block(); + case "discord4j.core.object.entity.Guild": + return componentInteractionEvent.getInteraction().getGuild().block(); + case "discord4j.core.object.entity.Member": + return componentInteractionEvent.getInteraction().getMember().orElse(null); + case "discord4j.core.object.entity.User": + return componentInteractionEvent.getInteraction().getUser(); + } + return null; + } + + @Override + public Object getAutocompleteFocusedOption(Object context) { + ChatInputAutoCompleteEvent interaction = (ChatInputAutoCompleteEvent) context; + return getOptionValue(interaction.getFocusedOption()); + } + +} diff --git a/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JWrapper.java b/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JWrapper.java new file mode 100644 index 0000000..4471161 --- /dev/null +++ b/wrapper/discord4j/src/main/java/net/tomatentum/marinara/wrapper/discord4j/Discord4JWrapper.java @@ -0,0 +1,201 @@ +package net.tomatentum.marinara.wrapper.discord4j; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.function.Function; + +import org.apache.logging.log4j.Logger; + +import discord4j.core.GatewayDiscordClient; +import discord4j.core.event.domain.interaction.ButtonInteractionEvent; +import discord4j.core.event.domain.interaction.ChatInputAutoCompleteEvent; +import discord4j.core.event.domain.interaction.ChatInputInteractionEvent; +import discord4j.core.event.domain.interaction.InteractionCreateEvent; +import discord4j.core.object.command.ApplicationCommandInteractionOption; +import discord4j.core.object.command.ApplicationCommandOption.Type; +import discord4j.discordjson.json.ApplicationCommandOptionChoiceData; +import discord4j.discordjson.json.ApplicationCommandOptionData; +import discord4j.discordjson.json.ApplicationCommandRequest; + +import io.leangen.geantyref.AnnotationFormatException; +import io.leangen.geantyref.TypeFactory; +import net.tomatentum.marinara.interaction.InteractionType; +import net.tomatentum.marinara.interaction.commands.ExecutableSlashCommandDefinition; +import net.tomatentum.marinara.interaction.commands.SlashCommandDefinition; +import net.tomatentum.marinara.interaction.commands.annotation.SlashCommand; +import net.tomatentum.marinara.interaction.commands.annotation.SlashCommandOption; +import net.tomatentum.marinara.interaction.commands.annotation.SlashCommandOptionChoice; +import net.tomatentum.marinara.interaction.commands.annotation.SubCommand; +import net.tomatentum.marinara.interaction.commands.annotation.SubCommandGroup; +import net.tomatentum.marinara.util.LoggerUtil; +import net.tomatentum.marinara.wrapper.ContextObjectProvider; +import net.tomatentum.marinara.wrapper.LibraryWrapper; +import reactor.core.publisher.Mono; + +public class Discord4JWrapper extends LibraryWrapper { + + public static final Function, List> SUB_FILTER = (i) -> + i.stream() + .filter(o -> o.getType().equals(Type.SUB_COMMAND) || o.getType().equals(Type.SUB_COMMAND_GROUP)) + .toList(); + + public static final Function, List> ARG_FILTER = (i) -> + i.stream() + .filter(o -> !o.getType().equals(Type.SUB_COMMAND) && !o.getType().equals(Type.SUB_COMMAND_GROUP)) + .toList(); + + private GatewayDiscordClient api; + private Discord4JContextObjectProvider contextObjectProvider; + + private Logger logger = LoggerUtil.getLogger(getClass()); + + public Discord4JWrapper(GatewayDiscordClient api) { + this.api = api; + this.contextObjectProvider = new Discord4JContextObjectProvider(); + api.on(InteractionCreateEvent.class) + .subscribe(event -> handleInteraction(event)); + Mono.just("test").subscribe(logger::debug); + + logger.info("Discord4J wrapper loaded!"); + } + + @Override + public InteractionType getInteractionType(Object context) { + if (ChatInputAutoCompleteEvent.class.isAssignableFrom(context.getClass())) + return InteractionType.AUTOCOMPLETE; + if (ChatInputInteractionEvent.class.isAssignableFrom(context.getClass())) + return InteractionType.COMMAND; + if (ButtonInteractionEvent.class.isAssignableFrom(context.getClass())) + return InteractionType.BUTTON; + + return null; + } + + @Override + public void registerSlashCommands(SlashCommandDefinition[] defs) { + HashMap> serverCommands = new HashMap<>(); + List globalCommands = new ArrayList<>(); + long applicationId = api.getRestClient().getApplicationId().block(); + + for (SlashCommandDefinition slashCommandDefinition : defs) { + ApplicationCommandRequest request = convertSlashCommand(slashCommandDefinition); + if (slashCommandDefinition.getFullSlashCommand().serverIds().length > 0) { + for (long serverId : slashCommandDefinition.getFullSlashCommand().serverIds()) { + serverCommands.putIfAbsent(serverId, new ArrayList<>()); + serverCommands.get(serverId).add(request); + } + }else + globalCommands.add(request); + } + + for (long serverId : serverCommands.keySet()) { + api.getRestClient().getApplicationService().bulkOverwriteGuildApplicationCommand(applicationId, serverId, serverCommands.get(serverId)); + } + api.getRestClient().getApplicationService().bulkOverwriteGlobalApplicationCommand(applicationId, globalCommands); + } + + @Override + public ExecutableSlashCommandDefinition getCommandDefinition(Object context) { + if (!(context instanceof ChatInputInteractionEvent)) + return null; + + ChatInputInteractionEvent interaction = (ChatInputInteractionEvent) context; + ExecutableSlashCommandDefinition.Builder builder = new ExecutableSlashCommandDefinition.Builder(); + List options = SUB_FILTER.apply(interaction.getOptions()); + + try { + builder.setApplicationCommand(TypeFactory.annotation(SlashCommand.class, Map.of("name", interaction.getCommandName()))); + if (!options.isEmpty()) { + if (!ARG_FILTER.apply(options.getFirst().getOptions()).isEmpty()) { + builder.setSubCommandGroup(TypeFactory.annotation(SubCommandGroup.class, Map.of("name", options.getFirst().getName()))); + builder.setSubCommand(TypeFactory.annotation(SubCommand.class, Map.of("name", SUB_FILTER.apply(options.getFirst().getOptions()).getFirst().getName()))); + }else + builder.setSubCommand(TypeFactory.annotation(SubCommand.class, Map.of("name", options.getFirst().getName()))); + } + } catch (AnnotationFormatException e) { + logger.fatal(e); + } + + return builder.build(); + } + + private ApplicationCommandRequest convertSlashCommand(SlashCommandDefinition def) { + List options = new ArrayList<>(); + SlashCommand cmd = def.getFullSlashCommand(); + if (!def.isRootCommand()) { + Arrays.stream(def.getSubCommands(null)).map(this::convertSubCommandDef).forEach(options::add); + Arrays.stream(def.getSubCommandGroups()).map((x) -> convertSubCommandGroupDef(def, x)).forEach(options::add); + }else { + Arrays.stream(cmd.options()).map(this::convertOptionDef).forEach(options::add); + } + + return ApplicationCommandRequest.builder() + .name(cmd.name()) + .description(cmd.description()) + .options(options) + .build(); + } + + private ApplicationCommandOptionData convertSubCommandGroupDef(SlashCommandDefinition def, SubCommandGroup subGroup) { + SubCommand[] subCommands = def.getSubCommands(subGroup.name()); + List convertedSubCommands = Arrays.stream(subCommands).map(this::convertSubCommandDef).toList(); + return ApplicationCommandOptionData.builder() + .type(Type.SUB_COMMAND_GROUP.getValue()) + .name(subGroup.name()) + .description(subGroup.description()) + .options(convertedSubCommands) + .build(); + } + + private ApplicationCommandOptionData convertSubCommandDef(SubCommand sub) { + List convertedOptions = Arrays.stream(sub.options()).map(this::convertOptionDef).toList(); + return ApplicationCommandOptionData.builder() + .type(Type.SUB_COMMAND_GROUP.getValue()) + .name(sub.name()) + .description(sub.description()) + .options(convertedOptions) + .build(); + } + + private ApplicationCommandOptionData convertOptionDef(SlashCommandOption option) { + Type type = Enum.valueOf(Type.class, option.type().toString()); + return ApplicationCommandOptionData.builder() + .type(type.getValue()) + .name(option.name()) + .description(option.description()) + .required(option.required()) + .autocomplete(option.autocomplete()) + .choices(convertChoices(option)) + .build(); + } + + private List convertChoices(SlashCommandOption option) { + List convertedChoices = new ArrayList<>(); + for (SlashCommandOptionChoice choice : ExecutableSlashCommandDefinition.getActualChoices(option)) { + var builder = ApplicationCommandOptionChoiceData.builder(); + builder.name(choice.name()); + if (choice.longValue() != Long.MAX_VALUE) + builder.value(choice.longValue()); + if (choice.doubleValue() != Double.MAX_VALUE) + builder.value(choice.doubleValue()); + if (!choice.stringValue().isEmpty()) + builder.value(choice.stringValue()); + } + return convertedChoices; + } + + @Override + public String getButtonId(Object context) { + ButtonInteractionEvent button = (ButtonInteractionEvent) context; + return button.getCustomId(); + } + + @Override + public ContextObjectProvider getContextObjectProvider() { + return this.contextObjectProvider; + } + +}