From 3219f396e5fe750d8cd271a1022763d3c6a1c239 Mon Sep 17 00:00:00 2001
From: v0ee <113121361+v0ee@users.noreply.github.com>
Date: Fri, 7 Aug 2026 10:36:00 +0300
Subject: [PATCH] family friendly christian minecraft server
---
pom.xml | 20 +
.../org/zeroBzeroT/chatCo/ChatPlayer.java | 5 +-
.../org/zeroBzeroT/chatCo/LinkBlocker.java | 83 ++
src/main/java/org/zeroBzeroT/chatCo/Main.java | 75 +-
.../org/zeroBzeroT/chatCo/PublicChat.java | 136 ++-
.../java/org/zeroBzeroT/chatCo/Redis.java | 50 ++
.../org/zeroBzeroT/chatCo/WordFilter.java | 161 ++++
src/main/resources/config.yml | 26 +-
src/main/resources/whitelist.txt | 2 +
src/main/resources/wordlist.txt | 772 ++++++++++++++++++
10 files changed, 1298 insertions(+), 32 deletions(-)
create mode 100644 src/main/java/org/zeroBzeroT/chatCo/LinkBlocker.java
create mode 100644 src/main/java/org/zeroBzeroT/chatCo/Redis.java
create mode 100644 src/main/java/org/zeroBzeroT/chatCo/WordFilter.java
create mode 100644 src/main/resources/whitelist.txt
create mode 100644 src/main/resources/wordlist.txt
diff --git a/pom.xml b/pom.xml
index 9844209..d4899f2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -54,6 +54,10 @@
org.bstats
org.zeroBzeroT.bstats
+
+ redis.clients
+ org.zeroBzeroT.chatCo.shaded.redis
+
@@ -62,6 +66,12 @@
META-INF/*.MF
+
+ redis.clients:*
+
+ META-INF/*.MF
+
+
META-INF/**
@@ -100,6 +110,10 @@
https://repo.codemc.org/repository/maven-public/
default
+
+ central
+ https://repo1.maven.org/maven2/
+
@@ -121,5 +135,11 @@
3.1.0
compile
+
+ redis.clients
+ jedis
+ 5.1.3
+ compile
+
\ No newline at end of file
diff --git a/src/main/java/org/zeroBzeroT/chatCo/ChatPlayer.java b/src/main/java/org/zeroBzeroT/chatCo/ChatPlayer.java
index 487f9ff..48983d4 100644
--- a/src/main/java/org/zeroBzeroT/chatCo/ChatPlayer.java
+++ b/src/main/java/org/zeroBzeroT/chatCo/ChatPlayer.java
@@ -25,6 +25,8 @@ public class ChatPlayer {
private List ignores;
private List ignoredBy;
+ public boolean rulesNoticeSent;
+
public ChatPlayer(final Player p) throws IOException {
name = p.getName();
@@ -34,7 +36,8 @@ public ChatPlayer(final Player p) throws IOException {
LastMessenger = null;
LastReceiver = null;
ignoredBy = new ArrayList<>();
-
+ rulesNoticeSent = false;
+
// create the ignore-list
saveIgnoreList("");
}
diff --git a/src/main/java/org/zeroBzeroT/chatCo/LinkBlocker.java b/src/main/java/org/zeroBzeroT/chatCo/LinkBlocker.java
new file mode 100644
index 0000000..37256a5
--- /dev/null
+++ b/src/main/java/org/zeroBzeroT/chatCo/LinkBlocker.java
@@ -0,0 +1,83 @@
+package org.zeroBzeroT.chatCo;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+public class LinkBlocker {
+
+ public static final Pattern URL_PATTERN = Pattern.compile(
+ "https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_\\\\+.~#?&/=]*");
+
+ private Set domainAllowlist = new HashSet<>();
+ private String replacement = "[link removed]";
+ private boolean loaded = false;
+
+ public synchronized void load(File whitelistFile, String replacement) {
+ this.replacement = replacement == null ? "[link removed]" : replacement;
+ this.domainAllowlist = new HashSet<>();
+ if (whitelistFile != null && whitelistFile.exists()) {
+ try (BufferedReader r = new BufferedReader(new FileReader(whitelistFile))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ String t = line.trim().toLowerCase(Locale.ROOT);
+ if (t.isEmpty() || t.startsWith("#")) continue;
+ if (t.startsWith("http://")) t = t.substring(7);
+ else if (t.startsWith("https://")) t = t.substring(8);
+ if (t.endsWith("/")) t = t.substring(0, t.length() - 1);
+ domainAllowlist.add(t);
+ }
+ } catch (IOException ignored) {}
+ }
+ this.loaded = true;
+ }
+
+ public synchronized String apply(String message) {
+ if (!loaded || message == null || message.isEmpty()) return message;
+ Matcher m = URL_PATTERN.matcher(message);
+ StringBuilder out = new StringBuilder(message.length());
+ int last = 0;
+ while (m.find()) {
+ String url = m.group();
+ if (isAllowed(url)) {
+ continue;
+ }
+ out.append(message, last, m.start());
+ if (!replacement.isEmpty()) {
+ out.append(replacement);
+ } else if (out.length() > 0 && out.charAt(out.length() - 1) == ' ') {
+ out.setLength(out.length() - 1);
+ }
+ last = m.end();
+ }
+ out.append(message, last, message.length());
+ return out.toString();
+ }
+
+ private boolean isAllowed(String url) {
+ String host = extractHost(url);
+ if (host == null) return false;
+ return domainAllowlist.contains(host);
+ }
+
+ private static String extractHost(String url) {
+ String s = url.toLowerCase(Locale.ROOT);
+ if (s.startsWith("https://")) s = s.substring(8);
+ else if (s.startsWith("http://")) s = s.substring(7);
+ int slash = s.indexOf('/');
+ if (slash >= 0) s = s.substring(0, slash);
+ int colon = s.indexOf(':');
+ if (colon >= 0) s = s.substring(0, colon);
+ return s.isEmpty() ? null : s;
+ }
+
+ public boolean isLoaded() {
+ return loaded;
+ }
+}
diff --git a/src/main/java/org/zeroBzeroT/chatCo/Main.java b/src/main/java/org/zeroBzeroT/chatCo/Main.java
index aaf2904..4120520 100644
--- a/src/main/java/org/zeroBzeroT/chatCo/Main.java
+++ b/src/main/java/org/zeroBzeroT/chatCo/Main.java
@@ -6,6 +6,7 @@
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.UUID;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
@@ -26,9 +27,20 @@ public class Main extends JavaPlugin {
public static File WhisperLog;
public static File dataFolder;
private static File Help;
+ public static File WordListFile;
+ public static File WhiteListFile;
public Collection playerList;
private Whispers whispers;
-
+ private WordFilter wordFilter;
+ private LinkBlocker linkBlocker;
+ private int thresholdHours;
+ private PublicChat publicChat;
+ private static Main instance;
+
+ public static Main getInstance() {
+ return instance;
+ }
+
@Override
public void onDisable() {
playerList.clear();
@@ -36,6 +48,7 @@ public void onDisable() {
@Override
public void onEnable() {
+ instance = this;
playerList = Collections.synchronizedCollection(new ArrayList<>());
// Config defaults
@@ -44,10 +57,13 @@ public void onEnable() {
saveResourceFiles();
toggleConfigValue(0);
+ loadFilters();
+ reloadGateConfig();
final PluginManager pm = getServer().getPluginManager();
- pm.registerEvents(new PublicChat(this), this);
+ publicChat = new PublicChat(this);
+ pm.registerEvents(publicChat, this);
if (getConfig().getBoolean("ChatCo.whisperChangesEnabled", true)) {
whispers = new Whispers(this);
@@ -63,6 +79,46 @@ public void onEnable() {
}
}
+ public void loadFilters() {
+ if (wordFilter == null) wordFilter = new WordFilter();
+ if (linkBlocker == null) linkBlocker = new LinkBlocker();
+ boolean fuzzy = getConfig().getBoolean("ChatCo.playtimeGate.wordFilter.fuzzy", false);
+ String replacement = getConfig().getString("ChatCo.playtimeGate.wordFilter.replacement", "bobba");
+ wordFilter.load(WordListFile, WhiteListFile, fuzzy, replacement);
+ String linkRepl = getConfig().getString("ChatCo.playtimeGate.linkBlock.replacement", "[link removed]");
+ linkBlocker.load(WhiteListFile, linkRepl);
+ }
+
+ public void reloadGateConfig() {
+ thresholdHours = getConfig().getInt("ChatCo.playtimeGate.thresholdHours", 10);
+ }
+
+ public WordFilter getWordFilter() {
+ return wordFilter;
+ }
+
+ public LinkBlocker getLinkBlocker() {
+ return linkBlocker;
+ }
+
+ public boolean isGated(org.bukkit.entity.Player player) {
+ if (thresholdHours == 0) return false;
+ if (thresholdHours < 0) return true;
+ int hours = getPlaytimeHours(player.getUniqueId());
+ return hours < thresholdHours;
+ }
+
+ private int getPlaytimeHours(UUID uuid) {
+ String key = "player." + uuid + ".playtime";
+ java.util.Optional value = Redis.readSync(key);
+ if (value.isEmpty()) return 0;
+ try {
+ return Integer.parseInt(value.get());
+ } catch (NumberFormatException e) {
+ return 0;
+ }
+ }
+
private void toggleConfigValue(final int change) {
switch (change) {
case 3:
@@ -101,6 +157,8 @@ private void saveResourceFiles() {
Main.PermissionConfig = new File(Main.dataFolder, "permissionConfig.yml");
Main.WhisperLog = new File(Main.dataFolder, "whisperlog.txt");
Main.Help = new File(Main.dataFolder, "help.txt");
+ Main.WordListFile = new File(Main.dataFolder, "wordlist.txt");
+ Main.WhiteListFile = new File(Main.dataFolder, "whitelist.txt");
if (!Main.WhisperLog.exists()) {
Main.WhisperLog.getParentFile().mkdirs();
@@ -112,6 +170,16 @@ private void saveResourceFiles() {
saveStreamToFile(getResource("help.txt"), Main.Help);
}
+ if (!Main.WordListFile.exists()) {
+ Main.WordListFile.getParentFile().mkdirs();
+ saveStreamToFile(getResource("wordlist.txt"), Main.WordListFile);
+ }
+
+ if (!Main.WhiteListFile.exists()) {
+ Main.WhiteListFile.getParentFile().mkdirs();
+ saveStreamToFile(getResource("whitelist.txt"), Main.WhiteListFile);
+ }
+
// Save the default config file, if it does not exist
saveDefaultConfig();
@@ -203,6 +271,9 @@ public boolean onCommand(final @NotNull CommandSender sender, final @NotNull Com
if (args.length > 0 && args[0].equalsIgnoreCase("reload")) {
reloadConfig();
saveConfig();
+ loadFilters();
+ reloadGateConfig();
+ if (publicChat != null) publicChat.reload();
sender.sendMessage("Config reloaded");
return true;
}
diff --git a/src/main/java/org/zeroBzeroT/chatCo/PublicChat.java b/src/main/java/org/zeroBzeroT/chatCo/PublicChat.java
index b744ff4..b4ae326 100644
--- a/src/main/java/org/zeroBzeroT/chatCo/PublicChat.java
+++ b/src/main/java/org/zeroBzeroT/chatCo/PublicChat.java
@@ -21,11 +21,20 @@
import org.bukkit.event.player.PlayerQuitEvent;
import java.io.File;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
import java.util.regex.Pattern;
public class PublicChat implements Listener {
public final Main plugin;
private final FileConfiguration permissionConfig;
+ private volatile Map chatPrefixes = Collections.emptyMap();
+ private volatile List chatInlineColors = Collections.emptyList();
+
+ private record InlineColor(String configKey, Pattern pattern, int triggerLength, NamedTextColor color) {}
public static final Pattern DEFAULT_URL_PATTERN = Pattern.compile("https?://(?:www\\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b[-a-zA-Z0-9()@:%_\\\\+.~#?&/=]*");
@@ -33,16 +42,45 @@ public PublicChat(final Main plugin) {
this.plugin = plugin;
File customConfig = Main.PermissionConfig;
permissionConfig = YamlConfiguration.loadConfiguration(customConfig);
+ reload();
+ }
+
+ public void reload() {
+ Map prefixes = new HashMap<>();
+ List inlines = new ArrayList<>();
+
+ for (String color : NamedTextColor.NAMES.keys()) {
+ String prefixKey = "ChatCo.chatPrefixes." + color;
+ String prefixValue = plugin.getConfig().getString(prefixKey);
+ if (prefixValue != null) {
+ prefixes.put(color, prefixValue);
+ }
+
+ String inlineKey = "ChatCo.chatColors." + color;
+ String inlineValue = plugin.getConfig().getString(inlineKey);
+ if (inlineValue != null) {
+ inlines.add(new InlineColor(
+ inlineKey,
+ Pattern.compile(Pattern.quote(inlineValue) + ".*$"),
+ inlineValue.length(),
+ NamedTextColor.NAMES.value(color)
+ ));
+ }
+ }
+
+ this.chatPrefixes = prefixes;
+ this.chatInlineColors = inlines;
}
public Component replacePrefixColors(Component message, final Player player) {
String messagePlain = PlainTextComponentSerializer.plainText().serialize(message);
- for (String color : NamedTextColor.NAMES.keys()) {
+ for (Map.Entry entry : chatPrefixes.entrySet()) {
+ String color = entry.getKey();
+ String configValue = entry.getValue();
String configKey = "ChatCo.chatPrefixes." + color;
- String configValue = plugin.getConfig().getString(configKey);
- if (configValue != null && messagePlain.startsWith(configValue)) {
+ if (messagePlain.startsWith(configValue)) {
if (permissionConfig.getBoolean(configKey, false) || player.hasPermission(configKey)) {
return message.color(NamedTextColor.NAMES.value(color));
}
@@ -53,17 +91,14 @@ public Component replacePrefixColors(Component message, final Player player) {
}
public Component replaceInlineColors(Component message, final Player player) {
- for (String color : NamedTextColor.NAMES.keys()) {
- String configKey = "ChatCo.chatColors." + color;
- String configValue = plugin.getConfig().getString(configKey);
-
- if (configValue != null) {
- if (permissionConfig.getBoolean(configKey, false) || player.hasPermission(configKey)) {
- return message.replaceText(TextReplacementConfig.builder()
- .match(Pattern.quote(configValue) + ".*$")
- .replacement(s -> s.content(s.content().substring(configValue.length())).color(NamedTextColor.NAMES.value(color)))
- .build());
- }
+ for (InlineColor ic : chatInlineColors) {
+ if (permissionConfig.getBoolean(ic.configKey(), false) || player.hasPermission(ic.configKey())) {
+ final NamedTextColor namedColor = ic.color();
+ final int triggerLen = ic.triggerLength();
+ return message.replaceText(TextReplacementConfig.builder()
+ .match(ic.pattern())
+ .replacement(s -> s.content(s.content().substring(triggerLen)).color(namedColor))
+ .build());
}
}
@@ -83,6 +118,14 @@ private Component replaceUrls(Component component) {
);
}
+ private Component buildMessage(Player senderPlayer, Component sender, String text) {
+ Component messageText = Component.text(text);
+ messageText = replacePrefixColors(messageText, senderPlayer);
+ messageText = replaceInlineColors(messageText, senderPlayer);
+ messageText = replaceUrls(messageText);
+ return Component.text("").append(Component.text("<")).append(sender).append(Component.text("> ")).append(messageText);
+ }
+
/**
* See Text (Chat Components)
*/
@@ -96,20 +139,30 @@ public void onAsyncChat(AsyncChatEvent event) {
return;
}
- // Message text
- Component messageText = Component.text(legacyMessage);
-
// Player
final Player player = event.getPlayer();
+ final boolean gated = plugin.isGated(player);
- // Replace color codes
- messageText = replacePrefixColors(messageText, player);
- messageText = replaceInlineColors(messageText, player);
+ // Apply gate filters (word filter + link block) to the outgoing text
+ if (gated) {
+ if (plugin.getConfig().getBoolean("ChatCo.playtimeGate.wordFilter.enabled", true)
+ && plugin.getWordFilter() != null && plugin.getWordFilter().isLoaded()) {
+ legacyMessage = plugin.getWordFilter().apply(legacyMessage);
+ }
- // Clickable links
- messageText = replaceUrls(messageText);
+ if (plugin.getConfig().getBoolean("ChatCo.playtimeGate.linkBlock.enabled", true)
+ && plugin.getLinkBlocker() != null && plugin.getLinkBlocker().isLoaded()) {
+ legacyMessage = plugin.getLinkBlocker().apply(legacyMessage);
+ }
+ }
+
+ // If filtering emptied the message, drop it entirely
+ if (gated && legacyMessage.trim().isEmpty()) {
+ event.viewers().clear();
+ event.setCancelled(true);
+ return;
+ }
- // Sender name
Component sender = player.displayName();
if (plugin.getConfig().getBoolean("ChatCo.whisperOnClick", true)) {
@@ -117,10 +170,7 @@ public void onAsyncChat(AsyncChatEvent event) {
sender = sender.hoverEvent(HoverEvent.hoverEvent(HoverEvent.Action.SHOW_TEXT, Component.text("Whisper to " + player.getName())));
}
- // Build Message
- TextComponent message = Component.text("").append(Component.text("<")).append(sender).append(Component.text("> ")).append(messageText);
-
- // Send to the players
+ // Send to the players, per-recipient so gated viewers can see a filtered version
if (!plugin.getConfig().getBoolean("ChatCo.chatDisabled", false)) {
for (Audience recipient : event.viewers()) {
try {
@@ -133,13 +183,43 @@ public void onAsyncChat(AsyncChatEvent event) {
continue;
}
- recipient.sendMessage(message);
+ // per recipient gate: gated viewers get the sanitized text, others get the original
+ String recipientText = legacyMessage;
+ if (recipient instanceof Player && plugin.isGated((Player) recipient)) {
+ if (plugin.getConfig().getBoolean("ChatCo.playtimeGate.wordFilter.enabled", true)
+ && plugin.getWordFilter() != null && plugin.getWordFilter().isLoaded()) {
+ recipientText = plugin.getWordFilter().apply(recipientText);
+ }
+ if (plugin.getConfig().getBoolean("ChatCo.playtimeGate.linkBlock.enabled", true)
+ && plugin.getLinkBlocker() != null && plugin.getLinkBlocker().isLoaded()) {
+ recipientText = plugin.getLinkBlocker().apply(recipientText);
+ }
+ if (recipientText.trim().isEmpty()) continue;
+ }
+
+ Component recipientMessage = buildMessage(player, sender, recipientText);
+ recipient.sendMessage(recipientMessage);
} catch (NullPointerException e) {
e.printStackTrace();
}
}
}
+ // 1 time rules notice for gated senders (do we need to store it in Redis so it persists across restart? probably not)
+ if (gated
+ && plugin.getConfig().getBoolean("ChatCo.playtimeGate.rulesNotice.enabled", true)) {
+ ChatPlayer chatPlayer = plugin.getChatPlayer(player);
+ if (!chatPlayer.rulesNoticeSent) {
+ String rulesMsg = plugin.getConfig().getString("ChatCo.playtimeGate.rulesNotice.message");
+ if (rulesMsg != null && !rulesMsg.isEmpty()) {
+ for (String line : rulesMsg.split("\\n")) {
+ player.sendMessage(LegacyComponentSerializer.legacyAmpersand().deserialize(line));
+ }
+ }
+ chatPlayer.rulesNoticeSent = true;
+ }
+ }
+
// Do not send it to the players again - no event cancelling, so that other plugins can process the chat
event.viewers().clear();
}
diff --git a/src/main/java/org/zeroBzeroT/chatCo/Redis.java b/src/main/java/org/zeroBzeroT/chatCo/Redis.java
new file mode 100644
index 0000000..9d3dcdf
--- /dev/null
+++ b/src/main/java/org/zeroBzeroT/chatCo/Redis.java
@@ -0,0 +1,50 @@
+package org.zeroBzeroT.chatCo;
+
+import net.kyori.adventure.text.Component;
+import org.bukkit.Bukkit;
+import redis.clients.jedis.HostAndPort;
+import redis.clients.jedis.Jedis;
+import redis.clients.jedis.exceptions.JedisConnectionException;
+
+import java.util.Optional;
+import java.util.concurrent.CompletableFuture;
+
+public class Redis {
+
+ private static boolean warnedDown = false;
+
+ private static HostAndPort getHost() {
+ return new HostAndPort(
+ Main.getInstance().getConfig().getString("ChatCo.redis.host", "127.0.0.1"),
+ Main.getInstance().getConfig().getInt("ChatCo.redis.port", 6379));
+ }
+
+ private static Optional read(String key) {
+ final HostAndPort host = getHost();
+ final Jedis jedis;
+ final String value;
+ try {
+ jedis = new Jedis(host);
+ value = jedis.get(key);
+ } catch (JedisConnectionException e) {
+ if (!warnedDown) {
+ warnedDown = true;
+ Bukkit.getLogger().warning("[ChatCo] Redis not reachable at "
+ + host.getHost() + ":" + host.getPort()
+ + " (" + e.getMessage() + "). Gating will fail closed.");
+ }
+ return Optional.empty();
+ }
+ jedis.close();
+ if (value == null) return Optional.empty();
+ return Optional.of(value);
+ }
+
+ public static CompletableFuture> readAsync(String key) {
+ return CompletableFuture.supplyAsync(() -> read(key));
+ }
+
+ public static Optional readSync(String key) {
+ return read(key);
+ }
+}
diff --git a/src/main/java/org/zeroBzeroT/chatCo/WordFilter.java b/src/main/java/org/zeroBzeroT/chatCo/WordFilter.java
new file mode 100644
index 0000000..fd0fdb1
--- /dev/null
+++ b/src/main/java/org/zeroBzeroT/chatCo/WordFilter.java
@@ -0,0 +1,161 @@
+package org.zeroBzeroT.chatCo;
+
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileReader;
+import java.io.IOException;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+
+public class WordFilter {
+
+ private Set words = new HashSet<>();
+ private Set whitelist = new HashSet<>();
+ private int minWordLength = Integer.MAX_VALUE;
+ private boolean fuzzy = false;
+ private String replacement = "bobba";
+ private boolean loaded = false;
+
+ public synchronized void load(File wordlist, File whitelistFile, boolean fuzzy, String replacement) {
+ this.fuzzy = fuzzy;
+ this.replacement = replacement == null ? "bobba" : replacement;
+ this.words = readSet(wordlist);
+ this.whitelist = readSet(whitelistFile);
+ this.minWordLength = Integer.MAX_VALUE;
+ for (String w : words) {
+ if (w.length() < minWordLength) minWordLength = w.length();
+ }
+ this.loaded = true;
+ }
+
+ public synchronized String apply(String message) {
+ if (!loaded || words.isEmpty() || message == null || message.isEmpty()) {
+ return message;
+ }
+
+ StringBuilder out = new StringBuilder(message);
+ String normalized = leetNormalize(message.toLowerCase(Locale.ROOT));
+
+ int n = normalized.length();
+ int i = 0;
+ while (i < n) {
+ while (i < n && !isLetter(normalized.charAt(i))) i++;
+ if (i >= n) break;
+ int start = i;
+ while (i < n && isLetter(normalized.charAt(i))) i++;
+ int tokenLen = i - start;
+ if (tokenLen < minWordLength) continue;
+ if (isMatch(normalized, start, tokenLen)) {
+ int end = i;
+ if (replacement.isEmpty()) {
+ int clearEnd = end;
+ if (clearEnd < out.length() && out.charAt(clearEnd) == ' ') {
+ clearEnd++;
+ }
+ for (int k = start; k < clearEnd; k++) {
+ out.setCharAt(k, ' ');
+ }
+ } else {
+ for (int k = start; k < end; k++) {
+ out.setCharAt(k, replacement.charAt(Math.min(k - start, replacement.length() - 1)));
+ }
+ }
+ }
+ }
+ return out.toString();
+ }
+
+ private boolean isMatch(String normalized, int start, int len) {
+ for (String wl : whitelist) {
+ if (wl.length() == len && regionMatchesIgnoreCase(normalized, start, wl, 0, len)) {
+ return false;
+ }
+ }
+ for (String w : words) {
+ if (w.length() != len) continue;
+ if (regionMatchesIgnoreCase(normalized, start, w, 0, len)) return true;
+ }
+ if (!fuzzy) return false;
+ if (len < 4) return false;
+ for (String w : words) {
+ if (Math.abs(w.length() - len) > 1) continue;
+ if (levenRegion(normalized, start, len, w)) return true;
+ }
+ return false;
+ }
+
+ private static boolean regionMatchesIgnoreCase(String s, int sOff, String other, int oOff, int len) {
+ for (int k = 0; k < len; k++) {
+ char a = s.charAt(sOff + k);
+ char b = other.charAt(oOff + k);
+ if (a == b) continue;
+ char al = (a >= 'A' && a <= 'Z') ? (char) (a + 32) : a;
+ char bl = (b >= 'A' && b <= 'Z') ? (char) (b + 32) : b;
+ if (al != bl) return false;
+ }
+ return true;
+ }
+
+ private static boolean isLetter(char c) {
+ return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z');
+ }
+
+ private static String leetNormalize(String s) {
+ StringBuilder b = new StringBuilder(s.length());
+ for (int i = 0; i < s.length(); i++) {
+ char c = s.charAt(i);
+ switch (c) {
+ case '0': b.append('o'); break;
+ case '1': b.append('i'); break;
+ case '3': b.append('e'); break;
+ case '4': b.append('a'); break;
+ case '5': b.append('s'); break;
+ case '7': b.append('t'); break;
+ case '@': b.append('a'); break;
+ case '$': b.append('s'); break;
+ default: b.append(c);
+ }
+ }
+ return b.toString();
+ }
+
+ private static boolean levenRegion(String s, int sOff, int sLen, String t) {
+ int tLen = t.length();
+ int[] prev = new int[tLen + 1];
+ int[] curr = new int[tLen + 1];
+ for (int j = 0; j <= tLen; j++) prev[j] = j;
+ for (int i = 1; i <= sLen; i++) {
+ curr[0] = i;
+ char sc = (s.charAt(sOff + i - 1) >= 'A' && s.charAt(sOff + i - 1) <= 'Z')
+ ? (char) (s.charAt(sOff + i - 1) + 32) : s.charAt(sOff + i - 1);
+ for (int j = 1; j <= tLen; j++) {
+ char tc = t.charAt(j - 1);
+ int cost = sc == tc ? 0 : 1;
+ curr[j] = Math.min(Math.min(curr[j - 1] + 1, prev[j] + 1), prev[j - 1] + cost);
+ }
+ int[] tmp = prev; prev = curr; curr = tmp;
+ }
+ return prev[tLen] <= 1;
+ }
+
+ private static Set readSet(File f) {
+ Set out = new HashSet<>();
+ if (f == null || !f.exists()) return out;
+ try (BufferedReader r = new BufferedReader(new FileReader(f))) {
+ String line;
+ while ((line = r.readLine()) != null) {
+ String t = line.trim().toLowerCase(Locale.ROOT);
+ if (t.isEmpty() || t.startsWith("#")) continue;
+ out.add(t);
+ }
+ } catch (IOException e) {
+ // best-effort
+ }
+ return out;
+ }
+
+ public boolean isLoaded() {
+ return loaded;
+ }
+}
diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml
index fe2cd77..aa616f2 100644
--- a/src/main/resources/config.yml
+++ b/src/main/resources/config.yml
@@ -63,4 +63,28 @@ ChatCo:
strikethrough: null
underline: null
white: null
- yellow: null
\ No newline at end of file
+ yellow: null
+
+ redis:
+ host: 127.0.0.1
+ port: 6379
+
+ playtimeGate:
+ thresholdHours: 10
+
+ wordFilter:
+ enabled: true
+ # set to "" to delete the matched word entirely
+ replacement: "*"
+ fuzzy: false
+
+ rulesNotice:
+ enabled: true
+ message: |-
+ &6Please follow the community standards:
+ &9https://www.minecraft.net/en-us/community-standards
+
+ linkBlock:
+ enabled: true
+ # set to "" to delete the URL entirely
+ replacement: "[link removed]"
\ No newline at end of file
diff --git a/src/main/resources/whitelist.txt b/src/main/resources/whitelist.txt
new file mode 100644
index 0000000..9078637
--- /dev/null
+++ b/src/main/resources/whitelist.txt
@@ -0,0 +1,2 @@
+example.com
+minecraft.net
diff --git a/src/main/resources/wordlist.txt b/src/main/resources/wordlist.txt
new file mode 100644
index 0000000..f6ac19d
--- /dev/null
+++ b/src/main/resources/wordlist.txt
@@ -0,0 +1,772 @@
+# minecraft bedrock edition's block list https://github.com/The-phoenixR/minecraft-profanity/tree/main
+1488
+8=D
+A55hole
+abortion
+ahole
+AIDS
+AIDs
+ainujin
+ainuzin
+akimekura
+Anal
+anus
+anuses
+Anushead
+anuslick
+anuss
+aokan
+Arsch
+Arschloch
+arse
+arsed
+arsehole
+arseholed
+arseholes
+arseholing
+arselicker
+arses
+Ass
+asshat
+asshole
+assholed
+assholes
+assholing
+asslick
+asslicker
+asses
+Auschwitz
+b00bs
+b00bz
+b1tc
+Baise
+bakachon
+bakatyon
+Ballsack
+Ballzack
+BAMF
+Bastard
+Beaner
+Beeatch
+beeeyotch
+beefwhistle
+beeotch
+Beetch
+beeyotch
+Bellend
+bestiality
+beyitch
+beyotch
+Biach
+bin laden
+binladen
+biotch
+bitch
+bitches
+Bitching
+blad
+bladt
+blowjob
+blow job
+blowme
+blow me
+blyad
+blyadt
+bon3r
+boner
+boobs
+boobz
+Btch
+Bukakke
+Bullshit
+butagorosi
+butthead
+Butthole
+Buttplug
+c0ck
+Cabron
+Cacca
+Cadela
+Cagada
+Cameljockey
+Caralho
+castrate
+Cazzo
+ceemen
+ch1nk
+chankoro
+chieokure
+chikusatsu
+Ching chong
+Chinga
+Chingada Madre
+Chingado
+Chingate
+chink
+chinpo
+Chlamydia
+choad
+chode
+chonga
+chonko
+chonkoro
+chourimbo
+chourinbo
+chourippo
+chuurembo
+chuurenbo
+circlejerk
+cl1t
+cli7
+clit
+clitoris
+cocain
+Cocaine
+cock
+Cocksucker
+Coglione
+Coglioni
+coitus
+coituss
+cojelon
+cojones
+condom
+coon
+coon hunt
+coon kill
+coonhunt
+coonkill
+Cooter
+cotton pic
+cotton pik
+cottonpic
+cottonpik
+Crackhead
+crap
+CSAM
+Culear
+Culero
+Culo
+Cum
+cumming
+cun7
+cunt
+cvn7
+cvnt
+cyka
+d1kc
+d4go
+dago
+Darkie
+Deez Nuts
+Deez Nutz
+deeznut
+deeznuts
+deeznutz
+Dickhead
+dickwad
+dick
+dicks
+dikc
+dildo
+Dio Bestia
+dong
+dongs
+douche
+Downie
+Dumass
+Dumbass
+Durka durka
+Dyke
+Ejaculate
+Encule
+enjokousai
+enzyokousai
+etahinin
+etambo
+etanbo
+f0ck
+f0kc
+f3lch
+facking
+fag
+faggot
+fags
+faggots
+Fanculo
+Fanny
+fatass
+fck
+Fckn
+fcuk
+fcuuk
+felch
+fellatio
+Fetish
+Fgt
+FiCKDiCH
+Figlio di Puttana
+fku
+fock
+fokc
+foreskin
+Fotze
+Foutre
+fucc
+fuck
+fucks
+fuckd
+fucked
+fucker
+fuckers
+fucking
+fuckr
+fucky
+fuct
+fujinoyamai
+fukashokumin
+Fupa
+fuuck
+fuuckd
+fuucked
+fuucker
+fuucking
+fuuckr
+fuuuck
+fuuuckd
+fuuucked
+fuuucker
+fuuucking
+fuuuckr
+fuuuuck
+fuuuuckd
+fuuuucked
+fuuuucker
+fuuuucking
+fuuuuckr
+fuuuuuck
+fuuuuuckd
+fuuuuucked
+fuuuuucker
+fuuuuucking
+fuuuuuckr
+fuuuuuuck
+fuuuuuuckd
+fuuuuuucked
+fuuuuuucker
+fuuuuuucking
+fuuuuuuckr
+fuuuuuuuck
+fuuuuuuuckd
+fuuuuuuucked
+fuuuuuuucker
+fuuuuuuucking
+fuuuuuuuckr
+fuuuuuuuuck
+fuuuuuuuuckd
+fuuuuuuuucked
+fuuuuuuuucker
+fuuuuuuuucking
+fuuuuuuuuckr
+fuuuuuuuuuck
+fuuuuuuuuuckd
+fuuuuuuuuucked
+fuuuuuuuuucker
+fuuuuuuuuucking
+fuuuuuuuuuckr
+fuuuuuuuuuu
+fvck
+fxck
+fxuxcxk
+g000k
+g00k
+g0ok
+gestapo
+go0k
+god damn
+goddamn
+goldenshowers
+golliwogg
+gollywog
+Gooch
+gook
+goook
+Gyp
+h0m0
+h0mo
+h1tl3
+h1tle
+hairpie
+hakujakusha
+hakuroubyo
+hakuzyakusya
+hantoujin
+hantouzin
+Herpes
+hitl3r
+hitler
+hitlr
+holocaust
+hom0
+homo
+honky
+Hooker
+hor3
+hore
+hukasyokumin
+Hurensohn
+huzinoyamai
+hymen
+inc3st
+incest
+Inculato
+Injun
+intercourse
+inugoroshi
+inugorosi
+j1g4b0
+j1g4bo
+j1gab0
+j1gabo
+Jack Off
+jackass
+jap
+JerkOff
+jig4b0
+jig4bo
+jigabo
+Jigaboo
+jiggaboo
+jizz
+Joder
+Joto
+Jungle Bunny
+junglebunny
+k k k
+k1k3
+kichigai
+kik3
+Kike
+kikeiji
+kikeizi
+Kilurself
+kitigai
+kkk
+klu klux
+Klu Klux Klan
+kluklux
+knobhead
+koon hunt
+koon kill
+koonhunt
+koonkill
+koroshiteyaru
+koumoujin
+koumouzin
+ku klux klan
+kun7
+kurombo
+Kurva
+Kurwa
+kxkxk
+l3sb0
+lesbo
+lezbo
+lezzie
+m07th3rfukr
+m0th3rfvk3r
+m0th3rfvker
+Madonna Puttana
+manberries
+manko
+manshaft
+Maricon
+Masterbat
+masterbate
+Masturbacion
+masturbait
+Masturbare
+Masturbate
+Masturbazione
+Merda
+Merde
+Meth
+Mierda
+milf
+Minge
+Miststück
+mitsukuchi
+mitukuti
+Molest
+molester
+molestor
+Mong
+Moon Cricket
+moth3rfucer
+moth3rfvk3r
+moth3rfvker
+motherfucker
+Mulatto
+n1663r
+n1664
+n166a
+n166er
+n1g3r
+n1German
+n1gg3r
+n1gGerman
+n3gro
+n4g3r
+n4gg3r
+n4gGerman
+n4z1
+nag3r
+nagg3r
+nagGerman
+natzi
+naz1
+nazi
+nazl
+neGerman
+ngGerman
+nggr
+NhigGerman
+ni666
+ni66a
+ni66er
+ni66g
+ni6g
+ni6g6
+ni6gg
+Nig
+nig66
+nig6g
+nigar
+niGerman
+nigg3
+nigg6
+nigga
+niggaz
+nigGerman
+nigger
+niggers
+nigglet
+niggr
+nigguh
+niggur
+niggy
+niglet
+Nignog
+nimpinin
+ninpinin
+Nipples
+niqqa
+niqqer
+Nonce
+nugga
+Nutsack
+Nutted
+nygGerman
+omeko
+Orgy
+p3n15
+p3n1s
+p3ni5
+p3nis
+p3nl5
+p3nls
+Paki
+Panties
+Pedo
+pedoph
+pedophile
+pen15
+pen1s
+Pendejo
+peni5
+penile
+penis
+Penis
+penl5
+penls
+penus
+Perra
+phag
+phaggot
+phagot
+phuck
+Pikey
+Pinche
+Pizda
+Polla
+Porca Madonna
+Porch monkey
+Porn
+Pornhub
+Porra
+pr1ck
+preteen
+prick
+pu555y
+pu55y
+pub1c
+Pube
+pubic
+pun4ni
+pun4nl
+Punal
+punan1
+punani
+punanl
+puss1
+puss3
+puss5
+pusse
+pussi
+pussy
+pussys
+Pussies
+pusss1
+pussse
+pusssi
+pusssl
+pusssy
+Pussy
+Puta
+Putain
+Pute
+Puto
+Puttana
+Puttane
+Puttaniere
+puzzy
+pvssy
+queef
+r3c7um
+r4p15t
+r4p1st
+r4p3
+r4pi5t
+r4pist
+raape
+raghead
+raibyo
+Raip
+rap15t
+rap1st
+Rapage
+rape
+Raped
+rapi5t
+Raping
+rapist
+rectum
+Red Tube
+Reggin
+reipu
+retard
+Ricchione
+rimjob
+rimming
+rizzape
+rompari
+Salaud
+Salope
+sangokujin
+sangokuzin
+santorum
+Scheiße
+Schlampe
+Schlampe
+schlong
+Schwuchtel
+Scrote
+secks
+seishinhakujaku
+seishinijo
+seisinhakuzyaku
+seisinizyo
+Semen
+semushiotoko
+semusiotoko
+sh|t
+sh|thead
+sh|tstain
+sh17
+sh17head
+sh17stain
+sh1t
+sh1thead
+sh1tstain
+Shat
+Shemale
+shi7
+shi7head
+shi7stain
+shinajin
+shinheimin
+shirakko
+shit
+shithead
+shitstain
+shitting
+Shitty
+shokubutsuningen
+sinazin
+sinheimin
+Skank
+SMD
+Sodom
+sofa king
+sofaking
+Spanishick
+Spanishook
+Spanishunk
+STD
+STDs
+Succhia Cazzi
+suck my
+suckmy
+syokubutuningen
+Taint
+Tapatte
+Tapette
+Tarlouse
+tea bag
+teabag
+teebag
+teensex
+teino
+Testa di Cazzo
+Testicles
+Thot
+tieokure
+tinpo
+Tits
+Titz
+titties
+tittiez
+tokushugakkyu
+tokusyugakkyu
+torukoburo
+torukojo
+torukozyo
+tosatsu
+tosatu
+towelhead
+Trannie
+Tranny
+tunbo
+tw47
+tw4t
+twat
+tyankoro
+tyonga
+tyonko
+tyonkoro
+tyourinbo
+tyourippo
+tyurenbo
+ushigoroshi
+usigorosi
+v461n4
+v461na
+v46in4
+v46ina
+v4g1n4
+v4g1na
+v4gin4
+v4gina
+va61n4
+va61na
+va6in4
+va6ina
+Vaccagare
+Vaffanculo
+Vag
+vag1n4
+vag1na
+vagin4
+vagina
+VateFaire
+vvhitepower
+w3tb4ck
+w3tback
+Wank
+wanker
+wetb4ck
+wetback
+wh0r3
+wh0re
+white power
+whitepower
+whor3
+whore
+whores
+Wog
+Wop
+x8lp3t
+xbl pet
+XBLPET
+XBLRewards
+Xl3LPET
+yabunirami
+Zipperhead
+Блядь
+сука
+アオカン
+あおかん
+イヌゴロシ
+いぬごろし
+インバイ
+いんばい
+オナニー
+おなにー
+オメコ
+カワラコジキ
+かわらこじき
+カワラモノ
+かわらもの
+キケイジ
+きけいじ
+キチガイ
+きちがい
+キンタマ
+きんたま
+クロンボ
+くろんぼ
+コロシテヤル
+ころしてやる
+シナジン
+しなじん
+タチンボ
+たちんぼ
+チョンコウ
+ちょんこう
+チョンコロ
+ちょんころ
+ちょん公
+チンポ
+ちんぽ
+ツンボ
+つんぼ
+とるこじょう
+とるこぶろ
+トルコ嬢
+トルコ風呂
+ニガー
+ニグロ
+にんぴにん
+はんとうじん
+マンコ
+まんこ
+レイプ
+れいぷ
+低能
+屠殺
+強姦
+援交
+支那人
+精薄
+精薄者
+輪姦
\ No newline at end of file