Skip to content

Commit b6ca54f

Browse files
committed
message automod rules
1 parent f14b3d1 commit b6ca54f

File tree

7 files changed

+243
-16
lines changed

7 files changed

+243
-16
lines changed

src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import net.discordjug.javabot.data.config.SystemsConfig.ApiConfig;
1010
import net.discordjug.javabot.data.config.guild.HelpConfig;
1111
import net.discordjug.javabot.data.config.guild.MessageCacheConfig;
12+
import net.discordjug.javabot.data.config.guild.MessageRule;
1213
import net.discordjug.javabot.data.config.guild.MetricsConfig;
1314
import net.discordjug.javabot.data.config.guild.ModerationConfig;
1415
import net.discordjug.javabot.data.config.guild.QOTWConfig;
@@ -34,7 +35,7 @@
3435
@RegisterReflectionForBinding({
3536
//register config classes for reflection
3637
BotConfig.class, GuildConfig.class, GuildConfigItem.class, SystemsConfig.class, ApiConfig.class,
37-
HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class,
38+
HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class,MessageRule.class, MessageRule.MessageAction.class,
3839

3940
//needs to be serialized for channel managers etc
4041
PermOverrideData.class,

src/main/java/net/discordjug/javabot/data/config/GuildConfig.java

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import java.nio.file.Path;
2121
import java.util.List;
2222
import java.util.Optional;
23+
import java.util.regex.Pattern;
2324

2425
/**
2526
* A collection of guild-specific configuration items, each of which represents
@@ -70,7 +71,9 @@ public GuildConfig(Guild guild, Path file) {
7071
* @throws UncheckedIOException if an IO error occurs.
7172
*/
7273
public static GuildConfig loadOrCreate(Guild guild, Path file) {
73-
Gson gson = new GsonBuilder().create();
74+
Gson gson = new GsonBuilder()
75+
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
76+
.create();
7477
GuildConfig config;
7578
if (Files.exists(file)) {
7679
try (BufferedReader reader = Files.newBufferedReader(file)) {
@@ -115,7 +118,11 @@ private void setGuild(Guild guild) {
115118
* Saves this config to its file path.
116119
*/
117120
public synchronized void flush() {
118-
Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create();
121+
Gson gson = new GsonBuilder()
122+
.serializeNulls()
123+
.setPrettyPrinting()
124+
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
125+
.create();
119126
try (BufferedWriter writer = Files.newBufferedWriter(this.file)) {
120127
gson.toJson(this, writer);
121128
writer.flush();
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package net.discordjug.javabot.data.config;
2+
3+
import java.io.IOException;
4+
import java.util.regex.Pattern;
5+
6+
import com.google.gson.TypeAdapter;
7+
import com.google.gson.stream.JsonReader;
8+
import com.google.gson.stream.JsonToken;
9+
import com.google.gson.stream.JsonWriter;
10+
11+
public class PatternTypeAdapter extends TypeAdapter<Pattern> {
12+
13+
@Override
14+
public void write(JsonWriter writer, Pattern value) throws IOException {
15+
if (value == null) {
16+
writer.nullValue();
17+
return;
18+
}
19+
writer.value(value.toString());
20+
}
21+
22+
@Override
23+
public Pattern read(JsonReader reader) throws IOException {
24+
if (reader.peek() == JsonToken.NULL) {
25+
reader.nextNull();
26+
return null;
27+
}
28+
String value = reader.nextString();
29+
return Pattern.compile(value);
30+
}
31+
32+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package net.discordjug.javabot.data.config.guild;
2+
3+
import java.util.HashSet;
4+
import java.util.Set;
5+
import java.util.regex.Pattern;
6+
7+
import lombok.Data;
8+
9+
/**
10+
* If a message matches all of the given requirements of a rule, the configured action is performed on the message.
11+
*/
12+
@Data
13+
public class MessageRule {
14+
/**
15+
* Messages must match this regex for the rule to activate.
16+
*/
17+
private Pattern messageRegex;
18+
/**
19+
* All attachments of the message must match this regex for the rule to activate.
20+
*/
21+
private Pattern attachmentNameRegex;
22+
/**
23+
* The number of attachments must be greater than or equal to that field for the rule to activate.
24+
*/
25+
private int minAttachments = -1;
26+
/**
27+
* The number of attachments must be less than or equal to that field for the rule to activate.
28+
*/
29+
private int maxAttachments = Integer.MAX_VALUE;
30+
/**
31+
* At least one attachment must match at least one of the SHA hashes for the rule to activate.
32+
* If this set is empty, this condition is ignored.
33+
*/
34+
private Set<String> attachmentSHAs = new HashSet<>();
35+
36+
/**
37+
* The action to execute on the message
38+
*/
39+
private MessageAction action = MessageAction.LOG;
40+
41+
public enum MessageAction {
42+
/**
43+
* The message is logged to a channel.
44+
*/
45+
LOG,
46+
/**
47+
* The message is deleted and logged to a channel.
48+
*/
49+
BLOCK
50+
}
51+
}

src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
99
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;
1010

11+
import java.util.ArrayList;
1112
import java.util.List;
1213

1314
/**
@@ -98,12 +99,17 @@ public class ModerationConfig extends GuildConfigItem {
9899
* The ID of the voice channel template that lets users create their own voice channels.
99100
*/
100101
private long customVoiceChannelId;
101-
102+
102103
/**
103104
* Text that is sent to users when they're banned.
104105
*/
105106
private String banMessageText = "Looks like you've been banned from the Java Discord. If you want to appeal this decision please fill out our form at <https://airtable.com/shrp5V4H1U5TYOXyC>.";
106107

108+
/**
109+
* A list of rules that can result in a message being blocked or similar
110+
*/
111+
private List<MessageRule> messageRules = new ArrayList<>();
112+
107113
public TextChannel getReportChannel() {
108114
return this.getGuild().getTextChannelById(this.reportChannelId);
109115
}

src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -188,23 +188,23 @@ private void requestMessageAttachments(CachedMessage message) {
188188
}
189189
}
190190

191-
private EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage before) {
191+
public EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage before, String contentFieldName) {
192192
long epoch = IdCalculatorCommand.getTimestampFromId(before.getMessageId()) / 1000;
193193
return new EmbedBuilder()
194194
.setAuthor(UserUtils.getUserTag(author), null, author.getEffectiveAvatarUrl())
195195
.addField("Author", author.getAsMention(), true)
196196
.addField("Channel", channel.getAsMention(), true)
197197
.addField("Created at", String.format("<t:%s:F>", epoch), true)
198-
.setFooter("ID: " + before.getMessageId());
198+
.setFooter("ID: " + before.getMessageId())
199+
.addField(contentFieldName,
200+
before.getMessageContent().substring(0, Math.min(before.getMessageContent().length(), MessageEmbed.VALUE_MAX_LENGTH)),
201+
false);
199202
}
200203

201204
private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChannel channel, CachedMessage before, Message after) {
202-
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before)
205+
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before, "Before")
203206
.setTitle("Message Edited")
204207
.setColor(Responses.Type.WARN.getColor())
205-
.addField("Before", before.getMessageContent().substring(0, Math.min(
206-
before.getMessageContent().length(),
207-
MessageEmbed.VALUE_MAX_LENGTH)), false)
208208
.addField("After", after.getContentRaw().substring(0, Math.min(
209209
after.getContentRaw().length(),
210210
MessageEmbed.VALUE_MAX_LENGTH)), false);
@@ -226,13 +226,9 @@ private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChan
226226
}
227227

228228
private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageChannel channel, CachedMessage message) {
229-
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message)
229+
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message, "Message Content")
230230
.setTitle("Message Deleted")
231-
.setColor(Responses.Type.ERROR.getColor())
232-
.addField("Message Content",
233-
message.getMessageContent().substring(0, Math.min(
234-
message.getMessageContent().length(),
235-
MessageEmbed.VALUE_MAX_LENGTH)), false);
231+
.setColor(Responses.Type.ERROR.getColor());
236232
if (!message.getAttachments().isEmpty()) {
237233
addAttachmentsToMessageBuilder(message, eb);
238234
}
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
package net.discordjug.javabot.listener.filter;
2+
3+
import java.io.IOException;
4+
import java.net.URI;
5+
import java.net.http.HttpClient;
6+
import java.net.http.HttpRequest;
7+
import java.net.http.HttpResponse;
8+
import java.net.http.HttpResponse.BodyHandlers;
9+
import java.security.MessageDigest;
10+
import java.security.NoSuchAlgorithmException;
11+
import java.time.Duration;
12+
import java.util.Base64;
13+
import java.util.List;
14+
import java.util.regex.Pattern;
15+
import java.util.stream.Collectors;
16+
17+
import com.google.gson.Gson;
18+
import com.google.gson.GsonBuilder;
19+
import lombok.RequiredArgsConstructor;
20+
import net.discordjug.javabot.data.config.BotConfig;
21+
import net.discordjug.javabot.data.config.PatternTypeAdapter;
22+
import net.discordjug.javabot.data.config.guild.MessageRule;
23+
import net.discordjug.javabot.data.config.guild.MessageRule.MessageAction;
24+
import net.discordjug.javabot.data.config.guild.ModerationConfig;
25+
import net.discordjug.javabot.data.h2db.message_cache.MessageCache;
26+
import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage;
27+
import net.discordjug.javabot.util.ExceptionLogger;
28+
import net.dv8tion.jda.api.EmbedBuilder;
29+
import net.dv8tion.jda.api.entities.Message.Attachment;
30+
import net.dv8tion.jda.api.entities.Message;
31+
import net.dv8tion.jda.api.entities.MessageEmbed;
32+
import org.springframework.stereotype.Component;
33+
34+
/**
35+
* This {@link MessageFilter} acts on messages according to {@link MessageRule}s.
36+
* If a message rule matches, the corresponding action is executed.
37+
*/
38+
@Component
39+
@RequiredArgsConstructor
40+
public class MessageRuleFilter implements MessageFilter {
41+
42+
private final BotConfig botConfig;
43+
private final MessageCache messageCache;
44+
45+
@Override
46+
public MessageModificationStatus processMessage(MessageContent content) {
47+
48+
ModerationConfig moderationConfig = botConfig.get(content.event().getGuild()).getModerationConfig();
49+
List<MessageRule> messageRules = moderationConfig.getMessageRules();
50+
51+
MessageRule ruleToExecute = null;
52+
for (MessageRule rule : messageRules) {
53+
if (matches(content, rule)) {
54+
if (ruleToExecute == null || rule.getAction() == MessageAction.BLOCK) {
55+
ruleToExecute = rule;
56+
}
57+
}
58+
}
59+
MessageModificationStatus status = MessageModificationStatus.NOT_MODIFIED;
60+
if (ruleToExecute != null) {
61+
if (ruleToExecute.getAction() == MessageAction.BLOCK) {
62+
content.event().getMessage().delete()
63+
.flatMap(_ -> content.event().getChannel().sendMessage(content.event().getAuthor().getAsMention() + " Your message has been deleted for moderative reasons. If you believe this happened by mistake, please contact the server staff."))
64+
.delay(Duration.ofSeconds(60))
65+
.flatMap(Message::delete)
66+
.queue();
67+
status = MessageModificationStatus.STOP_PROCESSING;
68+
}
69+
log(content, ruleToExecute, moderationConfig);
70+
}
71+
72+
return status;
73+
}
74+
75+
private void log(MessageContent content, MessageRule ruleToExecute, ModerationConfig moderationConfig) {
76+
Gson gson = new GsonBuilder()
77+
.serializeNulls()
78+
.setPrettyPrinting()
79+
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
80+
.create();
81+
EmbedBuilder embed = messageCache.buildMessageCacheEmbed(
82+
content.event().getMessage().getChannel(),
83+
content.event().getMessage().getAuthor(),
84+
CachedMessage.of(content.event().getMessage()), "Message content")
85+
.setTitle("Message rule triggered")
86+
.addField("Rule description", "```\n" + gson.toJson(ruleToExecute) + "\n```", false);
87+
if (!content.attachments().isEmpty()) {
88+
embed.addField("Attachment hashes", computeAttachmentDescription(content.attachments()), false);
89+
}
90+
content.event().getChannel().sendMessageEmbeds(embed.build()).queue();
91+
}
92+
93+
private boolean matches(MessageContent content, MessageRule rule) {
94+
if (rule.getMessageRegex() != null && !rule.getMessageRegex().matcher(content.messageText()).matches()) {
95+
return false;
96+
}
97+
if (content.attachments().size() > rule.getMaxAttachments()) {
98+
return false;
99+
}
100+
if (content.attachments().size() < rule.getMinAttachments()) {
101+
return false;
102+
}
103+
boolean matchesSHA = rule.getAttachmentSHAs().isEmpty();
104+
for (Attachment attachment : content.attachments()) {
105+
if (rule.getAttachmentNameRegex() != null && !rule.getAttachmentNameRegex().matcher(attachment.getFileName()).matches()) {
106+
return false;
107+
}
108+
if (!matchesSHA) {
109+
if (rule.getAttachmentSHAs().contains(computeSHA(attachment))) {
110+
matchesSHA = true;
111+
}
112+
}
113+
}
114+
return matchesSHA;
115+
}
116+
117+
private String computeAttachmentDescription(List<Message.Attachment> attachments) {
118+
return attachments.stream()
119+
.map(attachment -> "- " + attachment.getUrl() + ": `" + computeSHA(attachment) + "`")
120+
.collect(Collectors.joining("\n"));
121+
}
122+
123+
private String computeSHA(Attachment attachment) {
124+
try {
125+
HttpResponse<byte[]> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(attachment.getProxyUrl())).build(), BodyHandlers.ofByteArray());
126+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
127+
byte[] hash = digest.digest(res.body());
128+
return Base64.getEncoder().encodeToString(hash);
129+
} catch (IOException | InterruptedException | NoSuchAlgorithmException e) {
130+
ExceptionLogger.capture(e);
131+
return "";
132+
}
133+
}
134+
}

0 commit comments

Comments
 (0)