Skip to content

Gist auto filesharing in help threads #491

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Aug 19, 2022
Merged
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 @@ -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));
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/enterprise-server@3.4/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