From f0a93b516cdef3672b284aa1a17473b18bb469df Mon Sep 17 00:00:00 2001 From: Alathreon <45936420+Alathreon@users.noreply.github.com> Date: Sat, 5 Aug 2023 18:29:34 +0200 Subject: [PATCH 1/9] Feature/jshell from fork (#869) * JShell feature added * Added option to get the snippets of another user * Refactored JShell eval into its own class * Changed RateLimiter so it's a global limiter and not a per user limiter + merged user and oneOffSession together * Added context action for running java code * Fixing sonar wranings for jshell --- application/config.json.template | 5 + .../org/togetherjava/tjbot/config/Config.java | 14 +- .../tjbot/config/JShellConfig.java | 22 ++ .../togetherjava/tjbot/features/Features.java | 7 +- .../features/code/CodeMessageHandler.java | 6 +- .../tjbot/features/code/EvalCodeCommand.java | 42 +++ .../tjbot/features/jshell/JShellCommand.java | 250 ++++++++++++++++++ .../tjbot/features/jshell/JShellEval.java | 84 ++++++ .../features/jshell/backend/JShellApi.java | 80 ++++++ .../backend/dto/JShellExceptionResult.java | 4 + .../jshell/backend/dto/JShellResult.java | 12 + .../backend/dto/JShellResultWithId.java | 4 + .../jshell/backend/dto/SnippetList.java | 13 + .../jshell/backend/dto/SnippetStatus.java | 9 + .../jshell/backend/dto/SnippetType.java | 6 + .../tjbot/features/jshell/package-info.java | 11 + .../tjbot/features/jshell/render/Colors.java | 14 + .../jshell/render/ResultRenderer.java | 76 ++++++ .../tjbot/features/utils/MessageUtils.java | 2 +- .../tjbot/features/utils/RateLimiter.java | 60 +++++ .../utils/RequestFailedException.java | 19 ++ .../tjbot/features/utils/ResponseUtils.java | 44 +++ .../UncheckedRequestFailedException.java | 18 ++ 23 files changed, 797 insertions(+), 5 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/package-info.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/render/Colors.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java diff --git a/application/config.json.template b/application/config.json.template index 3f9262c32b..85f984a860 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": "http://localhost:8080/jshell/", + "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..bae8112d27 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. + * + * @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..2337c6e8d4 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java @@ -0,0 +1,22 @@ +package org.togetherjava.tjbot.config; + +import com.linkedin.urls.Url; + +import java.net.MalformedURLException; + +public record JShellConfig(String baseUrl, int rateLimitWindowSeconds, + int rateLimitRequestsInWindow) { + public JShellConfig { + try { + Url.create(baseUrl); + } catch (MalformedURLException e) { + throw new IllegalArgumentException(e); + } + 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..cd849926fd 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; @@ -63,10 +64,11 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. */ - 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..56dae630ad --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -0,0 +1,42 @@ +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.jshell.render.Colors; +import org.togetherjava.tjbot.features.utils.CodeFence; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +/** + * Evaluates the given 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); + } catch (RequestFailedException 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..eec7a46f5b --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -0,0 +1,250 @@ +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.jshell.render.Colors; +import org.togetherjava.tjbot.features.utils.RequestFailedException; + +import javax.annotation.Nullable; + +import java.util.List; +import java.util.Objects; + +public class JShellCommand extends SlashCommandAdapter { + private static final String JSHELL_TEXT_INPUT_ID = "jshell"; + private static final String JSHELL_COMMAND = "jshell"; + private static final String JSHELL_VERSION_SUBCOMMAND = "version"; + private static final String JSHELL_EVAL_SUBCOMMAND = "eval"; + private static final String JSHELL_SNIPPETS_SUBCOMMAND = "snippets"; + private static final String JSHELL_CLOSE_SUBCOMMAND = "shutdown"; + 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. + */ + public JShellCommand(JShellEval jshellEval) { + super(JSHELL_COMMAND, "JShell as a command.", CommandVisibility.GUILD); + + this.jshellEval = jshellEval; + + getData().addSubcommands( + new SubcommandData(JSHELL_VERSION_SUBCOMMAND, "Get the version of JShell"), + new SubcommandData(JSHELL_EVAL_SUBCOMMAND, + "Evaluate java code in JShell, don't fill the optional parameter to access a bigger input box.") + .addOption(OptionType.STRING, "code", + "Code to evaluate. If not supplied, open an inout box."), + new SubcommandData(JSHELL_SNIPPETS_SUBCOMMAND, + "Get the evaluated snippets of the user who sent the command, or the user specified user if any.") + .addOption(OptionType.USER, "user", + "User to get the snippets from. If null, get the snippets of the user who sent the command."), + new SubcommandData(JSHELL_CLOSE_SUBCOMMAND, "Close your session.")); + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + switch (Objects.requireNonNull(event.getSubcommandName())) { + case JSHELL_VERSION_SUBCOMMAND -> handleVersionCommand(event); + case JSHELL_EVAL_SUBCOMMAND -> handleEvalCommand(event); + case JSHELL_SNIPPETS_SUBCOMMAND -> handleSnippetsCommand(event); + case JSHELL_CLOSE_SUBCOMMAND -> handleCloseCommand(event); + default -> throw new AssertionError( + "Unexpected Subcommand: " + event.getSubcommandName()); + } + } + + @Override + public void onModalSubmitted(ModalInteractionEvent event, List args) { + ModalMapping mapping = event.getValue(JSHELL_TEXT_INPUT_ID); + if (mapping != null) { + handleEval(event, event.getUser(), true, mapping.getAsString()); + } + } + + 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); + } + + private void handleEvalCommand(SlashCommandInteractionEvent event) { + OptionMapping code = event.getOption("code"); + if (code == null) { + sendEvalModal(event); + } else { + handleEval(event, event.getUser(), true, code.getAsString()); + } + } + + private void sendEvalModal(SlashCommandInteractionEvent event) { + TextInput body = TextInput + .create(JSHELL_TEXT_INPUT_ID, "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 code the code + */ + private void handleEval(IReplyCallback replyCallback, @Nullable User user, boolean showCode, + String code) { + replyCallback.deferReply().queue(interactionHook -> { + try { + interactionHook + .editOriginalEmbeds(jshellEval.evaluateAndRespond(user, code, showCode)) + .queue(); + } catch (RequestFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + }); + } + + private void handleSnippetsCommand(SlashCommandInteractionEvent event) { + event.deferReply().queue(interactionHook -> { + OptionMapping userOption = event.getOption("user"); + User user = userOption == null ? event.getUser() : userOption.getAsUser(); + List snippets; + try { + snippets = jshellEval.getApi().snippetsSession(user.getId()).snippets(); + } catch (RequestFailedException e) { + if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { + interactionHook.editOriginalEmbeds(createSessionNotFoundErrorEmbed(user)) + .queue(); + } else { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + } + return; + } + + if (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() <= 25/* + * Max visible embed fields in an embed TODO replace + * with constant + */) { + sendSnippetsAsEmbed(interactionHook, user, snippets); + } else if (snippets.stream() + .mapToInt(s -> (s + "// Snippet 10").getBytes().length) + .sum() < Message.MAX_FILE_SIZE) { + sendSnippetsAsFile(interactionHook, user, snippets); + } else { + sendSnippetsTooLong(interactionHook, user); + } + }); + } + + 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 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; + } + + event + .replyEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Session closed") + .build()) + .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, RequestFailedException 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..4672413c9d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -0,0 +1,84 @@ +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.jshell.render.Colors; +import org.togetherjava.tjbot.features.jshell.render.ResultRenderer; +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; + +public class JShellEval { + private final JShellApi api; + + private final ResultRenderer renderer; + private final RateLimiter rateLimiter; + + 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 + * @return the response + * @throws RequestFailedException if a http error happens + */ + public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode) + throws RequestFailedException { + MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); + if (rateLimitedMessage != null) { + return rateLimitedMessage; + } + JShellResult result; + if (user == null) { + result = api.evalOnce(code); + } else { + result = api.evalSession(code, user.getId()); + } + + 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/backend/JShellApi.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java new file mode 100644 index 0000000000..b90f16b526 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/JShellApi.java @@ -0,0 +1,80 @@ +package org.togetherjava.tjbot.features.jshell.backend; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import org.togetherjava.tjbot.features.jshell.backend.dto.JShellResult; +import org.togetherjava.tjbot.features.jshell.backend.dto.SnippetList; +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.URI; +import java.net.URISyntaxException; +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; + +public class JShellApi { + public static final int SESSION_NOT_FOUND = 404; + + private final ObjectMapper objectMapper; + private final HttpClient httpClient; + private final String baseUrl; + + public JShellApi(ObjectMapper objectMapper, String baseUrl) { + this.objectMapper = objectMapper; + this.baseUrl = baseUrl; + + this.httpClient = HttpClient.newBuilder().build(); + } + + public JShellResult evalOnce(String code) throws RequestFailedException { + return send(baseUrl + "single-eval", + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + public JShellResult evalSession(String code, String sessionId) throws RequestFailedException { + return send(baseUrl + "eval/" + sessionId, + HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), + ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); + } + + public SnippetList snippetsSession(String sessionId) throws RequestFailedException { + return send(baseUrl + "snippets/" + sessionId, HttpRequest.newBuilder().GET(), + ResponseUtils.ofJson(SnippetList.class, objectMapper)).body(); + } + + public void closeSession(String sessionId) throws RequestFailedException { + send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) + .body(); + } + + private HttpResponse send(String url, HttpRequest.Builder builder, BodyHandler body) + throws RequestFailedException { + try { + HttpResponse response = httpClient.send(builder.uri(new URI(url)).build(), body); + if (response.statusCode() == 200 || response.statusCode() == 204) { + return response; + } + throw new RequestFailedException("Request failed with status: " + response.statusCode(), + response.statusCode()); + } catch (IOException e) { + if (e.getCause() instanceof UncheckedRequestFailedException r) { + throw r.toChecked(); + } + throw new UncheckedIOException(e); + } catch (URISyntaxException e) { + throw new IllegalArgumentException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(e); + } + } + +} 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..1cbb548d6e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellExceptionResult.java @@ -0,0 +1,4 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +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..afa83ed1b3 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResult.java @@ -0,0 +1,12 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import java.util.List; + +public record JShellResult(SnippetStatus status, SnippetType type, String id, String source, + String result, JShellExceptionResult exception, boolean stdoutOverflow, String stdout, + List errors) { + + 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..4c1b1ea970 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/JShellResultWithId.java @@ -0,0 +1,4 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +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..980b1aa211 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetList.java @@ -0,0 +1,13 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.List; +import java.util.Objects; + +public record SnippetList(List 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..c734acad34 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetStatus.java @@ -0,0 +1,9 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +public enum SnippetStatus { + VALID, + RECOVERABLE_DEFINED, + RECOVERABLE_NOT_DEFINED, + REJECTED, + 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..0de54ff44e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/SnippetType.java @@ -0,0 +1,6 @@ +package org.togetherjava.tjbot.features.jshell.backend.dto; + +public enum SnippetType { + ADDITION, + MODIFICATION +} 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/jshell/render/Colors.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/Colors.java new file mode 100644 index 0000000000..928668e15d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/Colors.java @@ -0,0 +1,14 @@ +package org.togetherjava.tjbot.features.jshell.render; + +import java.awt.Color; + +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/jshell/render/ResultRenderer.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java new file mode 100644 index 0000000000..2bbb0f5897 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java @@ -0,0 +1,76 @@ +package org.togetherjava.tjbot.features.jshell.render; + +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.jshell.render.Colors.*; + +public class ResultRenderer { + + 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/utils/MessageUtils.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/MessageUtils.java index 3788b8465f..025bf37da7 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,7 @@ * other commands to avoid similar methods appearing everywhere. */ public class MessageUtils { - private static final String ABBREVIATION = "..."; + 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..9ac4761480 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java @@ -0,0 +1,60 @@ +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; + +/** + * Custom rate limiter. + */ +public class RateLimiter { + + private List lastUses; + + private final Duration duration; + private final int allowedRequests; + + public RateLimiter(Duration duration, int allowedRequests) { + this.duration = duration; + this.allowedRequests = allowedRequests; + + this.lastUses = List.of(); + } + + 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)); + } + + 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..80cdcc1e69 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java @@ -0,0 +1,19 @@ +package org.togetherjava.tjbot.features.utils; + +public class RequestFailedException extends Exception { + private final int status; + + public RequestFailedException(UncheckedRequestFailedException ex) { + super(ex.getMessage()); + this.status = ex.getStatus(); + } + + public RequestFailedException(String message, int status) { + super(message); + this.status = 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..676eaf46cb --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java @@ -0,0 +1,44 @@ +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() {} + + public static BodyHandler ofJson(Class t, ObjectMapper mapper) { + return responseInfo -> BodySubscribers.mapping(BodySubscribers.ofByteArray(), bytes -> { + if (responseInfo.statusCode() == 200 || responseInfo.statusCode() == 204) { + return uncheckedParseJson(t, 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 t, ObjectMapper mapper, byte[] value) { + try { + return mapper.readValue(value, t); + } 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..b7d8852a5e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java @@ -0,0 +1,18 @@ +package org.togetherjava.tjbot.features.utils; + +public class UncheckedRequestFailedException extends RuntimeException { + private final int status; + + public UncheckedRequestFailedException(String message, int status) { + super(message); + this.status = status; + } + + public int getStatus() { + return status; + } + + public RequestFailedException toChecked() { + return new RequestFailedException(this); + } +} From 78828b89c0bb385fee73474a89e349af83ea5c40 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Mon, 7 Aug 2023 19:03:06 +0200 Subject: [PATCH 2/9] Added startupt scripts for jshell --- .../tjbot/features/code/EvalCodeCommand.java | 2 +- .../tjbot/features/jshell/JShellCommand.java | 71 ++++++++++++++----- .../tjbot/features/jshell/JShellEval.java | 7 +- .../features/jshell/backend/JShellApi.java | 17 +++-- 4 files changed, 70 insertions(+), 27 deletions(-) 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 index 56dae630ad..23aae33824 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -31,7 +31,7 @@ public MessageEmbed apply(CodeFence codeFence) { .build(); } try { - return jshellEval.evaluateAndRespond(null, codeFence.code(), false); + return jshellEval.evaluateAndRespond(null, codeFence.code(), false, false); } catch (RequestFailedException e) { return new EmbedBuilder().setColor(Colors.ERROR_COLOR) .setDescription("Request failed: " + e.getMessage()) 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 index eec7a46f5b..1db413a08e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -35,6 +35,12 @@ public class JShellCommand extends SlashCommandAdapter { private static final String JSHELL_EVAL_SUBCOMMAND = "eval"; private static final String JSHELL_SNIPPETS_SUBCOMMAND = "snippets"; private static final String JSHELL_CLOSE_SUBCOMMAND = "shutdown"; + private static final String JSHELL_STARTUP_SCRIPT_SUBCOMMAND = "startup-script"; + private static final String JSHELL_CODE_PARAMETER = "code"; + private static final String JSHELL_STARTUP_SCRIPT_PARAMETER = "startup-script"; + private static final String JSHELL_USER_PARAMETER = "user"; + private static final String JSHELL_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; @@ -52,13 +58,18 @@ public JShellCommand(JShellEval jshellEval) { new SubcommandData(JSHELL_VERSION_SUBCOMMAND, "Get the version of JShell"), new SubcommandData(JSHELL_EVAL_SUBCOMMAND, "Evaluate java code in JShell, don't fill the optional parameter to access a bigger input box.") - .addOption(OptionType.STRING, "code", - "Code to evaluate. If not supplied, open an inout box."), + .addOption(OptionType.STRING, JSHELL_CODE_PARAMETER, + "Code to evaluate. If not supplied, open an inout box.") + .addOption(OptionType.BOOLEAN, JSHELL_STARTUP_SCRIPT_PARAMETER, + "If the startup script should be loaded, true by default."), new SubcommandData(JSHELL_SNIPPETS_SUBCOMMAND, "Get the evaluated snippets of the user who sent the command, or the user specified user if any.") - .addOption(OptionType.USER, "user", - "User to get the snippets from. If null, get the snippets of the user who sent the command."), - new SubcommandData(JSHELL_CLOSE_SUBCOMMAND, "Close your session.")); + .addOption(OptionType.USER, JSHELL_USER_PARAMETER, + "User to get the snippets from. If null, get the snippets of the user who sent the command.") + .addOption(OptionType.BOOLEAN, JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER, + "if the startup script should be included, false by default."), + new SubcommandData(JSHELL_CLOSE_SUBCOMMAND, "Close your session."), + new SubcommandData(JSHELL_STARTUP_SCRIPT_SUBCOMMAND, "Display the startup script.")); } @Override @@ -68,6 +79,7 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { case JSHELL_EVAL_SUBCOMMAND -> handleEvalCommand(event); case JSHELL_SNIPPETS_SUBCOMMAND -> handleSnippetsCommand(event); case JSHELL_CLOSE_SUBCOMMAND -> handleCloseCommand(event); + case JSHELL_STARTUP_SCRIPT_SUBCOMMAND -> handleStartupScriptCommand(event); default -> throw new AssertionError( "Unexpected Subcommand: " + event.getSubcommandName()); } @@ -75,9 +87,13 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { @Override public void onModalSubmitted(ModalInteractionEvent event, List args) { - ModalMapping mapping = event.getValue(JSHELL_TEXT_INPUT_ID); + ModalMapping mapping = event.getValue(JSHELL_TEXT_INPUT_ID + "|" + JSHELL_STARTUP_SCRIPT_PARAMETER); + boolean startupScript = mapping != null; + if(mapping == null) { + mapping = event.getValue(JSHELL_TEXT_INPUT_ID); + } if (mapping != null) { - handleEval(event, event.getUser(), true, mapping.getAsString()); + handleEval(event, event.getUser(), true, mapping.getAsString(), startupScript); } } @@ -89,21 +105,22 @@ private void handleVersionCommand(SlashCommandInteractionEvent event) { System.out.println("OS: " + System.getProperty("os.name")); System.out.println("Arch: " + System.getProperty("os.arch")); System.out.println("```");"""; - handleEval(event, null, false, code); + handleEval(event, null, false, code, false); } private void handleEvalCommand(SlashCommandInteractionEvent event) { - OptionMapping code = event.getOption("code"); + OptionMapping code = event.getOption(JSHELL_CODE_PARAMETER); + boolean startupScript = event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER) == null || Objects.requireNonNull(event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER)).getAsBoolean(); if (code == null) { - sendEvalModal(event); + sendEvalModal(event, startupScript); } else { - handleEval(event, event.getUser(), true, code.getAsString()); + handleEval(event, event.getUser(), true, code.getAsString(), startupScript); } } - private void sendEvalModal(SlashCommandInteractionEvent event) { + private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupScript) { TextInput body = TextInput - .create(JSHELL_TEXT_INPUT_ID, "Enter code to evaluate.", TextInputStyle.PARAGRAPH) + .create(JSHELL_TEXT_INPUT_ID + (startupScript ? "|" + JSHELL_STARTUP_SCRIPT_PARAMETER : ""), "Enter code to evaluate.", TextInputStyle.PARAGRAPH) .setPlaceholder("Put your code here.") .setRequiredRange(MIN_MESSAGE_INPUT_LENGTH, MAX_MESSAGE_INPUT_LENGTH) .build(); @@ -118,14 +135,15 @@ private void sendEvalModal(SlashCommandInteractionEvent event) { * @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) { + String code, boolean startupScript) { replyCallback.deferReply().queue(interactionHook -> { try { interactionHook - .editOriginalEmbeds(jshellEval.evaluateAndRespond(user, code, showCode)) + .editOriginalEmbeds(jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) .queue(); } catch (RequestFailedException e) { interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); @@ -135,11 +153,13 @@ private void handleEval(IReplyCallback replyCallback, @Nullable User user, boole private void handleSnippetsCommand(SlashCommandInteractionEvent event) { event.deferReply().queue(interactionHook -> { - OptionMapping userOption = event.getOption("user"); + OptionMapping userOption = event.getOption(JSHELL_USER_PARAMETER); User user = userOption == null ? event.getUser() : userOption.getAsUser(); + OptionMapping includeStartupScriptOption = event.getOption(JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER); + boolean includeStartupScript = includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); List snippets; try { - snippets = jshellEval.getApi().snippetsSession(user.getId()).snippets(); + snippets = jshellEval.getApi().snippetsSession(user.getId(), includeStartupScript).snippets(); } catch (RequestFailedException e) { if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { interactionHook.editOriginalEmbeds(createSessionNotFoundErrorEmbed(user)) @@ -232,6 +252,23 @@ private void handleCloseCommand(SlashCommandInteractionEvent event) { .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 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) 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 index 4672413c9d..10346d81cc 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -43,10 +43,11 @@ public JShellApi getApi() { * @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 */ - public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode) + public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, boolean startupScript) throws RequestFailedException { MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); if (rateLimitedMessage != null) { @@ -54,9 +55,9 @@ public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean } JShellResult result; if (user == null) { - result = api.evalOnce(code); + result = api.evalOnce(code, startupScript); } else { - result = api.evalSession(code, user.getId()); + result = api.evalSession(code, user.getId(), startupScript); } return renderer 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 index b90f16b526..7662086d6b 100644 --- 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 @@ -21,6 +21,7 @@ public class JShellApi { public static final int SESSION_NOT_FOUND = 404; + private static final String STARTUP_SCRIPT_ID = "CUSTOM_DEFAULT"; private final ObjectMapper objectMapper; private final HttpClient httpClient; @@ -33,20 +34,20 @@ public JShellApi(ObjectMapper objectMapper, String baseUrl) { this.httpClient = HttpClient.newBuilder().build(); } - public JShellResult evalOnce(String code) throws RequestFailedException { - return send(baseUrl + "single-eval", + public JShellResult evalOnce(String code, boolean startupScript) throws RequestFailedException { + return send(baseUrl + "single-eval" + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); } - public JShellResult evalSession(String code, String sessionId) throws RequestFailedException { - return send(baseUrl + "eval/" + sessionId, + public JShellResult evalSession(String code, String sessionId, boolean startupScript) throws RequestFailedException { + return send(baseUrl + "eval/" + sessionId + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); } - public SnippetList snippetsSession(String sessionId) throws RequestFailedException { - return send(baseUrl + "snippets/" + sessionId, HttpRequest.newBuilder().GET(), + public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) throws RequestFailedException { + return send(baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, HttpRequest.newBuilder().GET(), ResponseUtils.ofJson(SnippetList.class, objectMapper)).body(); } @@ -54,6 +55,10 @@ public void closeSession(String sessionId) throws RequestFailedException { send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) .body(); } + public String startupScript() throws RequestFailedException { + 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 { From 7fa5e1e04bacf3ce3d7064186cd35c7ff98ccb81 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Mon, 7 Aug 2023 19:46:31 +0200 Subject: [PATCH 3/9] Formating jshell classes --- .../tjbot/features/jshell/JShellCommand.java | 43 ++++++++++++------- .../tjbot/features/jshell/JShellEval.java | 6 +-- .../features/jshell/backend/JShellApi.java | 19 +++++--- 3 files changed, 44 insertions(+), 24 deletions(-) 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 index 1db413a08e..dbf13f5176 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -69,7 +69,8 @@ public JShellCommand(JShellEval jshellEval) { .addOption(OptionType.BOOLEAN, JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER, "if the startup script should be included, false by default."), new SubcommandData(JSHELL_CLOSE_SUBCOMMAND, "Close your session."), - new SubcommandData(JSHELL_STARTUP_SCRIPT_SUBCOMMAND, "Display the startup script.")); + new SubcommandData(JSHELL_STARTUP_SCRIPT_SUBCOMMAND, + "Display the startup script.")); } @Override @@ -87,9 +88,10 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { @Override public void onModalSubmitted(ModalInteractionEvent event, List args) { - ModalMapping mapping = event.getValue(JSHELL_TEXT_INPUT_ID + "|" + JSHELL_STARTUP_SCRIPT_PARAMETER); + ModalMapping mapping = + event.getValue(JSHELL_TEXT_INPUT_ID + "|" + JSHELL_STARTUP_SCRIPT_PARAMETER); boolean startupScript = mapping != null; - if(mapping == null) { + if (mapping == null) { mapping = event.getValue(JSHELL_TEXT_INPUT_ID); } if (mapping != null) { @@ -110,7 +112,9 @@ private void handleVersionCommand(SlashCommandInteractionEvent event) { private void handleEvalCommand(SlashCommandInteractionEvent event) { OptionMapping code = event.getOption(JSHELL_CODE_PARAMETER); - boolean startupScript = event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER) == null || Objects.requireNonNull(event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER)).getAsBoolean(); + boolean startupScript = event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER) == null + || Objects.requireNonNull(event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER)) + .getAsBoolean(); if (code == null) { sendEvalModal(event, startupScript); } else { @@ -120,7 +124,9 @@ private void handleEvalCommand(SlashCommandInteractionEvent event) { private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupScript) { TextInput body = TextInput - .create(JSHELL_TEXT_INPUT_ID + (startupScript ? "|" + JSHELL_STARTUP_SCRIPT_PARAMETER : ""), "Enter code to evaluate.", TextInputStyle.PARAGRAPH) + .create(JSHELL_TEXT_INPUT_ID + + (startupScript ? "|" + JSHELL_STARTUP_SCRIPT_PARAMETER : ""), + "Enter code to evaluate.", TextInputStyle.PARAGRAPH) .setPlaceholder("Put your code here.") .setRequiredRange(MIN_MESSAGE_INPUT_LENGTH, MAX_MESSAGE_INPUT_LENGTH) .build(); @@ -131,7 +137,7 @@ private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupSc /** * 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 @@ -143,7 +149,8 @@ private void handleEval(IReplyCallback replyCallback, @Nullable User user, boole replyCallback.deferReply().queue(interactionHook -> { try { interactionHook - .editOriginalEmbeds(jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) + .editOriginalEmbeds( + jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) .queue(); } catch (RequestFailedException e) { interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); @@ -155,11 +162,15 @@ private void handleSnippetsCommand(SlashCommandInteractionEvent event) { event.deferReply().queue(interactionHook -> { OptionMapping userOption = event.getOption(JSHELL_USER_PARAMETER); User user = userOption == null ? event.getUser() : userOption.getAsUser(); - OptionMapping includeStartupScriptOption = event.getOption(JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER); - boolean includeStartupScript = includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); + OptionMapping includeStartupScriptOption = + event.getOption(JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER); + boolean includeStartupScript = + includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); List snippets; try { - snippets = jshellEval.getApi().snippetsSession(user.getId(), includeStartupScript).snippets(); + snippets = jshellEval.getApi() + .snippetsSession(user.getId(), includeStartupScript) + .snippets(); } catch (RequestFailedException e) { if (e.getStatus() == JShellApi.SESSION_NOT_FOUND) { interactionHook.editOriginalEmbeds(createSessionNotFoundErrorEmbed(user)) @@ -257,12 +268,12 @@ private void handleStartupScriptCommand(SlashCommandInteractionEvent event) { 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(); + .editOriginalEmbeds(new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) + .setAuthor(event.getUser().getName()) + .setTitle("Startup script") + .setDescription("```java\n" + startupScript + "```") + .build()) + .queue(); } catch (RequestFailedException e) { event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); } 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 index 10346d81cc..c54a2739d0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -39,7 +39,7 @@ public JShellApi getApi() { /** * 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 @@ -47,8 +47,8 @@ public JShellApi getApi() { * @return the response * @throws RequestFailedException if a http error happens */ - public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, boolean startupScript) - throws RequestFailedException { + public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, + boolean startupScript) throws RequestFailedException { MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); if (rateLimitedMessage != null) { return rateLimitedMessage; 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 index 7662086d6b..89e88b44a5 100644 --- 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 @@ -35,19 +35,27 @@ public JShellApi(ObjectMapper objectMapper, String baseUrl) { } public JShellResult evalOnce(String code, boolean startupScript) throws RequestFailedException { - return send(baseUrl + "single-eval" + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + return send( + baseUrl + "single-eval" + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); } - public JShellResult evalSession(String code, String sessionId, boolean startupScript) throws RequestFailedException { - return send(baseUrl + "eval/" + sessionId + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), + public JShellResult evalSession(String code, String sessionId, boolean startupScript) + throws RequestFailedException { + return send( + baseUrl + "eval/" + sessionId + + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), HttpRequest.newBuilder().POST(BodyPublishers.ofString(code)), ResponseUtils.ofJson(JShellResult.class, objectMapper)).body(); } - public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) throws RequestFailedException { - return send(baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, HttpRequest.newBuilder().GET(), + public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) + throws RequestFailedException { + return send( + baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, + HttpRequest.newBuilder().GET(), ResponseUtils.ofJson(SnippetList.class, objectMapper)).body(); } @@ -55,6 +63,7 @@ public void closeSession(String sessionId) throws RequestFailedException { send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) .body(); } + public String startupScript() throws RequestFailedException { return send(baseUrl + "startup_script/" + STARTUP_SCRIPT_ID, HttpRequest.newBuilder().GET(), BodyHandlers.ofString()).body(); From 900780c2c12a0eb1f364a93ee6f5df3ac27d2367 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Tue, 8 Aug 2023 18:35:22 +0200 Subject: [PATCH 4/9] JShell javadoc and many minor code improvements --- .../tjbot/config/JShellConfig.java | 26 ++++- .../features/code/CodeMessageHandler.java | 2 + .../tjbot/features/code/EvalCodeCommand.java | 6 +- .../tjbot/features/jshell/JShellCommand.java | 95 ++++++++++--------- .../tjbot/features/jshell/JShellEval.java | 12 ++- .../jshell/{render => }/ResultRenderer.java | 6 +- .../features/jshell/backend/JShellApi.java | 60 ++++++++++++ .../backend/dto/JShellExceptionResult.java | 6 ++ .../jshell/backend/dto/JShellResult.java | 19 +++- .../backend/dto/JShellResultWithId.java | 6 ++ .../jshell/backend/dto/SnippetList.java | 10 ++ .../jshell/backend/dto/SnippetStatus.java | 19 ++++ .../jshell/backend/dto/SnippetType.java | 3 + .../jshell/backend/dto/package-info.java | 10 ++ .../features/jshell/backend/package-info.java | 11 +++ .../{jshell/render => utils}/Colors.java | 5 +- .../tjbot/features/utils/MessageUtils.java | 1 + .../tjbot/features/utils/RateLimiter.java | 25 ++++- .../tjbot/features/utils/ResponseUtils.java | 19 +++- 19 files changed, 277 insertions(+), 64 deletions(-) rename application/src/main/java/org/togetherjava/tjbot/features/jshell/{render => }/ResultRenderer.java (94%) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/dto/package-info.java create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/jshell/backend/package-info.java rename application/src/main/java/org/togetherjava/tjbot/features/{jshell/render => utils}/Colors.java (77%) diff --git a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java index 2337c6e8d4..850ca9f4c3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java @@ -2,21 +2,43 @@ import com.linkedin.urls.Url; +import org.togetherjava.tjbot.features.utils.RateLimiter; + import java.net.MalformedURLException; +/** + * 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 valid + * @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 { try { Url.create(baseUrl); } catch (MalformedURLException e) { throw new IllegalArgumentException(e); } - if (rateLimitWindowSeconds <= 0) + if (rateLimitWindowSeconds < 0) { throw new IllegalArgumentException( "Illegal rateLimitWindowSeconds : " + rateLimitWindowSeconds); - if (rateLimitRequestsInWindow <= 0) + } + if (rateLimitRequestsInWindow < 0) { throw new IllegalArgumentException( "Illegal rateLimitRequestsInWindow : " + rateLimitRequestsInWindow); + } } } 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 cd849926fd..30ae6bdfc2 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 @@ -63,6 +63,8 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. + * + * @param jshellEval the jshell evaluation instance used in the code actions */ public CodeMessageHandler(JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); 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 index 23aae33824..1dfd6a7f8c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -4,12 +4,14 @@ import net.dv8tion.jda.api.entities.MessageEmbed; import org.togetherjava.tjbot.features.jshell.JShellEval; -import org.togetherjava.tjbot.features.jshell.render.Colors; import org.togetherjava.tjbot.features.utils.CodeFence; +import org.togetherjava.tjbot.features.utils.Colors; import org.togetherjava.tjbot.features.utils.RequestFailedException; /** - * Evaluates the given code. + * 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; 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 index dbf13f5176..92905b6756 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -20,7 +20,8 @@ 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.jshell.render.Colors; +import org.togetherjava.tjbot.features.utils.Colors; +import org.togetherjava.tjbot.features.utils.MessageUtils; import org.togetherjava.tjbot.features.utils.RequestFailedException; import javax.annotation.Nullable; @@ -28,18 +29,21 @@ import java.util.List; import java.util.Objects; +/** + * The JShell command, provide functionalities to create JShell sessions, evaluate code, etc. + */ public class JShellCommand extends SlashCommandAdapter { - private static final String JSHELL_TEXT_INPUT_ID = "jshell"; + private static final String TEXT_INPUT_PART_ID = "jshell"; private static final String JSHELL_COMMAND = "jshell"; - private static final String JSHELL_VERSION_SUBCOMMAND = "version"; - private static final String JSHELL_EVAL_SUBCOMMAND = "eval"; - private static final String JSHELL_SNIPPETS_SUBCOMMAND = "snippets"; - private static final String JSHELL_CLOSE_SUBCOMMAND = "shutdown"; - private static final String JSHELL_STARTUP_SCRIPT_SUBCOMMAND = "startup-script"; - private static final String JSHELL_CODE_PARAMETER = "code"; - private static final String JSHELL_STARTUP_SCRIPT_PARAMETER = "startup-script"; - private static final String JSHELL_USER_PARAMETER = "user"; - private static final String JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER = "include-startup-script"; + 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; @@ -48,39 +52,42 @@ public class JShellCommand extends SlashCommandAdapter { /** * Creates an instance of the command. + * + * @param jshellEval the jshell evaluation instance used in the jshell command */ public JShellCommand(JShellEval jshellEval) { - super(JSHELL_COMMAND, "JShell as a command.", CommandVisibility.GUILD); + super(JSHELL_COMMAND, + "JShell as a command, a Read–eval–print loop which allows to rapidly and simply execute java code.", + CommandVisibility.GUILD); this.jshellEval = jshellEval; getData().addSubcommands( - new SubcommandData(JSHELL_VERSION_SUBCOMMAND, "Get the version of JShell"), - new SubcommandData(JSHELL_EVAL_SUBCOMMAND, - "Evaluate java code in JShell, don't fill the optional parameter to access a bigger input box.") - .addOption(OptionType.STRING, JSHELL_CODE_PARAMETER, - "Code to evaluate. If not supplied, open an inout box.") - .addOption(OptionType.BOOLEAN, JSHELL_STARTUP_SCRIPT_PARAMETER, + 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(JSHELL_SNIPPETS_SUBCOMMAND, - "Get the evaluated snippets of the user who sent the command, or the user specified user if any.") - .addOption(OptionType.USER, JSHELL_USER_PARAMETER, - "User to get the snippets from. If null, get the snippets of the user who sent the command.") - .addOption(OptionType.BOOLEAN, JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER, + 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(JSHELL_CLOSE_SUBCOMMAND, "Close your session."), - new SubcommandData(JSHELL_STARTUP_SCRIPT_SUBCOMMAND, - "Display the startup script.")); + 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 JSHELL_VERSION_SUBCOMMAND -> handleVersionCommand(event); - case JSHELL_EVAL_SUBCOMMAND -> handleEvalCommand(event); - case JSHELL_SNIPPETS_SUBCOMMAND -> handleSnippetsCommand(event); - case JSHELL_CLOSE_SUBCOMMAND -> handleCloseCommand(event); - case JSHELL_STARTUP_SCRIPT_SUBCOMMAND -> handleStartupScriptCommand(event); + 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()); } @@ -88,11 +95,10 @@ public void onSlashCommand(SlashCommandInteractionEvent event) { @Override public void onModalSubmitted(ModalInteractionEvent event, List args) { - ModalMapping mapping = - event.getValue(JSHELL_TEXT_INPUT_ID + "|" + JSHELL_STARTUP_SCRIPT_PARAMETER); + ModalMapping mapping = event.getValue(TEXT_INPUT_PART_ID + "|" + STARTUP_SCRIPT_PARAMETER); boolean startupScript = mapping != null; if (mapping == null) { - mapping = event.getValue(JSHELL_TEXT_INPUT_ID); + mapping = event.getValue(TEXT_INPUT_PART_ID); } if (mapping != null) { handleEval(event, event.getUser(), true, mapping.getAsString(), startupScript); @@ -106,15 +112,14 @@ private void handleVersionCommand(SlashCommandInteractionEvent event) { 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("```");"""; + System.out.println("```");"""; handleEval(event, null, false, code, false); } private void handleEvalCommand(SlashCommandInteractionEvent event) { - OptionMapping code = event.getOption(JSHELL_CODE_PARAMETER); - boolean startupScript = event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER) == null - || Objects.requireNonNull(event.getOption(JSHELL_STARTUP_SCRIPT_PARAMETER)) - .getAsBoolean(); + 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 { @@ -124,8 +129,7 @@ private void handleEvalCommand(SlashCommandInteractionEvent event) { private void sendEvalModal(SlashCommandInteractionEvent event, boolean startupScript) { TextInput body = TextInput - .create(JSHELL_TEXT_INPUT_ID - + (startupScript ? "|" + JSHELL_STARTUP_SCRIPT_PARAMETER : ""), + .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) @@ -160,10 +164,10 @@ private void handleEval(IReplyCallback replyCallback, @Nullable User user, boole private void handleSnippetsCommand(SlashCommandInteractionEvent event) { event.deferReply().queue(interactionHook -> { - OptionMapping userOption = event.getOption(JSHELL_USER_PARAMETER); + OptionMapping userOption = event.getOption(USER_PARAMETER); User user = userOption == null ? event.getUser() : userOption.getAsUser(); OptionMapping includeStartupScriptOption = - event.getOption(JSHELL_INCLUDE_STARTUP_SCRIPT_PARAMETER); + event.getOption(INCLUDE_STARTUP_SCRIPT_PARAMETER); boolean includeStartupScript = includeStartupScriptOption != null && includeStartupScriptOption.getAsBoolean(); List snippets; @@ -185,10 +189,7 @@ private void handleSnippetsCommand(SlashCommandInteractionEvent event) { && snippets.stream() .mapToInt(s -> (s + "Snippet 10```java\n```").length()) .sum() < MessageEmbed.EMBED_MAX_LENGTH_BOT - 100 - && snippets.size() <= 25/* - * Max visible embed fields in an embed TODO replace - * with constant - */) { + && snippets.size() <= MessageUtils.MAXIMUM_VISIBLE_EMBEDS) { sendSnippetsAsEmbed(interactionHook, user, snippets); } else if (snippets.stream() .mapToInt(s -> (s + "// Snippet 10").getBytes().length) 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 index c54a2739d0..91d46feb14 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -9,8 +9,7 @@ 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.jshell.render.Colors; -import org.togetherjava.tjbot.features.jshell.render.ResultRenderer; +import org.togetherjava.tjbot.features.utils.Colors; import org.togetherjava.tjbot.features.utils.RateLimiter; import org.togetherjava.tjbot.features.utils.RequestFailedException; @@ -19,12 +18,21 @@ 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(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java similarity index 94% rename from application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java rename to application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java index 2bbb0f5897..f15dc3f973 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/ResultRenderer.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java @@ -1,4 +1,4 @@ -package org.togetherjava.tjbot.features.jshell.render; +package org.togetherjava.tjbot.features.jshell; import net.dv8tion.jda.api.EmbedBuilder; import net.dv8tion.jda.api.entities.MessageEmbed; @@ -12,9 +12,9 @@ import java.awt.Color; -import static org.togetherjava.tjbot.features.jshell.render.Colors.*; +import static org.togetherjava.tjbot.features.utils.Colors.*; -public class ResultRenderer { +class ResultRenderer { public EmbedBuilder renderToEmbed(@Nullable User originator, @Nullable String originalCode, boolean partOfSession, JShellResult result, EmbedBuilder builder) { 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 index 89e88b44a5..b0f6e7e073 100644 --- 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 @@ -19,14 +19,35 @@ import java.net.http.HttpResponse.BodyHandler; import java.net.http.HttpResponse.BodyHandlers; +/** + * Allows to interact with the unofficial JShell REST API of the Together-Java JShell backend + * 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 { 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; @@ -34,6 +55,15 @@ public JShellApi(ObjectMapper objectMapper, String 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 + */ public JShellResult evalOnce(String code, boolean startupScript) throws RequestFailedException { return send( baseUrl + "single-eval" @@ -42,6 +72,15 @@ public JShellResult evalOnce(String code, boolean startupScript) throws RequestF 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 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 + */ public JShellResult evalSession(String code, String sessionId, boolean startupScript) throws RequestFailedException { return send( @@ -51,6 +90,14 @@ public JShellResult evalSession(String code, String sessionId, boolean startupSc 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 + */ public SnippetList snippetsSession(String sessionId, boolean includeStartupScript) throws RequestFailedException { return send( @@ -59,11 +106,24 @@ public SnippetList snippetsSession(String sessionId, boolean includeStartupScrip 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 + */ public void closeSession(String sessionId) throws RequestFailedException { 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 + */ public String startupScript() throws RequestFailedException { return send(baseUrl + "startup_script/" + STARTUP_SCRIPT_ID, HttpRequest.newBuilder().GET(), BodyHandlers.ofString()).body(); 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 index 1cbb548d6e..dbffefda69 100644 --- 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 @@ -1,4 +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 index afa83ed1b3..830eb17429 100644 --- 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 @@ -1,10 +1,25 @@ package org.togetherjava.tjbot.features.jshell.backend.dto; +import javax.annotation.Nullable; + import java.util.List; +/** + * Result of a JShell eval. + * + * @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 + * @param exception thrown exception + * @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, - String result, JShellExceptionResult exception, boolean stdoutOverflow, String stdout, - List errors) { + @Nullable String result, @Nullable JShellExceptionResult exception, boolean stdoutOverflow, + String stdout, List errors) { 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 index 4c1b1ea970..2cd862e9b7 100644 --- 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 @@ -1,4 +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 index 980b1aa211..dbed64f37d 100644 --- 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 @@ -5,7 +5,17 @@ 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 index c734acad34..747604a9b1 100644 --- 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 @@ -1,9 +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 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 index 0de54ff44e..54c737dca5 100644 --- 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 @@ -1,5 +1,8 @@ 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/render/Colors.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java similarity index 77% rename from application/src/main/java/org/togetherjava/tjbot/features/jshell/render/Colors.java rename to application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java index 928668e15d..8222bf3c52 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/render/Colors.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/Colors.java @@ -1,7 +1,10 @@ -package org.togetherjava.tjbot.features.jshell.render; +package org.togetherjava.tjbot.features.utils; import java.awt.Color; +/** + * Provides the color of different things. + */ public class Colors { private Colors() { throw new UnsupportedOperationException(); 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 025bf37da7..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,6 +20,7 @@ * other commands to avoid similar methods appearing everywhere. */ public class MessageUtils { + public static final int MAXIMUM_VISIBLE_EMBEDS = 25; public static final String ABBREVIATION = "..."; private static final String CODE_FENCE_SYMBOL = "```"; 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 index 9ac4761480..8dda5c8c91 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RateLimiter.java @@ -7,7 +7,8 @@ import java.util.stream.Collectors; /** - * Custom rate limiter. + * Rate limiter, register when requests are done and tells if a request can be done or need to be + * canceled. */ public class RateLimiter { @@ -16,6 +17,16 @@ public class RateLimiter { 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; @@ -23,6 +34,12 @@ public RateLimiter(Duration duration, int 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); @@ -44,6 +61,12 @@ private List getEffectiveUses(Instant time) { .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); 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 index 676eaf46cb..0b84a6cf52 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java @@ -14,10 +14,21 @@ public class ResponseUtils { private ResponseUtils() {} - public static BodyHandler ofJson(Class t, ObjectMapper mapper) { + /** + * Creates a body handler which will parse the body of the request. If the parsing fails, an + * UncheckedIOException exception is thrown and may be wrapped in an IOException.//TODO If the + * request status code is not 200 or 204, a UncheckedRequestFailedException is thrown and + * 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(t, mapper, bytes); + return uncheckedParseJson(type, mapper, bytes); } String errorMessage = tryParseError(bytes, mapper) .orElse("Request failed with status: " + responseInfo.statusCode()); @@ -25,9 +36,9 @@ public static BodyHandler ofJson(Class t, ObjectMapper mapper) { }); } - private static T uncheckedParseJson(Class t, ObjectMapper mapper, byte[] value) { + private static T uncheckedParseJson(Class type, ObjectMapper mapper, byte[] value) { try { - return mapper.readValue(value, t); + return mapper.readValue(value, type); } catch (IOException e) { throw new UncheckedIOException("Error parsing json", e); } From ff0288ddb35e8191eb13a86a3cd0585ca5af4713 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Tue, 8 Aug 2023 19:09:36 +0200 Subject: [PATCH 5/9] JShell more minor code improvements --- application/config.json.template | 2 +- .../org/togetherjava/tjbot/config/Config.java | 2 +- .../tjbot/features/jshell/ResultRenderer.java | 13 ++++++++++++ .../features/jshell/backend/JShellApi.java | 3 ++- .../jshell/backend/dto/JShellResult.java | 17 ++++++++++++++-- .../utils/RequestFailedException.java | 20 +++++++++++++++++++ .../tjbot/features/utils/ResponseUtils.java | 5 ++--- .../UncheckedRequestFailedException.java | 20 +++++++++++++++++++ 8 files changed, 74 insertions(+), 8 deletions(-) diff --git a/application/config.json.template b/application/config.json.template index 85f984a860..02c6027d9e 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -91,7 +91,7 @@ "openaiApiKey": "", "sourceCodeBaseUrl": "//blob/master/application/src/main/java/>" "jshell": { - "baseUrl": "http://localhost:8080/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 bae8112d27..f16aed61b4 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -335,7 +335,7 @@ public String getSourceCodeBaseUrl() { } /** - * The configuration about jshell. + * The configuration about jshell REST API and command/code action settings. * * @return the jshell configuration */ 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 index f15dc3f973..3229b24237 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java @@ -14,8 +14,21 @@ 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 + * @param originalCode the original code to display + * @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) { 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 index b0f6e7e073..ea121001f1 100644 --- 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 @@ -74,8 +74,9 @@ public JShellResult evalOnce(String code, boolean startupScript) throws RequestF /** * 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 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 index 830eb17429..5deacedff7 100644 --- 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 @@ -11,8 +11,8 @@ * @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 - * @param exception thrown exception + * @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 @@ -21,6 +21,19 @@ public record JShellResult(SnippetStatus status, SnippetType type, String id, St @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/utils/RequestFailedException.java b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java index 80cdcc1e69..7649135447 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/RequestFailedException.java @@ -1,18 +1,38 @@ 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 index 0b84a6cf52..c2ab8675fb 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/ResponseUtils.java @@ -16,9 +16,8 @@ private ResponseUtils() {} /** * Creates a body handler which will parse the body of the request. If the parsing fails, an - * UncheckedIOException exception is thrown and may be wrapped in an IOException.//TODO If the - * request status code is not 200 or 204, a UncheckedRequestFailedException is thrown and - * wrapped in an IOException. + * 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 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 index b7d8852a5e..e35b140d34 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/utils/UncheckedRequestFailedException.java @@ -1,17 +1,37 @@ 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); } From 01054fc57c6e4c06208ed61a6fd830ab6c097a15 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Thu, 10 Aug 2023 14:41:58 +0200 Subject: [PATCH 6/9] Other minor code changes --- .../features/code/CodeMessageHandler.java | 2 +- .../tjbot/features/jshell/JShellCommand.java | 41 +++++++++++-------- .../tjbot/features/jshell/ResultRenderer.java | 4 +- .../features/jshell/backend/JShellApi.java | 5 ++- .../jshell/backend/dto/JShellResult.java | 13 +++--- .../jshell/backend/dto/SnippetStatus.java | 2 +- 6 files changed, 38 insertions(+), 29 deletions(-) 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 30ae6bdfc2..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 @@ -64,7 +64,7 @@ public final class CodeMessageHandler extends MessageReceiverAdapter implements /** * Creates a new instance. * - * @param jshellEval the jshell evaluation instance used in the code actions + * @param jshellEval used to execute java code and build visual result */ public CodeMessageHandler(JShellEval jshellEval) { componentIdInteractor = new ComponentIdInteractor(getInteractionType(), getName()); 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 index 92905b6756..920d25fc90 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -30,7 +30,10 @@ import java.util.Objects; /** - * The JShell command, provide functionalities to create JShell sessions, evaluate code, etc. + * 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"; @@ -53,12 +56,10 @@ public class JShellCommand extends SlashCommandAdapter { /** * Creates an instance of the command. * - * @param jshellEval the jshell evaluation instance used in the jshell command + * @param jshellEval used to execute java code and build visual result */ public JShellCommand(JShellEval jshellEval) { - super(JSHELL_COMMAND, - "JShell as a command, a Read–eval–print loop which allows to rapidly and simply execute java code.", - CommandVisibility.GUILD); + super(JSHELL_COMMAND, "Execute Java code in Discord!", CommandVisibility.GUILD); this.jshellEval = jshellEval; @@ -185,22 +186,26 @@ private void handleSnippetsCommand(SlashCommandInteractionEvent event) { return; } - if (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) { - sendSnippetsAsEmbed(interactionHook, user, snippets); - } else if (snippets.stream() - .mapToInt(s -> (s + "// Snippet 10").getBytes().length) - .sum() < Message.MAX_FILE_SIZE) { - sendSnippetsAsFile(interactionHook, user, snippets); - } else { - sendSnippetsTooLong(interactionHook, user); - } + sendSnippets(interactionHook, user, snippets); }); } + private void sendSnippets(InteractionHook interactionHook, User user, List snippets) { + if (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) { + sendSnippetsAsEmbed(interactionHook, user, snippets); + } else if (snippets.stream() + .mapToInt(s -> (s + "// Snippet 10").getBytes().length) + .sum() < Message.MAX_FILE_SIZE) { + sendSnippetsAsFile(interactionHook, user, snippets); + } else { + sendSnippetsTooLong(interactionHook, user); + } + } + private void sendSnippetsAsEmbed(InteractionHook interactionHook, User user, List snippets) { EmbedBuilder builder = new EmbedBuilder().setColor(Colors.SUCCESS_COLOR) 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 index 3229b24237..9d930249d9 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/ResultRenderer.java @@ -22,8 +22,8 @@ class ResultRenderer { /** * Renders a JShell result to an embed. * - * @param originator the user from who to display snippet ownership - * @param originalCode the original code to display + * @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 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 index ea121001f1..19eb5527d9 100644 --- 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 @@ -20,8 +20,9 @@ import java.net.http.HttpResponse.BodyHandlers; /** - * Allows to interact with the unofficial JShell REST API of the Together-Java JShell backend - * project. + * 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. 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 index 5deacedff7..11be982c91 100644 --- 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 @@ -1,5 +1,7 @@ package org.togetherjava.tjbot.features.jshell.backend.dto; +import jdk.jshell.SnippetEvent; + import javax.annotation.Nullable; import java.util.List; @@ -7,12 +9,13 @@ /** * Result of a JShell eval. * - * @param status status of the snippet - * @param type type of the snippet - * @param id id of the snippet + * @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 result of the snippet, nullable - * @param exception thrown exception, nullable + * @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 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 index 747604a9b1..63306ab0b2 100644 --- 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 @@ -2,7 +2,7 @@ /** * The status of the snippet, see {@link jdk.jshell.Snippet.Status} for most of them, and evaluation - * timeout of the JShell REST API for ABORTED. + * timeout of the JShell REST API for {@link SnippetStatus#ABORTED ABORTED}. */ public enum SnippetStatus { /** From 7edd53ccf3bf26059cd2513b49c5f7ad50f75826 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Thu, 10 Aug 2023 15:28:16 +0200 Subject: [PATCH 7/9] JShell now allows wrong uri and dead server, will give correct response to the user and will log them as warns --- .../tjbot/config/JShellConfig.java | 11 +--- .../tjbot/features/code/EvalCodeCommand.java | 3 +- .../tjbot/features/jshell/JShellCommand.java | 13 +++- .../tjbot/features/jshell/JShellEval.java | 3 +- .../features/jshell/backend/JShellApi.java | 59 +++++++++++++++---- .../utils/ConnectionFailedException.java | 34 +++++++++++ 6 files changed, 97 insertions(+), 26 deletions(-) create mode 100644 application/src/main/java/org/togetherjava/tjbot/features/utils/ConnectionFailedException.java diff --git a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java index 850ca9f4c3..91b85eb2d8 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/JShellConfig.java @@ -1,10 +1,9 @@ package org.togetherjava.tjbot.config; -import com.linkedin.urls.Url; import org.togetherjava.tjbot.features.utils.RateLimiter; -import java.net.MalformedURLException; +import java.util.Objects; /** * JShell config. @@ -20,18 +19,14 @@ public record JShellConfig(String baseUrl, int rateLimitWindowSeconds, /** * Creates a JShell config. * - * @param baseUrl the base url of the JShell REST API, must be valid + * @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 { - try { - Url.create(baseUrl); - } catch (MalformedURLException e) { - throw new IllegalArgumentException(e); - } + Objects.requireNonNull(baseUrl); if (rateLimitWindowSeconds < 0) { throw new IllegalArgumentException( "Illegal rateLimitWindowSeconds : " + rateLimitWindowSeconds); 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 index 1dfd6a7f8c..ad6cc91a3c 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/code/EvalCodeCommand.java @@ -6,6 +6,7 @@ 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; /** @@ -34,7 +35,7 @@ public MessageEmbed apply(CodeFence codeFence) { } try { return jshellEval.evaluateAndRespond(null, codeFence.code(), false, false); - } catch (RequestFailedException e) { + } 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 index 920d25fc90..6ae47e40d3 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -21,6 +21,7 @@ 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; @@ -157,7 +158,7 @@ private void handleEval(IReplyCallback replyCallback, @Nullable User user, boole .editOriginalEmbeds( jshellEval.evaluateAndRespond(user, code, showCode, startupScript)) .queue(); - } catch (RequestFailedException e) { + } catch (RequestFailedException | ConnectionFailedException e) { interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); } }); @@ -184,6 +185,9 @@ private void handleSnippetsCommand(SlashCommandInteractionEvent event) { interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); } return; + } catch (ConnectionFailedException e) { + interactionHook.editOriginalEmbeds(createUnexpectedErrorEmbed(user, e)).queue(); + return; } sendSnippets(interactionHook, user, snippets); @@ -259,6 +263,9 @@ private void handleCloseCommand(SlashCommandInteractionEvent event) { event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); } return; + } catch (ConnectionFailedException e) { + event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); + return; } event @@ -280,7 +287,7 @@ private void handleStartupScriptCommand(SlashCommandInteractionEvent event) { .setDescription("```java\n" + startupScript + "```") .build()) .queue(); - } catch (RequestFailedException e) { + } catch (RequestFailedException | ConnectionFailedException e) { event.replyEmbeds(createUnexpectedErrorEmbed(event.getUser(), e)).queue(); } }); @@ -293,7 +300,7 @@ private MessageEmbed createSessionNotFoundErrorEmbed(User user) { .build(); } - private MessageEmbed createUnexpectedErrorEmbed(@Nullable User user, RequestFailedException e) { + private MessageEmbed createUnexpectedErrorEmbed(@Nullable User user, Exception e) { EmbedBuilder embedBuilder = new EmbedBuilder().setColor(Colors.ERROR_COLOR) .setDescription("Request failed: " + e.getMessage()); if (user != null) { 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 index 91d46feb14..4bcd27a2c1 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -10,6 +10,7 @@ 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; @@ -56,7 +57,7 @@ public JShellApi getApi() { * @throws RequestFailedException if a http error happens */ public MessageEmbed evaluateAndRespond(@Nullable User user, String code, boolean showCode, - boolean startupScript) throws RequestFailedException { + boolean startupScript) throws RequestFailedException, ConnectionFailedException { MessageEmbed rateLimitedMessage = wasRateLimited(user, Instant.now()); if (rateLimitedMessage != null) { return rateLimitedMessage; 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 index 19eb5527d9..cd961a00c6 100644 --- 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 @@ -1,17 +1,20 @@ 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.URISyntaxException; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpRequest.BodyPublishers; @@ -33,6 +36,7 @@ * 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. @@ -64,8 +68,11 @@ public JShellApi(ObjectMapper objectMapper, String baseUrl) { * 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 { + public JShellResult evalOnce(String code, boolean startupScript) + throws RequestFailedException, ConnectionFailedException { return send( baseUrl + "single-eval" + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), @@ -82,9 +89,11 @@ public JShellResult evalOnce(String code, boolean startupScript) throws RequestF * 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 { + throws RequestFailedException, ConnectionFailedException { return send( baseUrl + "eval/" + sessionId + (startupScript ? "?startupScriptId=" + STARTUP_SCRIPT_ID : ""), @@ -99,9 +108,11 @@ public JShellResult evalSession(String code, String sessionId, boolean startupSc * @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 { + throws RequestFailedException, ConnectionFailedException { return send( baseUrl + "snippets/" + sessionId + "?includeStartupScript=" + includeStartupScript, HttpRequest.newBuilder().GET(), @@ -113,8 +124,11 @@ public SnippetList snippetsSession(String sessionId, boolean includeStartupScrip * * @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 { + public void closeSession(String sessionId) + throws RequestFailedException, ConnectionFailedException { send(baseUrl + sessionId, HttpRequest.newBuilder().DELETE(), BodyHandlers.discarding()) .body(); } @@ -125,32 +139,51 @@ public void closeSession(String sessionId) throws RequestFailedException { * * @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 { + 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 { + throws RequestFailedException, ConnectionFailedException { try { - HttpResponse response = httpClient.send(builder.uri(new URI(url)).build(), body); + HttpRequest request = buildRequestWithURI(builder, url); + HttpResponse response = httpClient.send(request, body); if (response.statusCode() == 200 || response.statusCode() == 204) { return response; } - throw new RequestFailedException("Request failed with status: " + response.statusCode(), - response.statusCode()); + 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 r.toChecked(); + 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 (URISyntaxException e) { - throw new IllegalArgumentException(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/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); + } +} From 2e36e55caaf28303f0d9d4559629a529197b1ac0 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Thu, 10 Aug 2023 15:33:43 +0200 Subject: [PATCH 8/9] Forgot one exception javadoc for jshell feature --- .../java/org/togetherjava/tjbot/features/jshell/JShellEval.java | 2 ++ 1 file changed, 2 insertions(+) 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 index 4bcd27a2c1..381c787cd0 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellEval.java @@ -55,6 +55,8 @@ public JShellApi getApi() { * @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 { From 09a1b69e30172a24a9b0b8f7599b76843074e8b3 Mon Sep 17 00:00:00 2001 From: Alathreon Date: Thu, 10 Aug 2023 19:42:38 +0200 Subject: [PATCH 9/9] JShell refactored snippets send conditions --- .../tjbot/features/jshell/JShellCommand.java | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) 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 index 6ae47e40d3..44bb6ba37f 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/JShellCommand.java @@ -195,21 +195,23 @@ private void handleSnippetsCommand(SlashCommandInteractionEvent event) { } private void sendSnippets(InteractionHook interactionHook, User user, List snippets) { - if (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) { + if (canBeSentAsEmbed(snippets)) { sendSnippetsAsEmbed(interactionHook, user, snippets); - } else if (snippets.stream() - .mapToInt(s -> (s + "// Snippet 10").getBytes().length) - .sum() < Message.MAX_FILE_SIZE) { + } 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) @@ -223,6 +225,12 @@ private void sendSnippetsAsEmbed(InteractionHook interactionHook, User user, 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();