Skip to content

Commit 617eb81

Browse files
authored
Gist auto filesharing in help threads (#491)
* 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
1 parent 7130b76 commit 617eb81

File tree

9 files changed

+295
-4
lines changed

9 files changed

+295
-4
lines changed

application/config.json.template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
{
22
"token": "<put_your_token_here>",
3+
"gistApiKey": "<your_gist_personal_access_token>",
34
"databasePath": "local-database.db",
45
"projectWebsite": "https://github.com/Together-Java/TJ-Bot",
56
"discordGuildInvite": "https://discord.com/invite/XXFUXzK",

application/src/main/java/org/togetherjava/tjbot/commands/Features.java

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,8 @@
22

33
import net.dv8tion.jda.api.JDA;
44
import org.jetbrains.annotations.NotNull;
5-
import org.togetherjava.tjbot.commands.basic.PingCommand;
6-
import org.togetherjava.tjbot.commands.basic.RoleSelectCommand;
7-
import org.togetherjava.tjbot.commands.basic.SuggestionsUpDownVoter;
8-
import org.togetherjava.tjbot.commands.basic.VcActivityCommand;
5+
import org.togetherjava.tjbot.commands.basic.*;
6+
import org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener;
97
import org.togetherjava.tjbot.commands.help.*;
108
import org.togetherjava.tjbot.commands.mathcommands.TeXCommand;
119
import org.togetherjava.tjbot.commands.mathcommands.wolframalpha.WolframAlphaCommand;
@@ -82,6 +80,7 @@ public enum Features {
8280
features.add(new SuggestionsUpDownVoter(config));
8381
features.add(new ScamBlocker(actionsStore, scamHistoryStore, config));
8482
features.add(new ImplicitAskListener(config, helpSystemHelper));
83+
features.add(new FileSharingMessageListener(config));
8584

8685
// Event receivers
8786
features.add(new RejoinModerationRoleListener(actionsStore, config));
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
package org.togetherjava.tjbot.commands.filesharing;
2+
3+
import com.fasterxml.jackson.core.JsonProcessingException;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import net.dv8tion.jda.api.entities.ChannelType;
6+
import net.dv8tion.jda.api.entities.Message;
7+
import net.dv8tion.jda.api.entities.ThreadChannel;
8+
import net.dv8tion.jda.api.entities.User;
9+
import net.dv8tion.jda.api.events.message.MessageReceivedEvent;
10+
import net.dv8tion.jda.api.interactions.components.buttons.Button;
11+
import org.jetbrains.annotations.NotNull;
12+
import org.slf4j.Logger;
13+
import org.slf4j.LoggerFactory;
14+
import org.togetherjava.tjbot.commands.MessageReceiverAdapter;
15+
import org.togetherjava.tjbot.config.Config;
16+
17+
import java.io.IOException;
18+
import java.io.InputStream;
19+
import java.io.UncheckedIOException;
20+
import java.net.HttpURLConnection;
21+
import java.net.URI;
22+
import java.net.http.HttpClient;
23+
import java.net.http.HttpRequest;
24+
import java.net.http.HttpResponse;
25+
import java.nio.charset.StandardCharsets;
26+
import java.util.*;
27+
import java.util.concurrent.CompletableFuture;
28+
import java.util.concurrent.ConcurrentHashMap;
29+
import java.util.function.Predicate;
30+
import java.util.regex.Pattern;
31+
32+
/**
33+
* Listener that receives all sent help messages and uploads them to a share service if the message
34+
* contains a file with the given extension in the
35+
* {@link FileSharingMessageListener#extensionFilter}.
36+
*/
37+
public class FileSharingMessageListener extends MessageReceiverAdapter {
38+
39+
private static final Logger LOGGER = LoggerFactory.getLogger(FileSharingMessageListener.class);
40+
private static final ObjectMapper JSON = new ObjectMapper();
41+
42+
private static final String SHARE_API = "https://api.github.com/gists";
43+
private static final HttpClient CLIENT = HttpClient.newHttpClient();
44+
45+
private final String gistApiKey;
46+
private final Set<String> extensionFilter = Set.of("txt", "java", "gradle", "xml", "kt", "json",
47+
"fxml", "css", "c", "h", "cpp", "py", "yml");
48+
49+
private final Predicate<String> isStagingChannelName;
50+
private final Predicate<String> isOverviewChannelName;
51+
52+
53+
public FileSharingMessageListener(@NotNull Config config) {
54+
super(Pattern.compile(".*"));
55+
56+
gistApiKey = config.getGistApiKey();
57+
isStagingChannelName = Pattern.compile(config.getHelpSystem().getStagingChannelPattern())
58+
.asMatchPredicate();
59+
isOverviewChannelName = Pattern.compile(config.getHelpSystem().getOverviewChannelPattern())
60+
.asMatchPredicate();
61+
}
62+
63+
@Override
64+
public void onMessageReceived(@NotNull MessageReceivedEvent event) {
65+
User author = event.getAuthor();
66+
if (author.isBot() || event.isWebhookMessage()) {
67+
return;
68+
}
69+
70+
if (!isHelpThread(event)) {
71+
return;
72+
}
73+
74+
75+
List<Message.Attachment> attachments = event.getMessage()
76+
.getAttachments()
77+
.stream()
78+
.filter(this::isAttachmentRelevant)
79+
.toList();
80+
81+
CompletableFuture.runAsync(() -> {
82+
try {
83+
processAttachments(event, attachments);
84+
} catch (Exception e) {
85+
LOGGER.error("Unknown error while processing attachments", e);
86+
}
87+
});
88+
}
89+
90+
private boolean isAttachmentRelevant(@NotNull Message.Attachment attachment) {
91+
String extension = attachment.getFileExtension();
92+
if (extension == null) {
93+
return false;
94+
}
95+
return extensionFilter.contains(extension);
96+
}
97+
98+
99+
private void processAttachments(@NotNull MessageReceivedEvent event,
100+
@NotNull List<Message.Attachment> attachments) {
101+
102+
Map<String, GistFile> nameToFile = new ConcurrentHashMap<>();
103+
104+
List<CompletableFuture<Void>> tasks = new ArrayList<>();
105+
for (Message.Attachment attachment : attachments) {
106+
CompletableFuture<Void> task = attachment.retrieveInputStream()
107+
.thenApply(this::readAttachment)
108+
.thenAccept(
109+
content -> nameToFile.put(getNameOf(attachment), new GistFile(content)));
110+
111+
tasks.add(task);
112+
}
113+
114+
tasks.forEach(CompletableFuture::join);
115+
116+
GistFiles files = new GistFiles(nameToFile);
117+
GistRequest request = new GistRequest(event.getAuthor().getName(), false, files);
118+
String url = uploadToGist(request);
119+
sendResponse(event, url);
120+
}
121+
122+
private @NotNull String readAttachment(@NotNull InputStream stream) {
123+
try {
124+
return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
125+
} catch (IOException e) {
126+
throw new UncheckedIOException(e);
127+
}
128+
}
129+
130+
private @NotNull String getNameOf(@NotNull Message.Attachment attachment) {
131+
String fileName = attachment.getFileName();
132+
String fileExtension = attachment.getFileExtension();
133+
134+
if (fileExtension == null || fileExtension.equals("txt")) {
135+
fileExtension = "java";
136+
} else if (fileExtension.equals("fxml")) {
137+
fileExtension = "xml";
138+
}
139+
140+
int extensionIndex = fileName.lastIndexOf('.');
141+
if (extensionIndex != -1) {
142+
fileName = fileName.substring(0, extensionIndex);
143+
}
144+
145+
fileName += "." + fileExtension;
146+
147+
return fileName;
148+
}
149+
150+
private @NotNull String uploadToGist(@NotNull GistRequest jsonRequest) {
151+
String body;
152+
try {
153+
body = JSON.writeValueAsString(jsonRequest);
154+
} catch (JsonProcessingException e) {
155+
throw new IllegalStateException(
156+
"Attempting to upload a file to gist, but unable to create the JSON request.",
157+
e);
158+
}
159+
160+
HttpRequest request = HttpRequest.newBuilder()
161+
.uri(URI.create(SHARE_API))
162+
.header("Accept", "application/json")
163+
.header("Authorization", "token " + gistApiKey)
164+
.POST(HttpRequest.BodyPublishers.ofString(body))
165+
.build();
166+
167+
HttpResponse<String> apiResponse;
168+
try {
169+
apiResponse = CLIENT.send(request, HttpResponse.BodyHandlers.ofString());
170+
} catch (IOException e) {
171+
throw new UncheckedIOException(e);
172+
} catch (InterruptedException e) {
173+
Thread.currentThread().interrupt();
174+
throw new IllegalStateException(
175+
"Attempting to upload a file to gist, but the request got interrupted.", e);
176+
}
177+
178+
int statusCode = apiResponse.statusCode();
179+
180+
if (statusCode < HttpURLConnection.HTTP_OK
181+
|| statusCode >= HttpURLConnection.HTTP_MULT_CHOICE) {
182+
throw new IllegalStateException("Gist API unexpected response: " + apiResponse.body());
183+
}
184+
185+
GistResponse gistResponse;
186+
try {
187+
gistResponse = JSON.readValue(apiResponse.body(), GistResponse.class);
188+
} catch (JsonProcessingException e) {
189+
throw new IllegalStateException(
190+
"Attempting to upload file to gist, but unable to parse its JSON response.", e);
191+
}
192+
return gistResponse.getHtmlUrl();
193+
}
194+
195+
private void sendResponse(@NotNull MessageReceivedEvent event, @NotNull String url) {
196+
Message message = event.getMessage();
197+
String messageContent =
198+
"I uploaded your attachments as **gist**. That way, they are easier to read for everyone, especially mobile users 👍";
199+
200+
message.reply(messageContent).setActionRow(Button.link(url, "gist")).queue();
201+
}
202+
203+
private boolean isHelpThread(@NotNull MessageReceivedEvent event) {
204+
if (event.getChannelType() != ChannelType.GUILD_PUBLIC_THREAD) {
205+
return false;
206+
}
207+
208+
ThreadChannel thread = event.getThreadChannel();
209+
String rootChannelName = thread.getParentChannel().getName();
210+
return isStagingChannelName.test(rootChannelName)
211+
|| isOverviewChannelName.test(rootChannelName);
212+
}
213+
}
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
package org.togetherjava.tjbot.commands.filesharing;
2+
3+
import org.jetbrains.annotations.NotNull;
4+
5+
/**
6+
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
7+
* API</a>
8+
*/
9+
record GistFile(@NotNull String content) {
10+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package org.togetherjava.tjbot.commands.filesharing;
2+
3+
import com.fasterxml.jackson.annotation.JsonAnyGetter;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
import java.util.Map;
7+
8+
/**
9+
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
10+
* API</a>
11+
*/
12+
record GistFiles(@NotNull @JsonAnyGetter Map<String, GistFile> nameToContent) {
13+
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
package org.togetherjava.tjbot.commands.filesharing;
2+
3+
import com.fasterxml.jackson.annotation.JsonProperty;
4+
import org.jetbrains.annotations.NotNull;
5+
6+
/**
7+
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
8+
* API</a>
9+
*/
10+
record GistRequest(@NotNull String description, @JsonProperty("public") boolean isPublic,
11+
@NotNull GistFiles files) {
12+
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package org.togetherjava.tjbot.commands.filesharing;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import org.jetbrains.annotations.NotNull;
6+
7+
/**
8+
* @see <a href="https://docs.github.com/en/rest/gists/gists#create-a-gist">Create a Gist via
9+
* API</a>
10+
*/
11+
@JsonIgnoreProperties(ignoreUnknown = true)
12+
final class GistResponse {
13+
@JsonProperty("html_url")
14+
private String htmlUrl;
15+
16+
public @NotNull String getHtmlUrl() {
17+
return this.htmlUrl;
18+
}
19+
20+
public void setHtmlUrl(@NotNull String htmlUrl) {
21+
this.htmlUrl = htmlUrl;
22+
}
23+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
/**
2+
* This package offers all the functionality for automatically uploading files to sharing services.
3+
* The core class is {@link org.togetherjava.tjbot.commands.filesharing.FileSharingMessageListener}.
4+
*/
5+
package org.togetherjava.tjbot.commands.filesharing;

application/src/main/java/org/togetherjava/tjbot/config/Config.java

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
*/
1515
public final class Config {
1616
private final String token;
17+
private final String gistApiKey;
1718
private final String databasePath;
1819
private final String projectWebsite;
1920
private final String discordGuildInvite;
@@ -31,6 +32,7 @@ public final class Config {
3132
@SuppressWarnings("ConstructorWithTooManyParameters")
3233
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
3334
private Config(@JsonProperty("token") String token,
35+
@JsonProperty("gistApiKey") String gistApiKey,
3436
@JsonProperty("databasePath") String databasePath,
3537
@JsonProperty("projectWebsite") String projectWebsite,
3638
@JsonProperty("discordGuildInvite") String discordGuildInvite,
@@ -45,6 +47,7 @@ private Config(@JsonProperty("token") String token,
4547
@JsonProperty("wolframAlphaAppId") String wolframAlphaAppId,
4648
@JsonProperty("helpSystem") HelpSystemConfig helpSystem) {
4749
this.token = token;
50+
this.gistApiKey = gistApiKey;
4851
this.databasePath = databasePath;
4952
this.projectWebsite = projectWebsite;
5053
this.discordGuildInvite = discordGuildInvite;
@@ -100,6 +103,18 @@ public String getToken() {
100103
return token;
101104
}
102105

106+
/**
107+
* Gets the API Key of GitHub to upload pastes via the API.
108+
*
109+
* @return the upload services API Key
110+
* @see <a href=
111+
* "https://docs.github.com/en/enterprise-server@3.4/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token">Create
112+
* a GitHub key</a>
113+
*/
114+
public String getGistApiKey() {
115+
return gistApiKey;
116+
}
117+
103118
/**
104119
* Gets the path where the database of the application is located at.
105120
*

0 commit comments

Comments
 (0)