Skip to content

Commit

Permalink
1158: /jshell-aws slash command
Browse files Browse the repository at this point in the history
  • Loading branch information
surajkumar committed Sep 1, 2024
1 parent ef468b5 commit ccdef25
Show file tree
Hide file tree
Showing 10 changed files with 379 additions and 1 deletion.
1 change: 1 addition & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"jshellAwsApiUrl": "<put_your_jshell_aws_api_url>",
"token": "<put_your_token_here>",
"githubApiKey": "<your_github_personal_access_token>",
"databasePath": "local-database.db",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -418,4 +421,8 @@ public String getMemberCountCategoryPattern() {
public RSSFeedsConfig getRSSFeedsConfig() {
return rssFeedsConfig;
}

public String getjShellAwsApiUrl() {
return jShellAwsApiUrl;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -192,6 +194,7 @@ public static Collection<Feature> 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<Class<?>> blacklist = blacklistConfig.normal();
return blacklist.filterStream(features.stream(), Object::getClass).toList();
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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) {
}
Original file line number Diff line number Diff line change
@@ -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<JShellSnippet> events) {
}
Original file line number Diff line number Diff line change
@@ -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<String> 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;
}
}
Loading

0 comments on commit ccdef25

Please sign in to comment.