Skip to content

Commit

Permalink
Gist auto filesharing in help threads (#491)
Browse files Browse the repository at this point in the history
* initial commit

* initial commit

* delete

* worked on PR comments

* added api key

* Added default api key to config

* fixed api key

* changed to record

* removed comments

* added multiple uploads to one gist

* Worked on pr comments

* Fixed Sonarlint checks

* Added documentation

* Worked on pr comments

* Accidentally pushed

* pr comments

* pr comments

* pr comments

* pr comments

* pr comments
  • Loading branch information
SquidXTV authored Aug 19, 2022
1 parent 7130b76 commit 617eb81
Show file tree
Hide file tree
Showing 9 changed files with 295 additions and 4 deletions.
1 change: 1 addition & 0 deletions application/config.json.template
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"token": "<put_your_token_here>",
"gistApiKey": "<your_gist_personal_access_token>",
"databasePath": "local-database.db",
"projectWebsite": "https://github.com/Together-Java/TJ-Bot",
"discordGuildInvite": "https://discord.com/invite/XXFUXzK",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -82,6 +80,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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> extensionFilter = Set.of("txt", "java", "gradle", "xml", "kt", "json",
"fxml", "css", "c", "h", "cpp", "py", "yml");

private final Predicate<String> isStagingChannelName;
private final Predicate<String> 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<Message.Attachment> 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<Message.Attachment> attachments) {

Map<String, GistFile> nameToFile = new ConcurrentHashMap<>();

List<CompletableFuture<Void>> tasks = new ArrayList<>();
for (Message.Attachment attachment : attachments) {
CompletableFuture<Void> 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<String> 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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package org.togetherjava.tjbot.commands.filesharing;

import org.jetbrains.annotations.NotNull;

/**
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
* API</a>
*/
record GistFile(@NotNull String content) {
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
* API</a>
*/
record GistFiles(@NotNull @JsonAnyGetter Map<String, GistFile> nameToContent) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package org.togetherjava.tjbot.commands.filesharing;

import com.fasterxml.jackson.annotation.JsonProperty;
import org.jetbrains.annotations.NotNull;

/**
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
* API</a>
*/
record GistRequest(@NotNull String description, @JsonProperty("public") boolean isPublic,
@NotNull GistFiles files) {
}
Original file line number Diff line number Diff line change
@@ -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 <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
* API</a>
*/
@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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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 <a href=
* "https://docs.github.com/en/[email protected]/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token">Create
* a GitHub key</a>
*/
public String getGistApiKey() {
return gistApiKey;
}

/**
* Gets the path where the database of the application is located at.
*
Expand Down

0 comments on commit 617eb81

Please sign in to comment.