diff --git a/application/config.json.template b/application/config.json.template index a1aec8f470..7c391a7282 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -1,4 +1,5 @@ { + "jshellAwsApiUrl": "", "token": "", "githubApiKey": "", "databasePath": "local-database.db", 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 e819f8e7d1..1799825163 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -16,6 +16,7 @@ * Configuration of the application. Create instances using {@link #load(Path)}. */ public final class Config { + private final String jShellAwsApiUrl; private final String token; private final String githubApiKey; private final String databasePath; @@ -49,7 +50,8 @@ public final class Config { @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) - private Config(@JsonProperty(value = "token", required = true) String token, + private Config(@JsonProperty(value = "jshellAwsApiUrl", required = true) String jShellAwsApiUrl, + @JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "githubApiKey", required = true) String githubApiKey, @JsonProperty(value = "databasePath", required = true) String databasePath, @JsonProperty(value = "projectWebsite", required = true) String projectWebsite, @@ -95,6 +97,7 @@ private Config(@JsonProperty(value = "token", required = true) String token, @JsonProperty(value = "rssConfig", required = true) RSSFeedsConfig rssFeedsConfig, @JsonProperty(value = "selectRolesChannelPattern", required = true) String selectRolesChannelPattern) { + this.jShellAwsApiUrl = Objects.requireNonNull(jShellAwsApiUrl); this.token = Objects.requireNonNull(token); this.githubApiKey = Objects.requireNonNull(githubApiKey); this.databasePath = Objects.requireNonNull(databasePath); @@ -418,4 +421,8 @@ public String getMemberCountCategoryPattern() { public RSSFeedsConfig getRSSFeedsConfig() { return rssFeedsConfig; } + + public String getjShellAwsApiUrl() { + return jShellAwsApiUrl; + } } 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 893adbc00f..b821cfd13b 100644 --- a/application/src/main/java/org/togetherjava/tjbot/features/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/features/Features.java @@ -36,6 +36,8 @@ import org.togetherjava.tjbot.features.javamail.RSSHandlerRoutine; import org.togetherjava.tjbot.features.jshell.JShellCommand; import org.togetherjava.tjbot.features.jshell.JShellEval; +import org.togetherjava.tjbot.features.jshell.aws.JShellAWSCommand; +import org.togetherjava.tjbot.features.jshell.aws.JShellService; import org.togetherjava.tjbot.features.mathcommands.TeXCommand; import org.togetherjava.tjbot.features.mathcommands.wolframalpha.WolframAlphaCommand; import org.togetherjava.tjbot.features.mediaonly.MediaOnlyChannelListener; @@ -192,6 +194,7 @@ public static Collection createFeatures(JDA jda, Database database, Con features.add(new BookmarksCommand(bookmarksSystem)); features.add(new ChatGptCommand(chatGptService, helpSystemHelper)); features.add(new JShellCommand(jshellEval)); + features.add(new JShellAWSCommand(new JShellService(config.getjShellAwsApiUrl()))); FeatureBlacklist> blacklist = blacklistConfig.normal(); return blacklist.filterStream(features.stream(), Object::getClass).toList(); diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java new file mode 100644 index 0000000000..d930e9ca4e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellAWSCommand.java @@ -0,0 +1,198 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Member; +import net.dv8tion.jda.api.events.interaction.command.SlashCommandInteractionEvent; +import net.dv8tion.jda.api.interactions.InteractionHook; +import net.dv8tion.jda.api.interactions.commands.OptionMapping; +import net.dv8tion.jda.api.interactions.commands.OptionType; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.tjbot.features.CommandVisibility; +import org.togetherjava.tjbot.features.SlashCommandAdapter; +import org.togetherjava.tjbot.features.jshell.aws.exceptions.JShellAPIException; + +import java.awt.Color; + +/** + * This class contains the complete logic for the /jshell-aws command. + * + * @author Suraj Kumar + */ +public class JShellAWSCommand extends SlashCommandAdapter { + private static final Logger logger = LogManager.getLogger(JShellAWSCommand.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String CODE_PARAMETER = "code"; + private final JShellService jShellService; + + /** + * Constructs a new JShellAWSCommand + * + * @param jShellService The service class to make requests against AWS + */ + public JShellAWSCommand(JShellService jShellService) { + super("jshell-aws", "Execute Java code in Discord!", CommandVisibility.GUILD); + getData().addOption(OptionType.STRING, CODE_PARAMETER, "The code to execute using JShell"); + this.jShellService = jShellService; + } + + @Override + public void onSlashCommand(SlashCommandInteractionEvent event) { + Member member = event.getMember(); + + if (member == null) { + event.reply("Member that executed the command is no longer available, won't execute") + .queue(); + return; + } + + logger.info("JShell AWS invoked by {} in channel {}", member.getAsMention(), + event.getChannelId()); + + OptionMapping input = event.getOption(CODE_PARAMETER); + + if (input == null || input.getAsString().isEmpty()) { + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(member.getAsMention() + + ", you forgot to provide the code for JShell to evaluate or it was too short!\nTry running the command again and make sure to select the code option"); + eb.setColor(Color.ORANGE); + event.replyEmbeds(eb.build()).queue(); + return; + } + + event.deferReply().queue(); + + InteractionHook hook = event.getHook(); + + String code = input.getAsString(); + + try { + respondWithJShellOutput(hook, jShellService.sendRequest(new JShellRequest(code)), code); + } catch (JShellAPIException jShellAPIException) { + handleJShellAPIException(hook, jShellAPIException, member, code); + } catch (Exception e) { + logger.error( + "An error occurred while sending/receiving request from the AWS JShell API", e); + respondWithSevereAPIError(hook, code); + } + } + + private static void handleJShellAPIException(InteractionHook hook, + JShellAPIException jShellAPIException, Member member, String code) { + switch (jShellAPIException.getStatusCode()) { + case 400 -> { + logger.warn("HTTP 400 error occurred with the JShell AWS API {}", + jShellAPIException.getBody()); + respondWithInputError(hook, jShellAPIException.getBody()); + } + case 408 -> respondWithTimeout(hook, member, code); + default -> { + logger.error("HTTP {} received from JShell AWS API {}", + jShellAPIException.getStatusCode(), jShellAPIException.getBody()); + respondWithSevereAPIError(hook, code); + } + } + } + + private static void respondWithJShellOutput(InteractionHook hook, JShellResponse response, + String code) { + // Extracted as fields to be compliant with Sonar + final String SNIPPET_SECTION_TITLE = "## Snippets\n"; + final String BACKTICK = "`"; + final String NEWLINE = "\n"; + final String DOUBLE_NEWLINE = "\n\n"; + final String STATUS = "**Status**: "; + final String OUTPUT_SECTION_TITLE = "**Output**\n"; + final String JAVA_CODE_BLOCK_START = "```java\n"; + final String CODE_BLOCK_END = "```\n"; + final String DIAGNOSTICS_SECTION_TITLE = "**Diagnostics**\n"; + final String CONSOLE_OUTPUT_SECTION_TITLE = "## Console Output\n"; + final String ERROR_OUTPUT_SECTION_TITLE = "## Error Output\n"; + + StringBuilder sb = new StringBuilder(); + sb.append(SNIPPET_SECTION_TITLE); + + for (JShellSnippet snippet : response.events()) { + sb.append(BACKTICK); + sb.append(snippet.statement()); + sb.append(BACKTICK).append(DOUBLE_NEWLINE); + sb.append(STATUS); + sb.append(snippet.status()); + sb.append(NEWLINE); + + if (snippet.value() != null && !snippet.value().isEmpty()) { + sb.append(OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(snippet.value()); + sb.append(CODE_BLOCK_END); + } + + if (!snippet.diagnostics().isEmpty()) { + sb.append(DIAGNOSTICS_SECTION_TITLE); + for (String diagnostic : snippet.diagnostics()) { + sb.append(BACKTICK).append(diagnostic).append(BACKTICK).append(NEWLINE); + } + } + } + + if (response.outputStream() != null && !response.outputStream().isEmpty()) { + sb.append(CONSOLE_OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(response.outputStream()); + sb.append(CODE_BLOCK_END); + } + + if (response.errorStream() != null && !response.errorStream().isEmpty()) { + sb.append(ERROR_OUTPUT_SECTION_TITLE); + sb.append(JAVA_CODE_BLOCK_START); + sb.append(response.errorStream()); + sb.append(CODE_BLOCK_END); + } + + String description; + if (sb.length() > 4000) { + description = sb.substring(0, 500) + "...``` truncated " + (sb.length() - 500) + + " characters"; + } else { + description = sb.toString(); + } + + sendEmbed(hook, description, Color.GREEN, code); + } + + private static void respondWithInputError(InteractionHook hook, String response) { + JShellErrorResponse errorResponse; + try { + errorResponse = OBJECT_MAPPER.readValue(response, JShellErrorResponse.class); + } catch (JsonProcessingException e) { + errorResponse = new JShellErrorResponse( + "There was a problem with the input you provided, please check and try again"); + } + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(errorResponse.error()); + eb.setColor(Color.ORANGE); + hook.editOriginalEmbeds(eb.build()).queue(); + } + + private static void respondWithTimeout(InteractionHook hook, Member member, String code) { + sendEmbed(hook, member.getAsMention() + + " the code you provided took too long and the request has timed out! Consider tweaking your code to run a little faster.", + Color.ORANGE, code); + } + + private static void respondWithSevereAPIError(InteractionHook hook, String code) { + sendEmbed(hook, "An internal error occurred, please try again later", Color.RED, code); + } + + private static void sendEmbed(InteractionHook hook, String description, Color color, + String code) { + EmbedBuilder eb = new EmbedBuilder(); + eb.setDescription(description); + eb.setColor(color); + eb.setFooter("Code that was executed:\n" + code); + hook.editOriginalEmbeds(eb.build()).queue(); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java new file mode 100644 index 0000000000..4b37699ea0 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellErrorResponse.java @@ -0,0 +1,13 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Represents a response from JShell that contains an error key. + * + * @author Suraj Kuamr + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellErrorResponse(@JsonProperty("error") String error) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java new file mode 100644 index 0000000000..b127d86dcd --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellRequest.java @@ -0,0 +1,11 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +/** + * A record containing the code snippet to be evaluated by the AWS JShell API + * + * @param code The Java code snippet to execute + * + * @author Suraj Kumar + */ +public record JShellRequest(String code) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java new file mode 100644 index 0000000000..4497d80c7c --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellResponse.java @@ -0,0 +1,21 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * A record containing the AWS JShell API response. + * + * @param errorStream The content in JShells error stream + * @param outputStream The content in JShells standard output stream + * @param events A list of snippets that were evaluated + * + * @author Suraj Kumar + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellResponse(@JsonProperty("errorStream") String errorStream, + @JsonProperty("outputStream") String outputStream, + @JsonProperty("events") List events) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java new file mode 100644 index 0000000000..35197b2514 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellService.java @@ -0,0 +1,76 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import org.togetherjava.tjbot.features.jshell.aws.exceptions.JShellAPIException; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +/** + * The JShellService class is used to interact with the AWS JShell API. + * + * @author Suraj Kumar + */ +public class JShellService { + private static final Logger LOGGER = LogManager.getLogger(JShellService.class); + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private final String apiURL; + private final HttpClient httpClient; + + /** + * Constructs a JShellService. + * + * @param apiURl The Lambda Function URL to send API requests to + */ + public JShellService(String apiURl) { + this.apiURL = apiURl; + this.httpClient = HttpClient.newHttpClient(); + } + + /** + * Sends an HTTP request to the AWS JShell API. + * + * @param jShellRequest The request object containing the code to evaluate + * @return The API response as a JShellResponse object + * @throws URISyntaxException If the API URL is invalid + * @throws JsonProcessingException If the API response failed to get parsed by Jackson to our + * mapping. + */ + public JShellResponse sendRequest(JShellRequest jShellRequest) + throws URISyntaxException, JsonProcessingException { + HttpRequest request = HttpRequest.newBuilder() + .uri(new URI(apiURL)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers + .ofString(OBJECT_MAPPER.writeValueAsString(jShellRequest))) + .build(); + + try { + HttpResponse response = + httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() != 200) { + throw new JShellAPIException(response.statusCode(), response.body()); + } + + String body = response.body(); + LOGGER.trace("Received the following body from the AWS JShell API: {}", body); + + return OBJECT_MAPPER.readValue(response.body(), JShellResponse.class); + + } catch (IOException | InterruptedException e) { + LOGGER.error("Failed to send http request to the AWS JShell API", e); + Thread.currentThread().interrupt(); + } + + return null; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java new file mode 100644 index 0000000000..01b0f36cfc --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/JShellSnippet.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.features.jshell.aws; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +/** + * A JShell snippet is a statement that is to be executed. This record is used to hold information + * about a statement that was provided by the AWS JShell API + * + * @param statement The statement that was executed + * @param value The return value of the statement + * @param status The status from evaluating the statement e.g. "VALID", "INVALID" + * @param diagnostics A list of diagnostics such as error messages provided by JShell + * + * @author Suraj Kumar + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record JShellSnippet(@JsonProperty("statement") String statement, + @JsonProperty("value") String value, @JsonProperty("status") String status, + List diagnostics) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java new file mode 100644 index 0000000000..75035ea17a --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/features/jshell/aws/exceptions/JShellAPIException.java @@ -0,0 +1,25 @@ +package org.togetherjava.tjbot.features.jshell.aws.exceptions; + +/** + * An exception that contains the HTTP status code and response body when the request to the JShell + * AWS API fails. + * + * @author Suraj Kumar + */ +public class JShellAPIException extends RuntimeException { + private final int statusCode; + private final String body; + + public JShellAPIException(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + public int getStatusCode() { + return statusCode; + } + + public String getBody() { + return body; + } +}