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); + } +}