diff --git a/application/config.json.template b/application/config.json.template index 3f9262c32b..02c6027d9e 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -90,4 +90,9 @@ "logErrorChannelWebhook": "", "openaiApiKey": "", "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" + "jshell": { + "baseUrl": "", + "rateLimitWindowSeconds": 10, + "rateLimitRequestsInWindow": 3 + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/Config.java b/application/src/main/java/org/togetherjava/tjbot/config/Config.java index f314b129b3..f16aed61b4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -38,6 +38,7 @@ public final class Config { private final String logErrorChannelWebhook; private final String openaiApiKey; private final String sourceCodeBaseUrl; + private final JShellConfig jshell; @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) @@ -74,7 +75,8 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "logErrorChannelWebhook", required = true) String logErrorChannelWebhook, @JsonProperty(value = "openaiApiKey", required = true) String openaiApiKey, - @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl) { + @JsonProperty(value = "sourceCodeBaseUrl", required = true) String sourceCodeBaseUrl, + @JsonProperty(value = "jshell", required = true) JShellConfig jshell) { this.token = Objects.requireNonNull(token); this.gistApiKey = Objects.requireNonNull(gistApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -99,6 +101,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, this.logErrorChannelWebhook = Objects.requireNonNull(logErrorChannelWebhook); this.openaiApiKey = Objects.requireNonNull(openaiApiKey); this.sourceCodeBaseUrl = Objects.requireNonNull(sourceCodeBaseUrl); + this.jshell = Objects.requireNonNull(jshell); } /** @@ -330,4 +333,13 @@ public String getOpenaiApiKey() { public String getSourceCodeBaseUrl() { return sourceCodeBaseUrl; } + + /** + * The configuration about jshell REST API and command/code action settings. + * + * @return the jshell configuration + */ + public JShellConfig getJshell() { + return jshell; + } } diff --git a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java new file mode 100644 index 0000000000..91b85eb2d8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.config; + + +import org.togetherjava.tjbot.features.utils.RateLimiter; + +import java.util.Objects; + +/** + * JShell config. + * + * @param baseUrl the base url of the JShell REST API + * @param rateLimitWindowSeconds the number of seconds of the {@link RateLimiter rate limiter} for + * jshell commands and code actions + * @param rateLimitRequestsInWindow the number of requests of the {@link RateLimiter rate limiter} + * for jshell commands and code actions + */ +public record JShellConfig(String baseUrl, int rateLimitWindowSeconds, + int rateLimitRequestsInWindow) { + /** + * Creates a JShell config. + * + * @param baseUrl the base url of the JShell REST API, must be not null + * @param rateLimitWindowSeconds the number of seconds of the {@link RateLimiter rate limiter} + * for jshell commands and code actions, must be higher than 0 + * @param rateLimitRequestsInWindow the number of requests of the {@link RateLimiter rate + * limiter} for jshell commands and code actions, must be higher than 0 + */ + public JShellConfig { + Objects.requireNonNull(baseUrl); + if (rateLimitWindowSeconds < 0) { + throw new IllegalArgumentException( + "Illegal rateLimitWindowSeconds : " + rateLimitWindowSeconds); + } + if (rateLimitRequestsInWindow < 0) { + throw new IllegalArgumentException( + "Illegal rateLimitRequestsInWindow : " + rateLimitRequestsInWindow); + } + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/Features.java b/application/src/main/java/org/togetherjava/tjbot/features/Features.java index 7f1f3af046..3210269955 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -13,6 +13,8 @@ import org.togetherjava.tjbot.features.code.CodeMessageManualDetection; import org.togetherjava.tjbot.features.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.features.help.*; +import org.togetherjava.tjbot.features.jshell.JShellCommand; +import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; @@ -67,13 +69,15 @@ private Features() { * @return a collection of all features */ public static Collection createFeatures(JDA jda, Database database, Config config) { + JShellEval jshellEval = new JShellEval(config.getJshell()); + TagSystem tagSystem = new TagSystem(database); BookmarksSystem bookmarksSystem = new BookmarksSystem(config, database); ModerationActionsStore actionsStore = new ModerationActionsStore(database); ModAuditLogWriter modAuditLogWriter = new ModAuditLogWriter(config); ScamHistoryStore scamHistoryStore = new ScamHistoryStore(database); HelpSystemHelper helpSystemHelper = new HelpSystemHelper(config, database); - CodeMessageHandler codeMessageHandler = new CodeMessageHandler(); + CodeMessageHandler codeMessageHandler = new CodeMessageHandler(jshellEval); ChatGptService chatGptService = new ChatGptService(config); // NOTE The system can add special system relevant commands also by itself, @@ -143,6 +147,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new ReportCommand(config)); features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService)); + features.add(new JShellCommand(jshellEval)); return features; } } diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java index 3073d665ee..a26cde9f1c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/CodeMessageHandler.java @@ -20,6 +20,7 @@ import org.togetherjava.tjbot.features.UserInteractor; import org.togetherjava.tjbot.features.componentids.ComponentIdGenerator; import org.togetherjava.tjbot.features.componentids.ComponentIdInteractor; +import org.togetherjava.tjbot.features.jshell.JShellEval; import org.togetherjava.tjbot.features.utils.CodeFence; import org.togetherjava.tjbot.features.utils.MessageUtils; @@ -62,11 +63,14 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. + * + * @param jshellEval used to execute java code and build visual result */ - public CodeMessageHandler() { + public CodeMessageHandler(JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); - List codeActions = List.of(new FormatCodeCommand()); + List codeActions = + List.of(new FormatCodeCommand(), new EvalCodeCommand(jshellEval)); labelToCodeAction = codeActions.stream() .collect(Collectors.toMap(CodeAction::getLabel, Function.identity(), (x, y) -> y, diff --git a/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java new file mode 100644 index 0000000000..ad6cc91a3c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -0,0 +1,45 @@ +package org.togetherjava.tjbot.features.code; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; + +import org.togetherjava.tjbot.features.jshell.JShellEval; +import org.togetherjava.tjbot.features.utils.CodeFence; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +/** + * Evaluates the given code with jshell. + *

+ * It will not work of the code isn't valid java or jshell compatible code. + */ +final class EvalCodeCommand implements CodeAction { + private final JShellEval jshellEval; + + EvalCodeCommand(JShellEval jshellEval) { + this.jshellEval = jshellEval; + } + + @Override + public String getLabel() { + return "Run code"; + } + + @Override + public MessageEmbed apply(CodeFence codeFence) { + if (codeFence.code().isEmpty()) { + return new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("There is nothing to evaluate") + .build(); + } + try { + return jshellEval.evaluateAndRespond(null, codeFence.code(), false, false); + } catch (RequestFailedException | ConnectionFailedException e) { + return new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("Request failed: " + e.getMessage()) + .build(); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java new file mode 100644 index 0000000000..44bb6ba37f --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -0,0 +1,319 @@ +package org.togetherjava.tjbot.features.jshell; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.interaction.ModalInteractionEvent; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.callbacks.IReplyCallback; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import net.dv8tion.jda.api.interactions.commands.build.SubcommandData; +import net.dv8tion.jda.api.interactions.components.Modal; +import net.dv8tion.jda.api.interactions.components.text.TextInput; +import net.dv8tion.jda.api.interactions.components.text.TextInputStyle; +import net.dv8tion.jda.api.interactions.modals.ModalMapping; +import net.dv8tion.jda.api.utils.FileUpload; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.jshell.backend.JShellApi; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.MessageUtils; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +import javax.annotation.Nullable; + +import java.util.List; +import java.util.Objects; + +/** + * The JShell command AKA {@code /jshell}, provide functionalities to create JShell sessions, + * evaluate code, etc. + *

+ * Example: {@code /jshell eval code:2+2} + */ +public class JShellCommand extends SlashCommandAdapter { + private static final String TEXT_INPUT_PART_ID = "jshell"; + private static final String JSHELL_COMMAND = "jshell"; + private static final String VERSION_SUBCOMMAND = "version"; + private static final String EVAL_SUBCOMMAND = "eval"; + private static final String SNIPPETS_SUBCOMMAND = "snippets"; + private static final String CLOSE_SUBCOMMAND = "shutdown"; + private static final String STARTUP_SCRIPT_SUBCOMMAND = "startup-script"; + private static final String CODE_PARAMETER = "code"; + private static final String STARTUP_SCRIPT_PARAMETER = "startup-script"; + private static final String USER_PARAMETER = "user"; + private static final String INCLUDE_STARTUP_SCRIPT_PARAMETER = "include-startup-script"; + + private static final int MIN_MESSAGE_INPUT_LENGTH = 0; + private static final int MAX_MESSAGE_INPUT_LENGTH = TextInput.MAX_VALUE_LENGTH; + + private final JShellEval jshellEval; + + /** + * Creates an instance of the command. + * + * @param jshellEval used to execute java code and build visual result + */ + public JShellCommand(JShellEval jshellEval) { + super(JSHELL_COMMAND, "Execute Java code in Discord!", CommandVisibility.GUILD); + + this.jshellEval = jshellEval; + + getData().addSubcommands( + new SubcommandData(VERSION_SUBCOMMAND, "Get the version of JShell"), + new SubcommandData(EVAL_SUBCOMMAND, + "Evaluate java code in JShell, submit the command without code for inputting longer, multi-line code.") + .addOption(OptionType.STRING, CODE_PARAMETER, + "Code to evaluate. Leave empty to input longer, multi-line code.") + .addOption(OptionType.BOOLEAN, STARTUP_SCRIPT_PARAMETER, + "If the startup script should be loaded, true by default."), + new SubcommandData(SNIPPETS_SUBCOMMAND, + "Display your snippets, or the snippets of the specified user if any.") + .addOption(OptionType.USER, USER_PARAMETER, + "User to get the snippets from. If null, get your snippets.") + .addOption(OptionType.BOOLEAN, INCLUDE_STARTUP_SCRIPT_PARAMETER, + "if the startup script should be included, false by default."), + new SubcommandData(CLOSE_SUBCOMMAND, "Close your session."), + new SubcommandData(STARTUP_SCRIPT_SUBCOMMAND, "Display the startup script.")); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Objects.requireNonNull(event.getSubcommandName())) { + case VERSION_SUBCOMMAND -> handleVersionCommand(event); + case EVAL_SUBCOMMAND -> handleEvalCommand(event); + case SNIPPETS_SUBCOMMAND -> handleSnippetsCommand(event); + case CLOSE_SUBCOMMAND -> handleCloseCommand(event); + case STARTUP_SCRIPT_SUBCOMMAND -> handleStartupScriptCommand(event); + default -> throw new AssertionError( + "Unexpected Subcommand: " + event.getSubcommandName()); + } + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + ModalMapping mapping = event.getValue(TEXT_INPUT_PART_ID + "|" + STARTUP_SCRIPT_PARAMETER); + boolean startupScript = mapping != null; + if (mapping == null) { + mapping = event.getValue(TEXT_INPUT_PART_ID); + } + if (mapping != null) { + handleEval(event, event.getUser(), true, mapping.getAsString(), startupScript); + } + } + + private void handleVersionCommand(SlashCommandInteractionEvent event) { + String code = """ + System.out.println("```"); + System.out.println("Version: " + Runtime.version()); + System.out.println("Vendor: " + System.getProperty("java.vendor")); + System.out.println("OS: " + System.getProperty("os.name")); + System.out.println("Arch: " + System.getProperty("os.arch")); + System.out.println("```");"""; + handleEval(event, null, false, code, false); + } + + private void handleEvalCommand(SlashCommandInteractionEvent event) { + OptionMapping code = event.getOption(CODE_PARAMETER); + boolean startupScript = event.getOption(STARTUP_SCRIPT_PARAMETER) == null + || Objects.requireNonNull(event.getOption(STARTUP_SCRIPT_PARAMETER)).getAsBoolean(); + if (code == null) { + sendEvalModal(event, startupScript); + } else { + handleEval(event, event.getUser(), true, code.getAsString(), startupScript); + } + } + + private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupScript) { + TextInput body = TextInput + .create(TEXT_INPUT_PART_ID + (startupScript ? "|" + STARTUP_SCRIPT_PARAMETER : ""), + "Enter code to evaluate.", TextInputStyle.PARAGRAPH) + .setPlaceholder("Put your code here.") + .setRequiredRange(MIN_MESSAGE_INPUT_LENGTH, MAX_MESSAGE_INPUT_LENGTH) + .build(); + + Modal modal = Modal.create(generateComponentId(), "JShell").addActionRow(body).build(); + event.replyModal(modal).queue(); + } + + /** + * Handle evaluation of code. + * + * @param replyCallback the callback to reply to + * @param user the user, if null, will create a single use session + * @param showCode if the embed should contain the original code + * @param startupScript if the startup script should be used or not + * @param code the code + */ + private void handleEval(IReplyCallback replyCallback, @Nullable User user, boolean showCode, + String code, boolean startupScript) { + replyCallback.deferReply().queue(interactionHook -> { + try { + interactionHook + .editOriginalEmbeds( + jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) + .queue(); + } catch (RequestFailedException | ConnectionFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + }); + } + + private void handleSnippetsCommand(SlashCommandInteractionEvent event) { + event.deferReply().queue(interactionHook -> { + OptionMapping userOption = event.getOption(USER_PARAMETER); + User user = userOption == null ? event.getUser() : userOption.getAsUser(); + OptionMapping includeStartupScriptOption = + event.getOption(INCLUDE_STARTUP_SCRIPT_PARAMETER); + boolean includeStartupScript = + includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); + List snippets; + try { + snippets = jshellEval.getApi() + .snippetsSession(user.getId(), includeStartupScript) + .snippets(); + } catch (RequestFailedException e) { + if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { + interactionHook.editOriginalEmbeds(createSessionNotFoundErrorEmbed(user)) + .queue(); + } else { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + return; + } catch (ConnectionFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + return; + } + + sendSnippets(interactionHook, user, snippets); + }); + } + + private void sendSnippets(InteractionHook interactionHook, User user, List snippets) { + if (canBeSentAsEmbed(snippets)) { + sendSnippetsAsEmbed(interactionHook, user, snippets); + } else if (canBeSentAsFile(snippets)) { + sendSnippetsAsFile(interactionHook, user, snippets); + } else { + sendSnippetsTooLong(interactionHook, user); + } + } + + private boolean canBeSentAsEmbed(List snippets) { + return snippets.stream().noneMatch(s -> s.length() >= MessageEmbed.VALUE_MAX_LENGTH) + && snippets.stream() + .mapToInt(s -> (s + "Snippet 10```java\n```").length()) + .sum() < MessageEmbed.EMBED_MAX_LENGTH_BOT - 100 + && snippets.size() <= MessageUtils.MAXIMUM_VISIBLE_EMBEDS; + } + + private void sendSnippetsAsEmbed(InteractionHook interactionHook, User user, + List snippets) { + EmbedBuilder builder = new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(user.getName()) + .setTitle(snippetsTitle(user)); + int i = 1; + for (String snippet : snippets) { + builder.addField("Snippet " + i, "```java\n" + snippet + "```", false); + i++; + } + interactionHook.editOriginalEmbeds(builder.build()).queue(); + } + + private boolean canBeSentAsFile(List snippets) { + return snippets.stream() + .mapToInt(s -> (s + "// Snippet 10").getBytes().length) + .sum() < Message.MAX_FILE_SIZE; + } + + private void sendSnippetsAsFile(InteractionHook interactionHook, User user, + List snippets) { + StringBuilder sb = new StringBuilder(); + int i = 1; + for (String snippet : snippets) { + sb.append("// Snippet ").append(i).append("\n").append(snippet); + i++; + } + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(user.getName()) + .setTitle(snippetsTitle(user)) + .build()) + .setFiles(FileUpload.fromData(sb.toString().getBytes(), snippetsTitle(user))) + .queue(); + } + + private String snippetsTitle(User user) { + return user.getName() + "'s snippets"; + } + + private void sendSnippetsTooLong(InteractionHook interactionHook, User user) { + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setAuthor(user.getName()) + .setTitle("Too much code to send...") + .build()) + .queue(); + } + + private void handleCloseCommand(SlashCommandInteractionEvent event) { + try { + jshellEval.getApi().closeSession(event.getUser().getId()); + } catch (RequestFailedException e) { + if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { + event.replyEmbeds(createSessionNotFoundErrorEmbed(event.getUser())).queue(); + } else { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + } + return; + } catch (ConnectionFailedException e) { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + return; + } + + event + .replyEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Session closed") + .build()) + .queue(); + } + + private void handleStartupScriptCommand(SlashCommandInteractionEvent event) { + event.deferReply().queue(interactionHook -> { + try { + String startupScript = jshellEval.getApi().startupScript(); + interactionHook + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Startup script") + .setDescription("```java\n" + startupScript + "```") + .build()) + .queue(); + } catch (RequestFailedException | ConnectionFailedException e) { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + } + }); + } + + private MessageEmbed createSessionNotFoundErrorEmbed(User user) { + return new EmbedBuilder().setAuthor(user.getName() + "'s result") + .setColor(Colors.ERROR_COLOR) + .setDescription("Could not find session for user " + user.getName()) + .build(); + } + + private MessageEmbed createUnexpectedErrorEmbed(@Nullable User user, Exception e) { + EmbedBuilder embedBuilder = new EmbedBuilder().setColor(Colors.ERROR_COLOR) + .setDescription("Request failed: " + e.getMessage()); + if (user != null) { + embedBuilder.setAuthor(user.getName() + "'s result"); + } + return embedBuilder.build(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java new file mode 100644 index 0000000000..381c787cd0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -0,0 +1,96 @@ +package org.togetherjava.tjbot.features.jshell; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.utils.TimeFormat; + +import org.togetherjava.tjbot.config.JShellConfig; +import org.togetherjava.tjbot.features.jshell.backend.JShellApi; +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RateLimiter; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +import javax.annotation.Nullable; + +import java.time.Duration; +import java.time.Instant; + +/** + * Provides a mid-ground between JDA and JShell API which can be used from many places in the bot, + * including JShell commands and JShell code actions. + */ +public class JShellEval { + private final JShellApi api; + + private final ResultRenderer renderer; + private final RateLimiter rateLimiter; + + /** + * Creates a JShell evaluation instance + * + * @param config the JShell configuration to use + */ + public JShellEval(JShellConfig config) { + this.api = new JShellApi(new ObjectMapper(), config.baseUrl()); + this.renderer = new ResultRenderer(); + + this.rateLimiter = new RateLimiter(Duration.ofSeconds(config.rateLimitWindowSeconds()), + config.rateLimitRequestsInWindow()); + } + + public JShellApi getApi() { + return api; + } + + /** + * Evaluate code and return a message containing the response. + * + * @param user the user, if null, will create a single use session + * @param code the code + * @param showCode if the original code should be displayed + * @param startupScript if the startup script should be used or not + * @return the response + * @throws RequestFailedException if a http error happens + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, + boolean startupScript) throws RequestFailedException, ConnectionFailedException { + MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); + if (rateLimitedMessage != null) { + return rateLimitedMessage; + } + JShellResult result; + if (user == null) { + result = api.evalOnce(code, startupScript); + } else { + result = api.evalSession(code, user.getId(), startupScript); + } + + return renderer + .renderToEmbed(user, showCode ? code : null, user != null, result, new EmbedBuilder()) + .build(); + } + + @Nullable + private MessageEmbed wasRateLimited(@Nullable User user, Instant checkTime) { + if (rateLimiter.allowRequest(checkTime)) { + return null; + } + + String nextAllowedTime = + TimeFormat.RELATIVE.format(rateLimiter.nextAllowedRequestTime(checkTime)); + EmbedBuilder embedBuilder = new EmbedBuilder() + .setDescription( + "You are currently rate-limited. Please try again " + nextAllowedTime + ".") + .setColor(Colors.ERROR_COLOR); + if (user != null) { + embedBuilder.setAuthor(user.getName() + "'s result"); + } + return embedBuilder.build(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java new file mode 100644 index 0000000000..9d930249d9 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java @@ -0,0 +1,89 @@ +package org.togetherjava.tjbot.features.jshell; + +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.entities.User; + +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.jshell.backend.dto.SnippetStatus; +import org.togetherjava.tjbot.features.utils.MessageUtils; + +import javax.annotation.Nullable; + +import java.awt.Color; + +import static org.togetherjava.tjbot.features.utils.Colors.*; + +/** + * Allows to render JShell results. + */ +class ResultRenderer { + + /** + * Renders a JShell result to an embed. + * + * @param originator the user from who to display snippet ownership, won't be displayed if null + * @param originalCode the original code to display, won't be displayed if null + * @param partOfSession if it was part of a regular session, or a one time session + * @param result the JShell result + * @param builder the embed builder + * @return the ember builder, for chaining + */ + public EmbedBuilder renderToEmbed(@Nullable User originator, @Nullable String originalCode, + boolean partOfSession, JShellResult result, EmbedBuilder builder) { + if (originator != null) { + builder.setAuthor(originator.getName() + "'s result"); + } + builder.setColor(color(result.status())); + + if (originalCode != null + && originalCode.length() + "```\n```".length() < MessageEmbed.VALUE_MAX_LENGTH) { + builder.setDescription("```java\n" + originalCode + "```"); + builder.addField( + originator == null ? "Original code" : (originator.getName() + "'s code"), + "```java\n" + originalCode + "```", false); + } + + if (result.result() != null && !result.result().isBlank()) { + builder.addField("Snippet result", result.result(), false); + } + if (result.status() == SnippetStatus.ABORTED) { + builder.setTitle("Request timed out"); + } + + String description = getDescriptionFromResult(result); + description = MessageUtils.abbreviate(description, MessageEmbed.DESCRIPTION_MAX_LENGTH); + if (result.stdoutOverflow() && !description.endsWith(MessageUtils.ABBREVIATION)) { + description += MessageUtils.ABBREVIATION; + } + builder.setDescription(description); + + if (partOfSession) { + builder.setFooter("Snippet " + result.id() + " of current session"); + } else { + builder.setFooter("This result is not part of a session"); + } + + return builder; + } + + private String getDescriptionFromResult(JShellResult result) { + if (result.exception() != null) { + return result.exception().exceptionClass() + ":" + + result.exception().exceptionMessage(); + } + if (!result.errors().isEmpty()) { + return String.join(", ", result.errors()); + } + return result.stdout(); + } + + private Color color(SnippetStatus status) { + return switch (status) { + case VALID -> SUCCESS_COLOR; + case RECOVERABLE_DEFINED, RECOVERABLE_NOT_DEFINED -> WARNING_COLOR; + case REJECTED, ABORTED -> ERROR_COLOR; + }; + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java new file mode 100644 index 0000000000..cd961a00c6 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java @@ -0,0 +1,189 @@ +package org.togetherjava.tjbot.features.jshell.backend; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.jshell.backend.dto.SnippetList; +import org.togetherjava.tjbot.features.utils.ConnectionFailedException; +import org.togetherjava.tjbot.features.utils.RequestFailedException; +import org.togetherjava.tjbot.features.utils.ResponseUtils; +import org.togetherjava.tjbot.features.utils.UncheckedRequestFailedException; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.ConnectException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpRequest.BodyPublishers; +import java.net.http.HttpResponse; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodyHandlers; + +/** + * Allows to interact with the + * Together-Java's JShell REST + * API project. + *

+ * Each method may do a blocking HTTP request and may throw a RequestFailedException if status code + * isn't 200 or 204. + *

+ * When startup script boolean argument is asked, true means {@link JShellApi#STARTUP_SCRIPT_ID} and + * false means Together-Java JShell backend's default startup script. + *

+ * For more information, check the Together-Java JShell backend project. + */ +public class JShellApi { + private static final Logger logger = LoggerFactory.getLogger(JShellApi.class); + public static final int SESSION_NOT_FOUND = 404; + /** + * The startup script to use when startup script boolean argument is true. + */ + private static final String STARTUP_SCRIPT_ID = "CUSTOM_DEFAULT"; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final String baseUrl; + + /** + * Creates a JShellAPI + * + * @param objectMapper the json mapper to use + * @param baseUrl the base url of the JShell REST API + */ + public JShellApi(ObjectMapper objectMapper, String baseUrl) { + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + + this.httpClient = HttpClient.newBuilder().build(); + } + + /** + * Evaluates the code in a one time only session, will block until the request is over. + * + * @param code the code to evaluate + * @param startupScript if the {@link JShellApi#STARTUP_SCRIPT_ID startup script} should be + * executed at the start of the session + * @return the result of the evaluation + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public JShellResult evalOnce(String code, boolean startupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "single-eval" + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + /** + * Evaluates the code in a regular session, will block until the request is over. + * + * @param code the code to evaluate + * @param sessionId the id of the session to get the snippets from + * @param startupScript if the {@link JShellApi#STARTUP_SCRIPT_ID startup script} should be + * executed at the start of the session + * @return the result of the evaluation + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public JShellResult evalSession(String code, String sessionId, boolean startupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "eval/" + sessionId + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + /** + * Gets and return the snippets for the given session id, will block until the request is over. + * + * @param sessionId the id of the session to get the snippets from + * @param includeStartupScript if the startup script should be included in the returned snippets + * @return the snippets of the session + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) + throws RequestFailedException, ConnectionFailedException { + return send( + baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, + HttpRequest.newBuilder().GET(), + ResponseUtils.ofJson(SnippetList.class, objectMapper)).body(); + } + + /** + * Closes the given session. + * + * @param sessionId the id of the session to close + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public void closeSession(String sessionId) + throws RequestFailedException, ConnectionFailedException { + send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) + .body(); + } + + /** + * Gets and return the {@link JShellApi#STARTUP_SCRIPT_ID startup script}, will block until the + * request is over. + * + * @return the startup script + * @throws RequestFailedException if the status code is not 200 or 204 + * @throws ConnectionFailedException if the connection to the API couldn't be made at the first + * place + */ + public String startupScript() throws RequestFailedException, ConnectionFailedException { + return send(baseUrl + "startup_script/" + STARTUP_SCRIPT_ID, HttpRequest.newBuilder().GET(), + BodyHandlers.ofString()).body(); + } + + private HttpResponse send(String url, HttpRequest.Builder builder, BodyHandler body) + throws RequestFailedException, ConnectionFailedException { + try { + HttpRequest request = buildRequestWithURI(builder, url); + HttpResponse response = httpClient.send(request, body); + if (response.statusCode() == 200 || response.statusCode() == 204) { + return response; + } + throw warn("JShell request failed.", new RequestFailedException( + "Request failed with status: " + response.statusCode(), response.statusCode())); + } catch (IOException e) { + if (e.getCause() instanceof UncheckedRequestFailedException r) { + throw warn("JShell request failed.", r.toChecked()); + } + if (e.getCause() instanceof ConnectException ce) { + throw warn("JShell Connection failed.", + new ConnectionFailedException("Couldn't connect to JShell server.", ce)); + } + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + + private HttpRequest buildRequestWithURI(HttpRequest.Builder builder, String url) + throws ConnectionFailedException { + try { + return builder.uri(URI.create(url)).build(); + } catch (IllegalArgumentException ex) { + throw warn("Invalid JShell URI.", + new ConnectionFailedException("Couldn't parse JShell URI.", ex)); + } + } + + private T warn(String message, T exception) { + logger.warn(message, exception); + return exception; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java new file mode 100644 index 0000000000..dbffefda69 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java @@ -0,0 +1,10 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * The thrown exception. + * + * @param exceptionClass the class of the exception + * @param exceptionMessage the message of the exception + */ +public record JShellExceptionResult(String exceptionClass, String exceptionMessage) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java new file mode 100644 index 0000000000..11be982c91 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java @@ -0,0 +1,43 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import jdk.jshell.SnippetEvent; + +import javax.annotation.Nullable; + +import java.util.List; + +/** + * Result of a JShell eval. + * + * @param status {@link SnippetStatus status} of the snippet + * @param type {@link SnippetType type} of the snippet + * @param id {@link jdk.jshell.Snippet#id() id} of the snippet + * @param source source code of the snippet + * @param result {@link SnippetEvent#value() result} of the snippet, usually null if the source code + * wasn't executed or if an exception happened during the execution, see related doc + * @param exception exception thrown by the executed code, null if no exception was thrown + * @param stdoutOverflow if stdout has overflowed and was truncated + * @param stdout what was printed by the snippet + * @param errors the compilations errors of the snippet + */ +public record JShellResult(SnippetStatus status, SnippetType type, String id, String source, + @Nullable String result, @Nullable JShellExceptionResult exception, boolean stdoutOverflow, + String stdout, List errors) { + + /** + * The JShell result. + * + * @param status status of the snippet + * @param type type of the snippet + * @param id id of the snippet + * @param source source code of the snippet + * @param result result of the snippet, nullable + * @param exception thrown exception, nullable + * @param stdoutOverflow if stdout has overflowed and was truncated + * @param stdout what was printed by the snippet + * @param errors the compilations errors of the snippet + */ + public JShellResult { + errors = List.copyOf(errors); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java new file mode 100644 index 0000000000..2cd862e9b7 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java @@ -0,0 +1,10 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * Result of a JShell eval plus the session id. + * + * @param id the session of the id + * @param result the JShell eval result + */ +public record JShellResultWithId(String id, JShellResult result) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java new file mode 100644 index 0000000000..dbed64f37d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.List; +import java.util.Objects; + +/** + * List of snippets returned by snippets endpoint. + * + * @param snippets the list of snippets + */ +public record SnippetList(List snippets) { + /** + * List of snippets returned by snippets endpoint. + * + * @param snippets the list of snippets + */ + @JsonCreator(mode = JsonCreator.Mode.DELEGATING) + public SnippetList { + Objects.requireNonNull(snippets); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java new file mode 100644 index 0000000000..63306ab0b2 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java @@ -0,0 +1,28 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * The status of the snippet, see {@link jdk.jshell.Snippet.Status} for most of them, and evaluation + * timeout of the JShell REST API for {@link SnippetStatus#ABORTED ABORTED}. + */ +public enum SnippetStatus { + /** + * See {@link jdk.jshell.Snippet.Status#VALID}. + */ + VALID, + /** + * See {@link jdk.jshell.Snippet.Status#RECOVERABLE_DEFINED}. + */ + RECOVERABLE_DEFINED, + /** + * See {@link jdk.jshell.Snippet.Status#RECOVERABLE_NOT_DEFINED}. + */ + RECOVERABLE_NOT_DEFINED, + /** + * See {@link jdk.jshell.Snippet.Status#REJECTED}. + */ + REJECTED, + /** + * Used when the timeout of an evaluation is reached. + */ + ABORTED +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java new file mode 100644 index 0000000000..54c737dca5 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java @@ -0,0 +1,9 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +/** + * Type of the snippet, if it was added or modified. + */ +public enum SnippetType { + ADDITION, + MODIFICATION +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java new file mode 100644 index 0000000000..c506b34feb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java @@ -0,0 +1,10 @@ +/** + * This packages offers value classes to encapsulate information of JShell REST API. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java new file mode 100644 index 0000000000..67a345f285 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java @@ -0,0 +1,11 @@ +/** + * This packages offers a class to interact with JShell REST API. The core class is + * {@link org.togetherjava.tjbot.features.jshell.backend.JShellApi}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell.backend; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java new file mode 100644 index 0000000000..1f1d03d1e8 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java @@ -0,0 +1,11 @@ +/** + * This packages offers all the functionality for jshell. The core class is + * {@link org.togetherjava.tjbot.features.jshell.JShellCommand}. + */ +@MethodsReturnNonnullByDefault +@ParametersAreNonnullByDefault +package org.togetherjava.tjbot.features.jshell; + +import org.togetherjava.tjbot.annotations.MethodsReturnNonnullByDefault; + +import javax.annotation.ParametersAreNonnullByDefault; diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java new file mode 100644 index 0000000000..8222bf3c52 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java @@ -0,0 +1,17 @@ +package org.togetherjava.tjbot.features.utils; + +import java.awt.Color; + +/** + * Provides the color of different things. + */ +public class Colors { + private Colors() { + throw new UnsupportedOperationException(); + } + + public static final Color ERROR_COLOR = new Color(255, 99, 71); + public static final Color SUCCESS_COLOR = new Color(118, 255, 0); + public static final Color WARNING_COLOR = new Color(255, 181, 71); + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java new file mode 100644 index 0000000000..7325cc97eb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java @@ -0,0 +1,34 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Happens when a connection has failed, or the URL was invalid. + */ +public class ConnectionFailedException extends Exception { + + /** + * Constructs a new exception with the specified detail message. The cause is not initialized, + * and may subsequently be initialized by a call to {@link #initCause}. + * + * @param message the detail message. The detail message is saved for later retrieval by the + * {@link #getMessage()} method. + */ + public ConnectionFailedException(String message) { + super(message); + } + + /** + * Constructs a new exception with the specified detail message and cause. + *

+ * Note that the detail message associated with {@code cause} is not automatically + * incorporated in this exception's detail message. + * + * @param message the detail message (which is saved for later retrieval by the + * {@link #getMessage()} method). + * @param cause the cause (which is saved for later retrieval by the {@link #getCause()} + * method). (A {@code null} value is permitted, and indicates that the cause is + * nonexistent or unknown.) + */ + public ConnectionFailedException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java index 3788b8465f..661f5acd64 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java @@ -20,7 +20,8 @@ * other commands to avoid similar methods appearing everywhere. */ public class MessageUtils { - private static final String ABBREVIATION = "..."; + public static final int MAXIMUM_VISIBLE_EMBEDS = 25; + public static final String ABBREVIATION = "..."; private static final String CODE_FENCE_SYMBOL = "```"; private MessageUtils() { diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java new file mode 100644 index 0000000000..8dda5c8c91 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java @@ -0,0 +1,83 @@ +package org.togetherjava.tjbot.features.utils; + +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * Rate limiter, register when requests are done and tells if a request can be done or need to be + * canceled. + */ +public class RateLimiter { + + private List lastUses; + + private final Duration duration; + private final int allowedRequests; + + /** + * Creates a rate limiter. + *

+ * Defines a window and a number of request, for example, if 10 requests should be allowed per 5 + * seconds, so 10/5s, the following should be called: {@snippet java: new + * RateLimit(Duration.of(5, TimeUnit.SECONDS), 10) } + * + * @param duration the duration of window + * @param allowedRequests the number of requests to allow in the window + */ + public RateLimiter(Duration duration, int allowedRequests) { + this.duration = duration; + this.allowedRequests = allowedRequests; + + this.lastUses = List.of(); + } + + /** + * Tries to allow the request. If it is allowed, the time is registered. + * + * @param time the time of the request + * @return if the request was allowed + */ + public boolean allowRequest(Instant time) { + synchronized (this) { + List usesInWindow = getEffectiveUses(time); + + if (usesInWindow.size() >= allowedRequests) { + return false; + } + usesInWindow.add(time); + + lastUses = usesInWindow; + + return true; + } + } + + private List getEffectiveUses(Instant time) { + return lastUses.stream() + .filter(it -> Duration.between(it, time).compareTo(duration) <= 0) + .collect(Collectors.toCollection(ArrayList::new)); + } + + /** + * Returns next time a request can be allowed. + * + * @param time the time of the request + * @return when the next request will be allowed + */ + public Instant nextAllowedRequestTime(Instant time) { + synchronized (this) { + List currentUses = getEffectiveUses(time); + currentUses.sort(Instant::compareTo); + + if (currentUses.size() < allowedRequests) { + return Instant.now(); + } + + return currentUses.get(0).plus(duration); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java new file mode 100644 index 0000000000..7649135447 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java @@ -0,0 +1,39 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Happens when an HTTP request has failed, contains an HTTP status code. Is the checked version of + * {@link UncheckedRequestFailedException}. + */ +public class RequestFailedException extends Exception { + private final int status; + + /** + * Creates a RequestFailedException from an unchecked one. + * + * @param ex the UncheckedRequestFailedException + */ + public RequestFailedException(UncheckedRequestFailedException ex) { + super(ex.getMessage()); + this.status = ex.getStatus(); + } + + /** + * Creates a RequestFailedException from a message and a HTTP status + * + * @param message the message + * @param status the http status + */ + public RequestFailedException(String message, int status) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status. + * + * @return the HTTP status + */ + public int getStatus() { + return status; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java new file mode 100644 index 0000000000..c2ab8675fb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java @@ -0,0 +1,54 @@ +package org.togetherjava.tjbot.features.utils; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.http.HttpResponse.BodyHandler; +import java.net.http.HttpResponse.BodySubscribers; +import java.util.Optional; + +/** + * Handle the parsing of json in a http request. + */ +public class ResponseUtils { + private ResponseUtils() {} + + /** + * Creates a body handler which will parse the body of the request. If the parsing fails, an + * IOException is thrown. if the request status code is not 200 or 204, a + * UncheckedRequestFailedException is thrown wrapped in an IOException. + * + * @param type the class to parse the json into + * @param mapper the json mapper + * @return the body handler + * @param the type of the class to parse the json into + */ + public static BodyHandler ofJson(Class type, ObjectMapper mapper) { + return responseInfo -> BodySubscribers.mapping(BodySubscribers.ofByteArray(), bytes -> { + if (responseInfo.statusCode() == 200 || responseInfo.statusCode() == 204) { + return uncheckedParseJson(type, mapper, bytes); + } + String errorMessage = tryParseError(bytes, mapper) + .orElse("Request failed with status: " + responseInfo.statusCode()); + throw new UncheckedRequestFailedException(errorMessage, responseInfo.statusCode()); + }); + } + + private static T uncheckedParseJson(Class type, ObjectMapper mapper, byte[] value) { + try { + return mapper.readValue(value, type); + } catch (IOException e) { + throw new UncheckedIOException("Error parsing json", e); + } + } + + private static Optional tryParseError(byte[] bytes, ObjectMapper mapper) { + try { + return Optional.ofNullable(mapper.readTree(bytes).get("error").asText()); + } catch (Exception e) { + return Optional.empty(); + } + } + +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java new file mode 100644 index 0000000000..e35b140d34 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java @@ -0,0 +1,38 @@ +package org.togetherjava.tjbot.features.utils; + +/** + * Internal exception when an HTTP request has failed, contains an HTTP status code. Is the + * unchecked version of {@link RequestFailedException}. + */ +public class UncheckedRequestFailedException extends RuntimeException { + private final int status; + + /** + * Creates an UncheckedRequestFailedException from a message and a HTTP status + * + * @param message the message + * @param status the http status + */ + public UncheckedRequestFailedException(String message, int status) { + super(message); + this.status = status; + } + + /** + * Returns the HTTP status. + * + * @return the HTTP status + */ + public int getStatus() { + return status; + } + + /** + * Creates a checked RequestFailedException from this UncheckedRequestFailedException. + * + * @return a checked RequestFailedException + */ + public RequestFailedException toChecked() { + return new RequestFailedException(this); + } +}