diff --git a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java index a41aaa565..0aead6bde 100644 --- a/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java +++ b/src/main/java/net/discordjug/javabot/RuntimeHintsConfiguration.java @@ -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; @@ -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, diff --git a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java index 8822b4705..a98f5dbe5 100644 --- a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java @@ -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 @@ -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)) { @@ -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(); diff --git a/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java b/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java new file mode 100644 index 000000000..dbdd4a708 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/data/config/PatternTypeAdapter.java @@ -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 { + + @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); + } + +} diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java b/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java new file mode 100644 index 000000000..68f742d21 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/data/config/guild/MessageRule.java @@ -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 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 + } +} diff --git a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java index 88f369205..565178783 100644 --- a/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/guild/ModerationConfig.java @@ -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; /** @@ -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 ."; + /** + * A list of rules that can result in a message being blocked or similar. + */ + private List messageRules = new ArrayList<>(); + public TextChannel getReportChannel() { return this.getGuild().getTextChannelById(this.reportChannelId); } diff --git a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java index 236f4631e..005c9a66e 100644 --- a/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java +++ b/src/main/java/net/discordjug/javabot/data/h2db/message_cache/MessageCache.java @@ -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("", 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); @@ -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); } diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java new file mode 100644 index 000000000..fe0221b14 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageRuleFilter.java @@ -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 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 attachments) { + return attachments.stream() + .map(attachment -> "- " + attachment.getUrl() + ": `" + computeSHA(attachment) + "`") + .collect(Collectors.joining("\n")); + } + + private String computeSHA(Attachment attachment) { + try { + HttpResponse 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 ""; + } + } +}