diff --git a/application/config.json.template b/application/config.json.template index 8238dba370..6c433a2043 100644 --- a/application/config.json.template +++ b/application/config.json.template @@ -1,5 +1,6 @@ { "token": "", + "gistApiKey": "", "databasePath": "local-database.db", "projectWebsite": "https://github.com/Together-Java/TJ-Bot", "discordGuildInvite": "https://discord.com/invite/XXFUXzK", diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java index 401171192a..33be7da19e 100644 --- a/application/src/main/java/org/togetherjava/tjbot/commands/Features.java +++ b/application/src/main/java/org/togetherjava/tjbot/commands/Features.java @@ -2,10 +2,8 @@ import net.dv8tion.jda.api.JDA; import org.jetbrains.annotations.NotNull; -import org.togetherjava.tjbot.commands.basic.PingCommand; -import org.togetherjava.tjbot.commands.basic.RoleSelectCommand; -import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter; -import org.togetherjava.tjbot.commands.basic.VcActivityCommand; +import org.togetherjava.tjbot.commands.basic.*; +import org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener; import org.togetherjava.tjbot.commands.help.*; import org.togetherjava.tjbot.commands.mathcommands.TeXCommand; import org.togetherjava.tjbot.commands.mathcommands.wolframalpha.WolframAlphaCommand; @@ -81,6 +79,7 @@ public enum Features { features.add(new SuggestionsUpDownVoter(config)); features.add(new ScamBlocker(actionsStore, scamHistoryStore, config)); features.add(new ImplicitAskListener(config, helpSystemHelper)); + features.add(new FileSharingMessageListener(config)); // Event receivers features.add(new RejoinModerationRoleListener(actionsStore, config)); diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java new file mode 100644 index 0000000000..1e1c0a3639 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/FileSharingMessageListener.java @@ -0,0 +1,213 @@ +package org.togetherjava.tjbot.commands.filesharing; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import net.dv8tion.jda.api.entities.ChannelType; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.ThreadChannel; +import net.dv8tion.jda.api.entities.User; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.interactions.components.buttons.Button; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.togetherjava.tjbot.commands.MessageReceiverAdapter; +import org.togetherjava.tjbot.config.Config; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Predicate; +import java.util.regex.Pattern; + +/** + * Listener that receives all sent help messages and uploads them to a share service if the message + * contains a file with the given extension in the + * {@link FileSharingMessageListener#extensionFilter}. + */ +public class FileSharingMessageListener extends MessageReceiverAdapter { + + private static final Logger LOGGER = LoggerFactory.getLogger(FileSharingMessageListener.class); + private static final ObjectMapper JSON = new ObjectMapper(); + + private static final String SHARE_API = "https://api.github.com/gists"; + private static final HttpClient CLIENT = HttpClient.newHttpClient(); + + private final String gistApiKey; + private final Set extensionFilter = Set.of("txt", "java", "gradle", "xml", "kt", "json", + "fxml", "css", "c", "h", "cpp", "py", "yml"); + + private final Predicate isStagingChannelName; + private final Predicate isOverviewChannelName; + + + public FileSharingMessageListener(@NotNull Config config) { + super(Pattern.compile(".*")); + + gistApiKey = config.getGistApiKey(); + isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern()) + .asMatchPredicate(); + isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern()) + .asMatchPredicate(); + } + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + User author = event.getAuthor(); + if (author.isBot() || event.isWebhookMessage()) { + return; + } + + if (!isHelpThread(event)) { + return; + } + + + List attachments = event.getMessage() + .getAttachments() + .stream() + .filter(this::isAttachmentRelevant) + .toList(); + + CompletableFuture.runAsync(() -> { + try { + processAttachments(event, attachments); + } catch (Exception e) { + LOGGER.error("Unknown error while processing attachments", e); + } + }); + } + + private boolean isAttachmentRelevant(@NotNull Message.Attachment attachment) { + String extension = attachment.getFileExtension(); + if (extension == null) { + return false; + } + return extensionFilter.contains(extension); + } + + + private void processAttachments(@NotNull MessageReceivedEvent event, + @NotNull List attachments) { + + Map nameToFile = new ConcurrentHashMap<>(); + + List> tasks = new ArrayList<>(); + for (Message.Attachment attachment : attachments) { + CompletableFuture task = attachment.retrieveInputStream() + .thenApply(this::readAttachment) + .thenAccept( + content -> nameToFile.put(getNameOf(attachment), new GistFile(content))); + + tasks.add(task); + } + + tasks.forEach(CompletableFuture::join); + + GistFiles files = new GistFiles(nameToFile); + GistRequest request = new GistRequest(event.getAuthor().getName(), false, files); + String url = uploadToGist(request); + sendResponse(event, url); + } + + private @NotNull String readAttachment(@NotNull InputStream stream) { + try { + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + private @NotNull String getNameOf(@NotNull Message.Attachment attachment) { + String fileName = attachment.getFileName(); + String fileExtension = attachment.getFileExtension(); + + if (fileExtension == null || fileExtension.equals("txt")) { + fileExtension = "java"; + } else if (fileExtension.equals("fxml")) { + fileExtension = "xml"; + } + + int extensionIndex = fileName.lastIndexOf('.'); + if (extensionIndex != -1) { + fileName = fileName.substring(0, extensionIndex); + } + + fileName += "." + fileExtension; + + return fileName; + } + + private @NotNull String uploadToGist(@NotNull GistRequest jsonRequest) { + String body; + try { + body = JSON.writeValueAsString(jsonRequest); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Attempting to upload a file to gist, but unable to create the JSON request.", + e); + } + + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(SHARE_API)) + .header("Accept", "application/json") + .header("Authorization", "token " + gistApiKey) + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + + HttpResponse apiResponse; + try { + apiResponse = CLIENT.send(request, HttpResponse.BodyHandlers.ofString()); + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException( + "Attempting to upload a file to gist, but the request got interrupted.", e); + } + + int statusCode = apiResponse.statusCode(); + + if (statusCode < HttpURLConnection.HTTP_OK + || statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) { + throw new IllegalStateException("Gist API unexpected response: " + apiResponse.body()); + } + + GistResponse gistResponse; + try { + gistResponse = JSON.readValue(apiResponse.body(), GistResponse.class); + } catch (JsonProcessingException e) { + throw new IllegalStateException( + "Attempting to upload file to gist, but unable to parse its JSON response.", e); + } + return gistResponse.getHtmlUrl(); + } + + private void sendResponse(@NotNull MessageReceivedEvent event, @NotNull String url) { + Message message = event.getMessage(); + String messageContent = + "I uploaded your attachments as **gist**. That way, they are easier to read for everyone, especially mobile users 👍"; + + message.reply(messageContent).setActionRow(Button.link(url, "gist")).queue(); + } + + private boolean isHelpThread(@NotNull MessageReceivedEvent event) { + if (event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) { + return false; + } + + ThreadChannel thread = event.getThreadChannel(); + String rootChannelName = thread.getParentChannel().getName(); + return isStagingChannelName.test(rootChannelName) + || isOverviewChannelName.test(rootChannelName); + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFile.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFile.java new file mode 100644 index 0000000000..cba4472b4e --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFile.java @@ -0,0 +1,10 @@ +package org.togetherjava.tjbot.commands.filesharing; + +import org.jetbrains.annotations.NotNull; + +/** + * @see Create a Gist via + * API + */ +record GistFile(@NotNull String content) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFiles.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFiles.java new file mode 100644 index 0000000000..3d3f8ca032 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistFiles.java @@ -0,0 +1,13 @@ +package org.togetherjava.tjbot.commands.filesharing; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * @see Create a Gist via + * API + */ +record GistFiles(@NotNull @JsonAnyGetter Map nameToContent) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistRequest.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistRequest.java new file mode 100644 index 0000000000..d1e1156547 --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistRequest.java @@ -0,0 +1,12 @@ +package org.togetherjava.tjbot.commands.filesharing; + +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.NotNull; + +/** + * @see Create a Gist via + * API + */ +record GistRequest(@NotNull String description, @JsonProperty("public") boolean isPublic, + @NotNull GistFiles files) { +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistResponse.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistResponse.java new file mode 100644 index 0000000000..af7d3f2ddf --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/GistResponse.java @@ -0,0 +1,23 @@ +package org.togetherjava.tjbot.commands.filesharing; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.jetbrains.annotations.NotNull; + +/** + * @see Create a Gist via + * API + */ +@JsonIgnoreProperties(ignoreUnknown = true) +final class GistResponse { + @JsonProperty("html_url") + private String htmlUrl; + + public @NotNull String getHtmlUrl() { + return this.htmlUrl; + } + + public void setHtmlUrl(@NotNull String htmlUrl) { + this.htmlUrl = htmlUrl; + } +} diff --git a/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/package-info.java b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/package-info.java new file mode 100644 index 0000000000..3eba1af69d --- /dev/null +++ b/application/src/main/java/org/togetherjava/tjbot/commands/filesharing/package-info.java @@ -0,0 +1,5 @@ +/** + * This package offers all the functionality for automatically uploading files to sharing services. + * The core class is {@link org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener}. + */ +package org.togetherjava.tjbot.commands.filesharing; 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 fb4ef623b8..9888d12191 100644 --- a/application/src/main/java/org/togetherjava/tjbot/config/Config.java +++ b/application/src/main/java/org/togetherjava/tjbot/config/Config.java @@ -14,6 +14,7 @@ */ public final class Config { private final String token; + private final String gistApiKey; private final String databasePath; private final String projectWebsite; private final String discordGuildInvite; @@ -31,6 +32,7 @@ public final class Config { @SuppressWarnings("ConstructorWithTooManyParameters") @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) private Config(@JsonProperty("token") String token, + @JsonProperty("gistApiKey") String gistApiKey, @JsonProperty("databasePath") String databasePath, @JsonProperty("projectWebsite") String projectWebsite, @JsonProperty("discordGuildInvite") String discordGuildInvite, @@ -45,6 +47,7 @@ private Config(@JsonProperty("token") String token, @JsonProperty("wolframAlphaAppId") String wolframAlphaAppId, @JsonProperty("helpSystem") HelpSystemConfig helpSystem) { this.token = token; + this.gistApiKey = gistApiKey; this.databasePath = databasePath; this.projectWebsite = projectWebsite; this.discordGuildInvite = discordGuildInvite; @@ -100,6 +103,18 @@ public String getToken() { return token; } + /** + * Gets the API Key of GitHub to upload pastes via the API. + * + * @return the upload services API Key + * @see Create + * a GitHub key + */ + public String getGistApiKey() { + return gistApiKey; + } + /** * Gets the path where the database of the application is located at. *