Skip to content

Commit 38c9404

Browse files
authored
Merge pull request #528 from danthe1st/message-mod-rules
message automod rules
2 parents 70172b0 + b038163 commit 38c9404

File tree

7 files changed

+258
-17
lines changed

7 files changed

+258
-17
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: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
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+
/**
12+
* A gson {@link TypeAdapter} that allows serializing and deserializing regex {@link Pattern}s.
13+
*/
14+
public class PatternTypeAdapter extends TypeAdapter<Pattern> {
15+
16+
@Override
17+
public void write(JsonWriter writer, Pattern value) throws IOException {
18+
if (value == null) {
19+
writer.nullValue();
20+
return;
21+
}
22+
writer.value(value.toString());
23+
}
24+
25+
@Override
26+
public Pattern read(JsonReader reader) throws IOException {
27+
if (reader.peek() == JsonToken.NULL) {
28+
reader.nextNull();
29+
return null;
30+
}
31+
String value = reader.nextString();
32+
return Pattern.compile(value);
33+
}
34+
35+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
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+
/**
42+
* Enum for actions that can be performed on messages based on rules.
43+
*/
44+
public enum MessageAction {
45+
/**
46+
* The message is logged to a channel.
47+
*/
48+
LOG,
49+
/**
50+
* The message is deleted and logged to a channel.
51+
*/
52+
BLOCK
53+
}
54+
}

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: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -188,23 +188,31 @@ private void requestMessageAttachments(CachedMessage message) {
188188
}
189189
}
190190

191-
private EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage before) {
192-
long epoch = IdCalculatorCommand.getTimestampFromId(before.getMessageId()) / 1000;
191+
/**
192+
* Creates an {@link EmbedBuilder} with information about a cached message.
193+
* @param channel The channel the message was sent in.
194+
* @param author The author of the message.
195+
* @param message The message to extract the information from as a {@link CachedMessage}.
196+
* @param contentFieldName the name of the field containing the message content in the embed.
197+
* @return an {@link EmbedBuilder} with information about the message.
198+
*/
199+
public EmbedBuilder buildMessageCacheEmbed(MessageChannel channel, User author, CachedMessage message, String contentFieldName) {
200+
long epoch = IdCalculatorCommand.getTimestampFromId(message.getMessageId()) / 1000;
193201
return new EmbedBuilder()
194202
.setAuthor(UserUtils.getUserTag(author), null, author.getEffectiveAvatarUrl())
195203
.addField("Author", author.getAsMention(), true)
196204
.addField("Channel", channel.getAsMention(), true)
197205
.addField("Created at", String.format("<t:%s:F>", epoch), true)
198-
.setFooter("ID: " + before.getMessageId());
206+
.setFooter("ID: " + message.getMessageId())
207+
.addField(contentFieldName,
208+
message.getMessageContent().substring(0, Math.min(message.getMessageContent().length(), MessageEmbed.VALUE_MAX_LENGTH)),
209+
false);
199210
}
200211

201212
private MessageEmbed buildMessageEditEmbed(User author, MessageChannel channel, CachedMessage before, Message after) {
202-
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before)
213+
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before, "Before")
203214
.setTitle("Message Edited")
204215
.setColor(Responses.Type.WARN.getColor())
205-
.addField("Before", before.getMessageContent().substring(0, Math.min(
206-
before.getMessageContent().length(),
207-
MessageEmbed.VALUE_MAX_LENGTH)), false)
208216
.addField("After", after.getContentRaw().substring(0, Math.min(
209217
after.getContentRaw().length(),
210218
MessageEmbed.VALUE_MAX_LENGTH)), false);
@@ -226,13 +234,9 @@ private MessageEmbed buildMessageEditEmbed(User author, MessageChannel channel,
226234
}
227235

228236
private MessageEmbed buildMessageDeleteEmbed(User author, MessageChannel channel, CachedMessage message) {
229-
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message)
237+
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message, "Message Content")
230238
.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);
239+
.setColor(Responses.Type.ERROR.getColor());
236240
if (!message.getAttachments().isEmpty()) {
237241
addAttachmentsToMessageBuilder(message, eb);
238242
}
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.Checks;
28+
import net.discordjug.javabot.util.ExceptionLogger;
29+
import net.dv8tion.jda.api.EmbedBuilder;
30+
import net.dv8tion.jda.api.entities.Message.Attachment;
31+
import net.dv8tion.jda.api.entities.Message;
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 && !Checks.hasStaffRole(botConfig, content.event().getMember())) {
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)