Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import net.discordjug.javabot.data.config.SystemsConfig.ApiConfig;
import net.discordjug.javabot.data.config.guild.HelpConfig;
import net.discordjug.javabot.data.config.guild.MessageCacheConfig;
import net.discordjug.javabot.data.config.guild.MessageRule;
import net.discordjug.javabot.data.config.guild.MetricsConfig;
import net.discordjug.javabot.data.config.guild.ModerationConfig;
import net.discordjug.javabot.data.config.guild.QOTWConfig;
Expand All @@ -34,7 +35,7 @@
@RegisterReflectionForBinding({
//register config classes for reflection
BotConfig.class, GuildConfig.class, GuildConfigItem.class, SystemsConfig.class, ApiConfig.class,
HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class,
HelpConfig.class, MessageCacheConfig.class, MetricsConfig.class, ModerationConfig.class, QOTWConfig.class, ServerLockConfig.class, StarboardConfig.class,MessageRule.class, MessageRule.MessageAction.class,

//needs to be serialized for channel managers etc
PermOverrideData.class,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.regex.Pattern;

/**
* A collection of guild-specific configuration items, each of which represents
Expand Down Expand Up @@ -70,7 +71,9 @@ public GuildConfig(Guild guild, Path file) {
* @throws UncheckedIOException if an IO error occurs.
*/
public static GuildConfig loadOrCreate(Guild guild, Path file) {
Gson gson = new GsonBuilder().create();
Gson gson = new GsonBuilder()
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
.create();
GuildConfig config;
if (Files.exists(file)) {
try (BufferedReader reader = Files.newBufferedReader(file)) {
Expand Down Expand Up @@ -115,7 +118,11 @@ private void setGuild(Guild guild) {
* Saves this config to its file path.
*/
public synchronized void flush() {
Gson gson = new GsonBuilder().serializeNulls().setPrettyPrinting().create();
Gson gson = new GsonBuilder()
.serializeNulls()
.setPrettyPrinting()
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
.create();
try (BufferedWriter writer = Files.newBufferedWriter(this.file)) {
gson.toJson(this, writer);
writer.flush();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package net.discordjug.javabot.data.config;

import java.io.IOException;
import java.util.regex.Pattern;

import com.google.gson.TypeAdapter;
import com.google.gson.stream.JsonReader;
import com.google.gson.stream.JsonToken;
import com.google.gson.stream.JsonWriter;

/**
* A gson {@link TypeAdapter} that allows serializing and deserializing regex {@link Pattern}s.
*/
public class PatternTypeAdapter extends TypeAdapter<Pattern> {

@Override
public void write(JsonWriter writer, Pattern value) throws IOException {
if (value == null) {
writer.nullValue();
return;
}
writer.value(value.toString());
}

@Override
public Pattern read(JsonReader reader) throws IOException {
if (reader.peek() == JsonToken.NULL) {
reader.nextNull();
return null;
}
String value = reader.nextString();
return Pattern.compile(value);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package net.discordjug.javabot.data.config.guild;

import java.util.HashSet;
import java.util.Set;
import java.util.regex.Pattern;

import lombok.Data;

/**
* If a message matches all of the given requirements of a rule, the configured action is performed on the message.
*/
@Data
public class MessageRule {
/**
* Messages must match this regex for the rule to activate.
*/
private Pattern messageRegex;
/**
* All attachments of the message must match this regex for the rule to activate.
*/
private Pattern attachmentNameRegex;
/**
* The number of attachments must be greater than or equal to that field for the rule to activate.
*/
private int minAttachments = -1;
/**
* The number of attachments must be less than or equal to that field for the rule to activate.
*/
private int maxAttachments = Integer.MAX_VALUE;
/**
* At least one attachment must match at least one of the SHA hashes for the rule to activate.
* If this set is empty, this condition is ignored.
*/
private Set<String> attachmentSHAs = new HashSet<>();

/**
* The action to execute on the message.
*/
private MessageAction action = MessageAction.LOG;

/**
* Enum for actions that can be performed on messages based on rules.
*/
public enum MessageAction {
/**
* The message is logged to a channel.
*/
LOG,
/**
* The message is deleted and logged to a channel.
*/
BLOCK
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import net.dv8tion.jda.api.entities.channel.concrete.TextChannel;
import net.dv8tion.jda.api.entities.channel.concrete.VoiceChannel;

import java.util.ArrayList;
import java.util.List;

/**
Expand Down Expand Up @@ -98,12 +99,17 @@ public class ModerationConfig extends GuildConfigItem {
* The ID of the voice channel template that lets users create their own voice channels.
*/
private long customVoiceChannelId;

/**
* Text that is sent to users when they're banned.
*/
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>.";

/**
* A list of rules that can result in a message being blocked or similar.
*/
private List<MessageRule> messageRules = new ArrayList<>();

public TextChannel getReportChannel() {
return this.getGuild().getTextChannelById(this.reportChannelId);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,23 +188,31 @@ private void requestMessageAttachments(CachedMessage message) {
}
}

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

private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChannel channel, CachedMessage before, Message after) {
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before)
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, before, "Before")
.setTitle("Message Edited")
.setColor(Responses.Type.WARN.getColor())
.addField("Before", before.getMessageContent().substring(0, Math.min(
before.getMessageContent().length(),
MessageEmbed.VALUE_MAX_LENGTH)), false)
.addField("After", after.getContentRaw().substring(0, Math.min(
after.getContentRaw().length(),
MessageEmbed.VALUE_MAX_LENGTH)), false);
Expand All @@ -226,13 +234,9 @@ private MessageEmbed buildMessageEditEmbed(Guild guild, User author, MessageChan
}

private MessageEmbed buildMessageDeleteEmbed(Guild guild, User author, MessageChannel channel, CachedMessage message) {
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message)
EmbedBuilder eb = buildMessageCacheEmbed(channel, author, message, "Message Content")
.setTitle("Message Deleted")
.setColor(Responses.Type.ERROR.getColor())
.addField("Message Content",
message.getMessageContent().substring(0, Math.min(
message.getMessageContent().length(),
MessageEmbed.VALUE_MAX_LENGTH)), false);
.setColor(Responses.Type.ERROR.getColor());
if (!message.getAttachments().isEmpty()) {
addAttachmentsToMessageBuilder(message, eb);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package net.discordjug.javabot.listener.filter;

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import lombok.RequiredArgsConstructor;
import net.discordjug.javabot.data.config.BotConfig;
import net.discordjug.javabot.data.config.PatternTypeAdapter;
import net.discordjug.javabot.data.config.guild.MessageRule;
import net.discordjug.javabot.data.config.guild.MessageRule.MessageAction;
import net.discordjug.javabot.data.config.guild.ModerationConfig;
import net.discordjug.javabot.data.h2db.message_cache.MessageCache;
import net.discordjug.javabot.data.h2db.message_cache.model.CachedMessage;
import net.discordjug.javabot.util.Checks;
import net.discordjug.javabot.util.ExceptionLogger;
import net.dv8tion.jda.api.EmbedBuilder;
import net.dv8tion.jda.api.entities.Message.Attachment;
import net.dv8tion.jda.api.entities.Message;
import org.springframework.stereotype.Component;

/**
* This {@link MessageFilter} acts on messages according to {@link MessageRule}s.
* If a message rule matches, the corresponding action is executed.
*/
@Component
@RequiredArgsConstructor
public class MessageRuleFilter implements MessageFilter {

private final BotConfig botConfig;
private final MessageCache messageCache;

@Override
public MessageModificationStatus processMessage(MessageContent content) {

ModerationConfig moderationConfig = botConfig.get(content.event().getGuild()).getModerationConfig();
List<MessageRule> messageRules = moderationConfig.getMessageRules();

MessageRule ruleToExecute = null;
for (MessageRule rule : messageRules) {
if (matches(content, rule)) {
if (ruleToExecute == null || rule.getAction() == MessageAction.BLOCK) {
ruleToExecute = rule;
}
}
}
MessageModificationStatus status = MessageModificationStatus.NOT_MODIFIED;
if (ruleToExecute != null) {
if (ruleToExecute.getAction() == MessageAction.BLOCK && !Checks.hasStaffRole(botConfig, content.event().getMember())) {
content.event().getMessage().delete()
.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."))
.delay(Duration.ofSeconds(60))
.flatMap(Message::delete)
.queue();
status = MessageModificationStatus.STOP_PROCESSING;
}
log(content, ruleToExecute, moderationConfig);
}

return status;
}

private void log(MessageContent content, MessageRule ruleToExecute, ModerationConfig moderationConfig) {
Gson gson = new GsonBuilder()
.serializeNulls()
.setPrettyPrinting()
.registerTypeAdapter(Pattern.class, new PatternTypeAdapter())
.create();
EmbedBuilder embed = messageCache.buildMessageCacheEmbed(
content.event().getMessage().getChannel(),
content.event().getMessage().getAuthor(),
CachedMessage.of(content.event().getMessage()), "Message content")
.setTitle("Message rule triggered")
.addField("Rule description", "```\n" + gson.toJson(ruleToExecute) + "\n```", false);
if (!content.attachments().isEmpty()) {
embed.addField("Attachment hashes", computeAttachmentDescription(content.attachments()), false);
}
content.event().getChannel().sendMessageEmbeds(embed.build()).queue();
}

private boolean matches(MessageContent content, MessageRule rule) {
if (rule.getMessageRegex() != null && !rule.getMessageRegex().matcher(content.messageText()).matches()) {
return false;
}
if (content.attachments().size() > rule.getMaxAttachments()) {
return false;
}
if (content.attachments().size() < rule.getMinAttachments()) {
return false;
}
boolean matchesSHA = rule.getAttachmentSHAs().isEmpty();
for (Attachment attachment : content.attachments()) {
if (rule.getAttachmentNameRegex() != null && !rule.getAttachmentNameRegex().matcher(attachment.getFileName()).matches()) {
return false;
}
if (!matchesSHA) {
if (rule.getAttachmentSHAs().contains(computeSHA(attachment))) {
matchesSHA = true;
}
}
}
return matchesSHA;
}

private String computeAttachmentDescription(List<Message.Attachment> attachments) {
return attachments.stream()
.map(attachment -> "- " + attachment.getUrl() + ": `" + computeSHA(attachment) + "`")
.collect(Collectors.joining("\n"));
}

private String computeSHA(Attachment attachment) {
try {
HttpResponse<byte[]> res = HttpClient.newHttpClient().send(HttpRequest.newBuilder(URI.create(attachment.getProxyUrl())).build(), BodyHandlers.ofByteArray());
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(res.body());
return Base64.getEncoder().encodeToString(hash);
} catch (IOException | InterruptedException | NoSuchAlgorithmException e) {
ExceptionLogger.capture(e);
return "";
}
}
}