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 6e57bfe0e..8822b4705 100644 --- a/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java +++ b/src/main/java/net/discordjug/javabot/data/config/GuildConfig.java @@ -18,6 +18,7 @@ import java.lang.reflect.Field; import java.nio.file.Files; import java.nio.file.Path; +import java.util.List; import java.util.Optional; /** @@ -37,6 +38,7 @@ public class GuildConfig { private StarboardConfig starboardConfig; private MessageCacheConfig messageCacheConfig; private ServerLockConfig serverLockConfig; + private List blacklistedMessageExtensions = List.of("jar", "exe", "zip"); /** * Constructor that initializes all Config classes. diff --git a/src/main/java/net/discordjug/javabot/listener/HugListener.java b/src/main/java/net/discordjug/javabot/listener/HugListener.java deleted file mode 100644 index 46eac3ea9..000000000 --- a/src/main/java/net/discordjug/javabot/listener/HugListener.java +++ /dev/null @@ -1,120 +0,0 @@ -package net.discordjug.javabot.listener; - -import lombok.RequiredArgsConstructor; -import net.discordjug.javabot.data.config.BotConfig; -import net.discordjug.javabot.systems.moderation.AutoMod; -import net.discordjug.javabot.util.ExceptionLogger; -import net.discordjug.javabot.util.WebhookUtil; -import net.dv8tion.jda.api.entities.channel.ChannelType; -import net.dv8tion.jda.api.entities.channel.concrete.TextChannel; -import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; -import net.dv8tion.jda.api.events.message.MessageReceivedEvent; -import net.dv8tion.jda.api.hooks.ListenerAdapter; - -import javax.annotation.Nonnull; -import java.util.Objects; -import java.util.regex.Pattern; - -/** - * Replaces all occurrences of 'fuck' in incoming messages with 'hug'. - */ -@RequiredArgsConstructor -public class HugListener extends ListenerAdapter { - private static final Pattern FUCKER = Pattern.compile( - "(fuck)(ing|er|ed|k+)?", - Pattern.CASE_INSENSITIVE - ); - private final AutoMod autoMod; - private final BotConfig botConfig; - - private static String processHug(String originalText) { - // FucK -> HuG, FuCk -> Hug - return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + - copyCase(originalText, 3, 'g'); - } - - private static String replaceFucks(String str) { - return FUCKER.matcher(str).replaceAll(matchResult -> { - String theFuck = matchResult.group(1); - String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); - String processedSuffix = switch (suffix.toLowerCase()) { - case "er", "ed", "ing" -> - copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked - case "" -> ""; // just fuck - default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... - }; - return processHug(theFuck) + processedSuffix; - }); - } - - private static String copyCase(String source, String toChange) { - if (source.length() != toChange.length()) { - throw new IllegalArgumentException("lengths differ"); - } - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < source.length(); i++) { - sb.append(copyCase(source, i, toChange.charAt(i))); - } - return sb.toString(); - } - - private static char copyCase(String original, int index, char newChar) { - if (Character.isUpperCase(original.charAt(index))) { - return Character.toUpperCase(newChar); - } else { - return newChar; - } - } - - @Override - public void onMessageReceived(@Nonnull MessageReceivedEvent event) { - if (!event.isFromGuild()) { - return; - } - if (autoMod.hasSuspiciousLink(event.getMessage()) || - autoMod.hasAdvertisingLink(event.getMessage())) { - return; - } - if (!event.getMessage().getMentions().getUsers().isEmpty()) { - return; - } - if (event.isWebhookMessage()) { - return; - } - if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) - .getModerationConfig() - .getSuggestionChannelId()) { - return; - } - TextChannel tc = null; - if (event.isFromType(ChannelType.TEXT)) { - tc = event.getChannel().asTextChannel(); - } - if (event.isFromThread()) { - StandardGuildChannel parentChannel = event.getChannel() - .asThreadChannel() - .getParentChannel() - .asStandardGuildChannel(); - if (parentChannel instanceof TextChannel textChannel) { - tc = textChannel; - } - } - if (tc == null) { - return; - - } - String content = event.getMessage().getContentRaw(); - if (FUCKER.matcher(content).find()) { - long threadId = event.isFromThread() ? event.getChannel().getIdLong() : 0; - WebhookUtil.ensureWebhookExists( - tc, - wh -> WebhookUtil.replaceMemberMessage(wh, event.getMessage(), - replaceFucks(content), threadId - ), - e -> ExceptionLogger.capture(e, getClass().getSimpleName()) - ); - } - } - - -} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java new file mode 100644 index 000000000..e9db4e99b --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/BlacklistedMessageAttachmentFilter.java @@ -0,0 +1,41 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.data.config.GuildConfig; +import net.dv8tion.jda.api.EmbedBuilder; +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import org.springframework.stereotype.Component; + +import java.util.List; + +/** + * This {@link MessageFilter} blocks attachments blacklisted using {@link GuildConfig}. + */ +@Component +@RequiredArgsConstructor +public class BlacklistedMessageAttachmentFilter implements MessageFilter { + + private final BotConfig botConfig; + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + MessageReceivedEvent event = content.event(); + List attachments = content.attachments(); + List embeds = content.embeds(); + GuildConfig guildConfig = botConfig.get(event.getGuild()); + List blacklistedMessageExtensions = guildConfig.getBlacklistedMessageExtensions(); + boolean removed = attachments.removeIf(attachment -> blacklistedMessageExtensions.contains(attachment.getFileExtension())); + if (removed) { + MessageEmbed attachmentRemovedInfo = new EmbedBuilder() + .setDescription("Disallowed attachments have been removed from this message.") + .build(); + embeds.add(attachmentRemovedInfo); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java new file mode 100644 index 000000000..63785dc83 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/HugFilter.java @@ -0,0 +1,74 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * This {@link MessageFilter} replaces all occurrences of 'fuck' in incoming messages with 'hug'. + */ +@Component +@RequiredArgsConstructor +public class HugFilter implements MessageFilter { + + private static final Pattern FUCKER = Pattern.compile( + "(fuck)(ing|er|ed|k+)?", + Pattern.CASE_INSENSITIVE + ); + + @Override + public MessageModificationStatus processMessage(MessageContent content) { + if (!content.event().getMessage().getMentions().getUsers().isEmpty()) { + return MessageModificationStatus.NOT_MODIFIED; + } + String before = content.messageText().toString(); + String processed = replaceFucks(content.messageText().toString()); + if (!before.equals(processed)) { + content.messageText().setLength(0); + content.messageText().append(processed); + return MessageModificationStatus.MODIFIED; + } else { + return MessageModificationStatus.NOT_MODIFIED; + } + } + + private static String processHug(String originalText) { + // FucK -> HuG, FuCk -> Hug + return String.valueOf(copyCase(originalText, 0, 'h')) + copyCase(originalText, 1, 'u') + + copyCase(originalText, 3, 'g'); + } + + private static String replaceFucks(String str) { + return FUCKER.matcher(str).replaceAll(matchResult -> { + String theFuck = matchResult.group(1); + String suffix = Objects.requireNonNullElse(matchResult.group(2), ""); + String processedSuffix = switch (suffix.toLowerCase()) { + case "er", "ed", "ing" -> copyCase(suffix, 0, 'g') + suffix; // fucking, fucker, fucked + case "" -> ""; // just fuck + default -> copyCase(suffix, "g".repeat(suffix.length())); // fuckkkkk... + }; + return processHug(theFuck) + processedSuffix; + }); + } + + private static String copyCase(String source, String toChange) { + if (source.length() != toChange.length()) { + throw new IllegalArgumentException("lengths differ"); + } + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < source.length(); i++) { + sb.append(copyCase(source, i, toChange.charAt(i))); + } + return sb.toString(); + } + + private static char copyCase(String original, int index, char newChar) { + if (Character.isUpperCase(original.charAt(index))) { + return Character.toUpperCase(newChar); + } else { + return newChar; + } + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java new file mode 100644 index 000000000..de05cd0ad --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageContent.java @@ -0,0 +1,20 @@ +package net.discordjug.javabot.listener.filter; + +import net.dv8tion.jda.api.entities.Message; +import net.dv8tion.jda.api.entities.MessageEmbed; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; + +import java.util.List; + +/** + * A class containing modifiable content of a message that has been received. + * @param event The event associated with receiving the message + * @param messageText The text associated with the message + * @param attachments The attachments associated with the message + * @param embeds The embeds associated with the message + */ +public record MessageContent(MessageReceivedEvent event, + StringBuilder messageText, + List attachments, + List embeds) { +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java new file mode 100644 index 000000000..2a7141b21 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilter.java @@ -0,0 +1,23 @@ +package net.discordjug.javabot.listener.filter; + +/** + * This interface is implemented by all message filters. + * + * The {@link MessageContent} is processed by every class implementing {@link MessageFilter} + * unless one of the filters returns {@link MessageModificationStatus#STOP_PROCESSING} which stops further filters from executing. + */ +public interface MessageFilter { + + /** + * When a message is received, it is processed by the registered filters. + * + * @param content The content of the new message that will be reposted instead of the received message + * if at least one filter returns {@link MessageModificationStatus#MODIFIED} + * and no filter returns {@link MessageModificationStatus#STOP_PROCESSING}. + * This {@link MessageContent} is built up incrementally by the filters. + * @return the appropriate {@link MessageModificationStatus} based on the filter's processing. + * @see MessageFilterHandler + */ + MessageModificationStatus processMessage(MessageContent content); + +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java new file mode 100644 index 000000000..0700b21d4 --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageFilterHandler.java @@ -0,0 +1,118 @@ +package net.discordjug.javabot.listener.filter; + +import lombok.RequiredArgsConstructor; +import net.discordjug.javabot.data.config.BotConfig; +import net.discordjug.javabot.systems.moderation.AutoMod; +import net.discordjug.javabot.util.ExceptionLogger; +import net.discordjug.javabot.util.WebhookUtil; +import net.dv8tion.jda.api.entities.channel.ChannelType; +import net.dv8tion.jda.api.entities.channel.attribute.IWebhookContainer; +import net.dv8tion.jda.api.entities.channel.middleman.StandardGuildChannel; +import net.dv8tion.jda.api.events.message.MessageReceivedEvent; +import net.dv8tion.jda.api.hooks.ListenerAdapter; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; + +/** + * This class is responsible for calling {@link MessageFilter}s on incoming messages and optionally replacing the message. + * + * When a message is received, registered {@link MessageFilter filters} are executed sequentially to process the message. + * These filters are able to act on the message and modify message contents using the {@link MessageContent} record. + * Modifications to the message are performed after all filters are executed by deleting the original message and re-sending a modified message. + * + * Message filters are Spring components implementing the {@link MessageFilter} interface. + */ +@RequiredArgsConstructor +public class MessageFilterHandler extends ListenerAdapter { + + private final List filters; + private final AutoMod autoMod; + private final BotConfig botConfig; + + @Override + public void onMessageReceived(@NotNull MessageReceivedEvent event) { + if (!shouldRunFilters(event)) { + return; + } + + MessageContent content = new MessageContent( + event, + new StringBuilder(event.getMessage().getContentRaw()), + new ArrayList<>(event.getMessage().getAttachments()), + new ArrayList<>(event.getMessage().getEmbeds()) + ); + + boolean handled = false; + + for (MessageFilter filter : filters) { + MessageModificationStatus status = filter.processMessage(content); + if (status == MessageModificationStatus.MODIFIED) { + handled = true; + } else if (status == MessageModificationStatus.STOP_PROCESSING) { + return; + } + } + + if (handled) { + IWebhookContainer webhookContainer = null; + long threadId = 0; + if (event.isFromType(ChannelType.TEXT)) { + webhookContainer = event.getChannel().asTextChannel(); + } + if (event.isFromThread()) { + StandardGuildChannel parentChannel = event.getChannel() + .asThreadChannel() + .getParentChannel() + .asStandardGuildChannel(); + threadId = event.getChannel().getIdLong(); + webhookContainer = (IWebhookContainer) parentChannel; + } + if (webhookContainer == null) { + return; + } + replaceMessage(webhookContainer, threadId, content); + } + } + + private boolean shouldRunFilters(@NotNull MessageReceivedEvent event) { + if (event.isWebhookMessage()) { + return false; + } + if (!event.isFromGuild()) { + return false; + } + if (event.getAuthor().isBot() || event.getAuthor().isSystem()) { + return false; + } + if (autoMod.hasSuspiciousLink(event.getMessage()) || + autoMod.hasAdvertisingLink(event.getMessage())) { + return false; + } + if (event.getChannel().getIdLong() == botConfig.get(event.getGuild()) + .getModerationConfig() + .getSuggestionChannelId()) { + return false; + } + return true; + } + + private void replaceMessage(IWebhookContainer webhookContainer, long threadId, MessageContent content) { + WebhookUtil.ensureWebhookExists( + webhookContainer, + wh -> WebhookUtil.replaceMemberMessage( + wh, + content.event().getMessage(), + content.messageText().toString(), + threadId, + content.attachments(), + content.embeds() + ), + e -> ExceptionLogger.capture( + e, + this.getClass().getSimpleName() + ) + ); + } +} diff --git a/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java new file mode 100644 index 000000000..7a51a733c --- /dev/null +++ b/src/main/java/net/discordjug/javabot/listener/filter/MessageModificationStatus.java @@ -0,0 +1,24 @@ +package net.discordjug.javabot.listener.filter; + +/** + * This enum describes the result of a {@link MessageFilter} execution and what should be done with the message. + */ +public enum MessageModificationStatus { + /** + * The message representation has been modified requiring the message to be deleted and re-sent. + * + * Further filters will be executed. + */ + MODIFIED, + /** + * The message representation has not been modified and the filter does not require the message to be deleted and re-sent. + * + * If another filter returns {@link #MODIFIED}, the message is still deleted and re-sent. + * Further filters will be executed. + */ + NOT_MODIFIED, + /** + * Indicates that no further filters should be executed on the message and that the message should not be deleted and re-sent by the filter handling logic. + */ + STOP_PROCESSING +} \ No newline at end of file diff --git a/src/main/java/net/discordjug/javabot/util/WebhookUtil.java b/src/main/java/net/discordjug/javabot/util/WebhookUtil.java index 485caf41c..f7c40cf15 100644 --- a/src/main/java/net/discordjug/javabot/util/WebhookUtil.java +++ b/src/main/java/net/discordjug/javabot/util/WebhookUtil.java @@ -17,6 +17,7 @@ import org.jetbrains.annotations.Nullable; import java.util.List; +import java.util.Objects; import java.util.Optional; import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; @@ -91,17 +92,35 @@ public static void ensureWebhookExists(@NotNull IWebhookContainer channel, @NotN * the message */ public static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, @NotNull Message originalMessage, String newMessageContent, long threadId, @Nullable List components, @Nullable List embeds) { + return mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, null, embeds); + } + + /** + * Resends a specific message using a webhook with a custom content. + * + * @param webhook the webhook used for sending the message + * @param originalMessage the message to copy + * @param newMessageContent the new (custom) content + * @param threadId the thread to send the message in or {@code 0} if the + * message should be sent directly + * @param components A nullable list of {@link LayoutComponent}s. + * @param embeds A nullable list of {@link MessageEmbed}s. + * @param attachments A nullable list of {@link Attachment}s. + * @return a {@link CompletableFuture} representing the action of sending + * the message + */ + public static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, @NotNull Message originalMessage, String newMessageContent, long threadId, @Nullable List components, @Nullable List attachments, @Nullable List embeds) { return originalMessage .getGuild() .retrieveMember(originalMessage.getAuthor()) .submit() .exceptionally(e -> null)//if the member cannot be found, use no member information - .thenCompose(member -> - mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, embeds, member)); + .thenCompose(member -> + mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, components, attachments, embeds, member)); } - private static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, Message originalMessage, String newMessageContent, long threadId, - List components, List embeds, Member member) { + private static CompletableFuture mirrorMessageToWebhook(@NotNull Webhook webhook, Message originalMessage, String newMessageContent, long threadId, List components, + List attachments, List embeds, Member member) { WebhookMessageBuilder message = new WebhookMessageBuilder().setContent(newMessageContent) .setAllowedMentions(AllowedMentions.none()) .setAvatarUrl(transformOrNull(member, Member::getEffectiveAvatarUrl)) @@ -118,11 +137,11 @@ private static CompletableFuture mirrorMessageToWebhook(@NotNul message.addEmbeds(embeds.stream() .map(e -> WebhookEmbedBuilder.fromJDA(e).build()) .toList()); - List attachments = originalMessage.getAttachments(); + List newAttachments = Objects.requireNonNullElse(attachments, originalMessage.getAttachments()); @SuppressWarnings("unchecked") - CompletableFuture[] futures = new CompletableFuture[attachments.size()]; - for (int i = 0; i < attachments.size(); i++) { - Attachment attachment = attachments.get(i); + CompletableFuture[] futures = new CompletableFuture[newAttachments.size()]; + for (int i = 0; i < newAttachments.size(); i++) { + Attachment attachment = newAttachments.get(i); futures[i] = attachment.getProxy() .download() .thenAccept(is -> message.addFile((attachment.isSpoiler() ? "SPOILER_" : "") + attachment.getFileName(), is)); @@ -156,13 +175,18 @@ private static R transformOrNull(T toTransform, Function transforme * @param newMessageContent a String containing the new message's content * @param threadId id of the thread in which the message should be replaced * @param embeds optional additional embeds to be added + * @param attachments optional additional attachments to be added */ - public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, MessageEmbed... embeds) { - WebhookUtil.mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, null, List.of(embeds)) + public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, List attachments, List embeds) { + WebhookUtil.mirrorMessageToWebhook(webhook, originalMessage, newMessageContent, threadId, null, attachments, embeds) .thenAccept(unused -> originalMessage.delete().queue()) .exceptionally(e -> { ExceptionLogger.capture(e, WebhookUtil.class.getSimpleName()); return null; }); } + + public static void replaceMemberMessage(Webhook webhook, Message originalMessage, String newMessageContent, long threadId, MessageEmbed... embeds) { + replaceMemberMessage(webhook, originalMessage, newMessageContent, threadId, null, List.of(embeds)); + } }