diff --git a/API/src/main/java/me/braydon/profanity/common/DiscordWebhook.java b/API/src/main/java/me/braydon/profanity/common/DiscordWebhook.java new file mode 100644 index 0000000..a918bde --- /dev/null +++ b/API/src/main/java/me/braydon/profanity/common/DiscordWebhook.java @@ -0,0 +1,394 @@ +package me.braydon.profanity.common; + +import javax.net.ssl.HttpsURLConnection; +import java.awt.*; +import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Array; +import java.net.URL; +import java.util.List; +import java.util.*; + +/** + * Class used to execute Discord Webhooks with low effort + * + * @author Here + */ +public class DiscordWebhook { + + private final String url; + private String content; + private String username; + private String avatarUrl; + private boolean tts; + private List embeds = new ArrayList<>(); + + /** + * Constructs a new DiscordWebhook instance + * + * @param url The webhook URL obtained in Discord + */ + public DiscordWebhook(String url) { + this.url = url; + } + + public void setContent(String content) { + this.content = content; + } + + public void setUsername(String username) { + this.username = username; + } + + public void setAvatarUrl(String avatarUrl) { + this.avatarUrl = avatarUrl; + } + + public void setTts(boolean tts) { + this.tts = tts; + } + + public void addEmbed(EmbedObject embed) { + this.embeds.add(embed); + } + + public void execute() throws IOException { + if (this.content == null && this.embeds.isEmpty()) { + throw new IllegalArgumentException("Set content or add at least one EmbedObject"); + } + + JSONObject json = new JSONObject(); + + json.put("content", this.content); + json.put("username", this.username); + json.put("avatar_url", this.avatarUrl); + json.put("tts", this.tts); + + if (!this.embeds.isEmpty()) { + List embedObjects = new ArrayList<>(); + + for (EmbedObject embed : this.embeds) { + JSONObject jsonEmbed = new JSONObject(); + + jsonEmbed.put("title", embed.getTitle()); + jsonEmbed.put("description", embed.getDescription()); + jsonEmbed.put("url", embed.getUrl()); + + if (embed.getColor() != null) { + Color color = embed.getColor(); + int rgb = color.getRed(); + rgb = (rgb << 8) + color.getGreen(); + rgb = (rgb << 8) + color.getBlue(); + + jsonEmbed.put("color", rgb); + } + + EmbedObject.Footer footer = embed.getFooter(); + EmbedObject.Image image = embed.getImage(); + EmbedObject.Thumbnail thumbnail = embed.getThumbnail(); + EmbedObject.Author author = embed.getAuthor(); + List fields = embed.getFields(); + + if (footer != null) { + JSONObject jsonFooter = new JSONObject(); + + jsonFooter.put("text", footer.getText()); + jsonFooter.put("icon_url", footer.getIconUrl()); + jsonEmbed.put("footer", jsonFooter); + } + + if (image != null) { + JSONObject jsonImage = new JSONObject(); + + jsonImage.put("url", image.getUrl()); + jsonEmbed.put("image", jsonImage); + } + + if (thumbnail != null) { + JSONObject jsonThumbnail = new JSONObject(); + + jsonThumbnail.put("url", thumbnail.getUrl()); + jsonEmbed.put("thumbnail", jsonThumbnail); + } + + if (author != null) { + JSONObject jsonAuthor = new JSONObject(); + + jsonAuthor.put("name", author.getName()); + jsonAuthor.put("url", author.getUrl()); + jsonAuthor.put("icon_url", author.getIconUrl()); + jsonEmbed.put("author", jsonAuthor); + } + + List jsonFields = new ArrayList<>(); + for (EmbedObject.Field field : fields) { + JSONObject jsonField = new JSONObject(); + + jsonField.put("name", field.getName()); + jsonField.put("value", field.getValue()); + jsonField.put("inline", field.isInline()); + + jsonFields.add(jsonField); + } + + jsonEmbed.put("fields", jsonFields.toArray()); + embedObjects.add(jsonEmbed); + } + + json.put("embeds", embedObjects.toArray()); + } + + URL url = new URL(this.url); + HttpsURLConnection connection = (HttpsURLConnection) url.openConnection(); + connection.addRequestProperty("Content-Type", "application/json"); + connection.addRequestProperty("User-Agent", "Java-DiscordWebhook-BY-Gelox_"); + connection.setDoOutput(true); + connection.setRequestMethod("POST"); + + OutputStream stream = connection.getOutputStream(); + stream.write(json.toString().getBytes()); + stream.flush(); + stream.close(); + + connection.getInputStream().close(); //I'm not sure why but it doesn't work without getting the InputStream + connection.disconnect(); + + // Reset the state of this webhook after execution + content = null; + embeds.clear(); + } + + public static class EmbedObject { + private String title; + private String description; + private String url; + private Color color; + + private Footer footer; + private Thumbnail thumbnail; + private Image image; + private Author author; + private List fields = new ArrayList<>(); + + public String getTitle() { + return title; + } + + public String getDescription() { + return description; + } + + public String getUrl() { + return url; + } + + public Color getColor() { + return color; + } + + public Footer getFooter() { + return footer; + } + + public Thumbnail getThumbnail() { + return thumbnail; + } + + public Image getImage() { + return image; + } + + public Author getAuthor() { + return author; + } + + public List getFields() { + return fields; + } + + public EmbedObject setTitle(String title) { + this.title = title; + return this; + } + + public EmbedObject setDescription(String description) { + this.description = description; + return this; + } + + public EmbedObject setUrl(String url) { + this.url = url; + return this; + } + + public EmbedObject setColor(Color color) { + this.color = color; + return this; + } + + public EmbedObject setFooter(String text, String icon) { + this.footer = new Footer(text, icon); + return this; + } + + public EmbedObject setThumbnail(String url) { + this.thumbnail = new Thumbnail(url); + return this; + } + + public EmbedObject setImage(String url) { + this.image = new Image(url); + return this; + } + + public EmbedObject setAuthor(String name, String url, String icon) { + this.author = new Author(name, url, icon); + return this; + } + + public EmbedObject addField(String name, String value, boolean inline) { + this.fields.add(new Field(name, value, inline)); + return this; + } + + private class Footer { + private String text; + private String iconUrl; + + private Footer(String text, String iconUrl) { + this.text = text; + this.iconUrl = iconUrl; + } + + private String getText() { + return text; + } + + private String getIconUrl() { + return iconUrl; + } + } + + private class Thumbnail { + private String url; + + private Thumbnail(String url) { + this.url = url; + } + + private String getUrl() { + return url; + } + } + + private class Image { + private String url; + + private Image(String url) { + this.url = url; + } + + private String getUrl() { + return url; + } + } + + private class Author { + private String name; + private String url; + private String iconUrl; + + private Author(String name, String url, String iconUrl) { + this.name = name; + this.url = url; + this.iconUrl = iconUrl; + } + + private String getName() { + return name; + } + + private String getUrl() { + return url; + } + + private String getIconUrl() { + return iconUrl; + } + } + + private class Field { + private String name; + private String value; + private boolean inline; + + private Field(String name, String value, boolean inline) { + this.name = name; + this.value = value; + this.inline = inline; + } + + private String getName() { + return name; + } + + private String getValue() { + return value; + } + + private boolean isInline() { + return inline; + } + } + } + + private class JSONObject { + + private final HashMap map = new HashMap<>(); + + void put(String key, Object value) { + if (value != null) { + map.put(key, value); + } + } + + @Override + public String toString() { + StringBuilder builder = new StringBuilder(); + Set> entrySet = map.entrySet(); + builder.append("{"); + + int i = 0; + for (Map.Entry entry : entrySet) { + Object val = entry.getValue(); + builder.append(quote(entry.getKey())).append(":"); + + if (val instanceof String) { + builder.append(quote(String.valueOf(val))); + } else if (val instanceof Integer) { + builder.append(Integer.valueOf(String.valueOf(val))); + } else if (val instanceof Boolean) { + builder.append(val); + } else if (val instanceof JSONObject) { + builder.append(val.toString()); + } else if (val.getClass().isArray()) { + builder.append("["); + int len = Array.getLength(val); + for (int j = 0; j < len; j++) { + builder.append(Array.get(val, j).toString()).append(j != len - 1 ? "," : ""); + } + builder.append("]"); + } + + builder.append(++i == entrySet.size() ? "}" : ","); + } + + return builder.toString(); + } + + private String quote(String string) { + return "\"" + string + "\""; + } + } + +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/model/input/ContentProcessInput.java b/API/src/main/java/me/braydon/profanity/model/input/ContentProcessInput.java index 9892fff..31efe0b 100644 --- a/API/src/main/java/me/braydon/profanity/model/input/ContentProcessInput.java +++ b/API/src/main/java/me/braydon/profanity/model/input/ContentProcessInput.java @@ -1,7 +1,11 @@ package me.braydon.profanity.model.input; import lombok.Getter; +import lombok.NonNull; import lombok.Setter; +import me.braydon.profanity.common.ContentTag; + +import java.util.List; /** * The input to use for processing content. @@ -19,7 +23,27 @@ public final class ContentProcessInput { * The char to use for matched * replacement operations. */ - private char replaceChar = '*'; + private Character replaceChar = '*'; + + /** + * An optional list of tags to ignore. + *

+ * E.g: If {@link ContentTag#ADVERTISEMENT} + * is ignored, advertisements will not be + * filtered. + *

+ */ + private List ignoredTags; + + /** + * Check if the given tag is ignored. + * + * @param tag the tag to check + * @return whether the tag is ignored + */ + public boolean isTagIgnored(@NonNull ContentTag tag) { + return ignoredTags != null && (ignoredTags.contains(tag)); + } /** * Check if this input is malformed. @@ -27,6 +51,7 @@ public final class ContentProcessInput { * @return whether the input is malformed */ public boolean isMalformed() { - return content == null || content.isEmpty(); + return content == null || content.isEmpty() + || replaceChar == null || replaceChar == '\0'; } } \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/model/response/ContentProcessResponse.java b/API/src/main/java/me/braydon/profanity/model/response/ContentProcessResponse.java index 4851583..4ccfdaa 100644 --- a/API/src/main/java/me/braydon/profanity/model/response/ContentProcessResponse.java +++ b/API/src/main/java/me/braydon/profanity/model/response/ContentProcessResponse.java @@ -17,6 +17,11 @@ public final class ContentProcessResponse { */ private final boolean containsProfanity; + /** + * The original content. + */ + @NonNull private final String content; + /** * The replacement for the content. */ diff --git a/API/src/main/java/me/braydon/profanity/notification/INotificationSource.java b/API/src/main/java/me/braydon/profanity/notification/INotificationSource.java new file mode 100644 index 0000000..c226700 --- /dev/null +++ b/API/src/main/java/me/braydon/profanity/notification/INotificationSource.java @@ -0,0 +1,26 @@ +package me.braydon.profanity.notification; + +import lombok.NonNull; +import me.braydon.profanity.model.response.ContentProcessResponse; + +/** + * A source that can receive notifications. + * + * @author Braydon + */ +public interface INotificationSource { + /** + * Check if this source is enabled. + * + * @return whether this source is enabled + */ + boolean isEnabled(); + + /** + * Send an alert to this source. + * + * @param response the content response to alert for + * @param content the content to send in the alert + */ + void alert(@NonNull ContentProcessResponse response, @NonNull NotificationContent content); +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/notification/NotificationContent.java b/API/src/main/java/me/braydon/profanity/notification/NotificationContent.java new file mode 100644 index 0000000..84da9d6 --- /dev/null +++ b/API/src/main/java/me/braydon/profanity/notification/NotificationContent.java @@ -0,0 +1,33 @@ +package me.braydon.profanity.notification; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +/** + * The content that should + * be sent within a notification. + * + * @author Braydon + */ +@AllArgsConstructor @Getter +public final class NotificationContent { + /** + * Whether content should be displayed. + */ + private final boolean displayContent; + + /** + * Whether matched content should be displayed. + */ + private final boolean displayMatched; + + /** + * Whether obtained tags should be displayed. + */ + private final boolean displayTags; + + /** + * Whether the score should be displayed. + */ + private final boolean displayScore; +} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/notification/NotificationSource.java b/API/src/main/java/me/braydon/profanity/notification/NotificationSource.java deleted file mode 100644 index 3b5ea87..0000000 --- a/API/src/main/java/me/braydon/profanity/notification/NotificationSource.java +++ /dev/null @@ -1,13 +0,0 @@ -package me.braydon.profanity.notification; - -/** - * A source that can receive notifications. - * - * @author Braydon - */ -public abstract class NotificationSource { - /** - * Send an alert to this notification source. - */ - public abstract void alert(); -} \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/notification/impl/DiscordSource.java b/API/src/main/java/me/braydon/profanity/notification/impl/DiscordSource.java index 71d0134..0ec3057 100644 --- a/API/src/main/java/me/braydon/profanity/notification/impl/DiscordSource.java +++ b/API/src/main/java/me/braydon/profanity/notification/impl/DiscordSource.java @@ -1,8 +1,18 @@ package me.braydon.profanity.notification.impl; -import lombok.AllArgsConstructor; +import jakarta.annotation.PostConstruct; import lombok.NonNull; -import me.braydon.profanity.notification.NotificationSource; +import lombok.SneakyThrows; +import me.braydon.profanity.common.ContentTag; +import me.braydon.profanity.common.DiscordWebhook; +import me.braydon.profanity.model.response.ContentProcessResponse; +import me.braydon.profanity.notification.INotificationSource; +import me.braydon.profanity.notification.NotificationContent; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.awt.*; +import java.util.stream.Collectors; /** * A notification source that @@ -10,16 +20,63 @@ import me.braydon.profanity.notification.NotificationSource; * * @author Braydon */ -@AllArgsConstructor -public final class DiscordSource extends NotificationSource { - /** - * The URL of the webhook to send alerts to. - */ - @NonNull private final String webhookUrl; +@Component +public final class DiscordSource implements INotificationSource { + @Value("${notifications.sources.Discord.url}") + private String webhookUrl; + + @Value("${notifications.sources.Discord.username}") + private String username; + + @Value("${notifications.sources.Discord.avatar}") + private String avatarUrl; /** - * Send an alert to this notification source. + * The instance of the webhook to use. + */ + private DiscordWebhook webhook; + + @PostConstruct + public void onInitialize() { + webhook = new DiscordWebhook(webhookUrl = webhookUrl.trim()); + webhook.setUsername(username); + webhook.setAvatarUrl(avatarUrl); + } + + /** + * Check if this source is enabled. + * + * @return whether this source is enabled */ @Override - public void alert() {} + public boolean isEnabled() { + return !webhookUrl.isEmpty(); + } + + /** + * Send an alert to this source. + * + * @param response the content response to alert for + * @param content the content to send in the alert + */ + @Override @SneakyThrows + public void alert(@NonNull ContentProcessResponse response, @NonNull NotificationContent content) { + DiscordWebhook.EmbedObject embed = new DiscordWebhook.EmbedObject() + .setColor(Color.RED) + .setTitle("Content Filtered"); + if (content.isDisplayContent()) { + embed.addField("Content", "`" + response.getContent() + "`", false); + } + if (content.isDisplayMatched()) { + embed.addField("Matched", String.join("`, `", response.getMatched()), false); + } + if (content.isDisplayTags()) { + embed.addField("Tags", response.getTags().stream().map(ContentTag::name).collect(Collectors.joining("`, `")), false); + } + if (content.isDisplayTags()) { + embed.addField("Score", "`" + response.getScore() + "`", false); + } + webhook.addEmbed(embed); + webhook.execute(); + } } \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/service/FiltrationService.java b/API/src/main/java/me/braydon/profanity/service/FiltrationService.java index 92fcb5f..a747628 100644 --- a/API/src/main/java/me/braydon/profanity/service/FiltrationService.java +++ b/API/src/main/java/me/braydon/profanity/service/FiltrationService.java @@ -108,10 +108,16 @@ public final class FiltrationService { if (profanityList != null) { // Invoke each text processor on the content for (TextProcessor textProcessor : textProcessors) { + ContentTag tag = textProcessor.getTag(); + + // Skip this processor as it's tag is ignored. + if (input.isTagIgnored(tag)) { + continue; + } int before = matched.size(); replacement = textProcessor.process(profanityList, content, replacement, input.getReplaceChar(), matched); if (matched.size() > before) { - tags.add(textProcessor.getTag()); + tags.add(tag); } } } @@ -124,6 +130,6 @@ public final class FiltrationService { } score = Math.min(score, 1D); - return new ContentProcessResponse(!matched.isEmpty(), replacement.toString(), matched, tags, score); + return new ContentProcessResponse(!matched.isEmpty(), input.getContent(), replacement.toString(), matched, tags, score); } } \ No newline at end of file diff --git a/API/src/main/java/me/braydon/profanity/service/ModerationService.java b/API/src/main/java/me/braydon/profanity/service/ModerationService.java index 6c07cbf..ec0c0d8 100644 --- a/API/src/main/java/me/braydon/profanity/service/ModerationService.java +++ b/API/src/main/java/me/braydon/profanity/service/ModerationService.java @@ -1,22 +1,91 @@ package me.braydon.profanity.service; import lombok.NonNull; +import lombok.extern.log4j.Log4j2; import me.braydon.profanity.model.response.ContentProcessResponse; +import me.braydon.profanity.notification.INotificationSource; +import me.braydon.profanity.notification.NotificationContent; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import java.util.List; + /** * This service is responsible for moderating text * content and reporting it to the appropriate parties. * * @author Braydon */ -@Service +@Service @Log4j2(topic = "Moderation") public final class ModerationService { + @Value("${notifications.enabled}") + private boolean enabled; + + /** + * # Notification Configuration + * notifications: + * enabled: false + * content: # Elements to display in the notification + * content: true # Should filtered content be displayed? + * matched: true # Should matched content be displayed? + * tags: true # Should obtained tags be displayed? + * score: true # Should the score be displayed? + */ + @Value("${notifications.content.content}") + private boolean displayContent; + + @Value("${notifications.content.matched}") + private boolean displayMatched; + + @Value("${notifications.content.tags}") + private boolean displayTags; + + @Value("${notifications.content.score}") + private boolean displayScore; + + /** + * The content to display within notifications. + */ + @NonNull private final NotificationContent notificationContent; + + /** + * The registered notification sources to alert. + */ + @NonNull private final List notificationSources; + + @Autowired + public ModerationService(@NonNull List notificationSources) { + notificationContent = new NotificationContent(displayContent, displayMatched, displayTags, displayScore); + this.notificationSources = notificationSources; + } + + /** + * Handle alerts for the given response. + *

+ * If the content in the given response + * contains profanity, notification sources + * will be notified. + *

+ * + * @param response the response to handle + */ public void handleAlerts(@NonNull ContentProcessResponse response) { - // Likely safe content, no need to alert anyone - if (response.getScore() < 0.6D) { + // Disabled or likely safe content, no need to alert anyone + if (!enabled || response.getScore() < 0.6D) { return; } - // TODO: handle alerting of the content to the appropriate parties + // Notify sources + int notified = 0; + for (INotificationSource source : notificationSources) { + if (!source.isEnabled()) { + continue; + } + notified++; + source.alert(response, notificationContent); + } + if (notified > 0) { + log.info("Notified {} sources of filtered content", notified); + } } } \ No newline at end of file diff --git a/API/src/main/resources/application.yml b/API/src/main/resources/application.yml index 38c7d58..c039da1 100644 --- a/API/src/main/resources/application.yml +++ b/API/src/main/resources/application.yml @@ -8,6 +8,20 @@ logging: file: path: "./logs" +# Notification Configuration +notifications: + enabled: false + content: # Elements to display in the notification + content: true # Should filtered content be displayed? + matched: true # Should matched content be displayed? + tags: true # Should obtained tags be displayed? + score: true # Should the score be displayed? + sources: + Discord: + url: "" + username: "TextPurify" + avatar: "https://cdn.rainnny.club/usRQ10FSwaAI.png" + # Spring Configuration spring: data: