diff --git a/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java b/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java new file mode 100644 index 0000000..2646353 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java @@ -0,0 +1,319 @@ +package net.viper.status.modules.chat; + +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Verwaltet die Verknüpfung von Minecraft-Accounts mit Discord/Telegram. + * + * Ablauf: + * 1. Spieler tippt /linkdiscord oder /linktelegram → Token wird generiert + * 2. Spieler schickt Token an den Bot + * 3. Bot-Polling erkennt Token → Verknüpfung wird gespeichert + * + * Speicherformat (chat_links.dat): + * minecraft:|name:|discord:|telegram: + */ +public class AccountLinkManager { + + private final File file; + private final Logger logger; + + // UUID → verknüpfte Accounts + private final ConcurrentHashMap links = new ConcurrentHashMap<>(); + + // Ausstehende Token: token → UUID (läuft nach 10 Min ab) + private final ConcurrentHashMap pendingTokens = new ConcurrentHashMap<>(); + + public AccountLinkManager(File dataFolder, Logger logger) { + this.file = new File(dataFolder, "chat_links.dat"); + this.logger = logger; + } + + // ===== Datenklassen ===== + + public static class LinkedAccount { + public UUID minecraftUUID; + public String minecraftName; + public String discordUserId = ""; // leer = nicht verknüpft + public String telegramUserId = ""; // leer = nicht verknüpft + public String telegramUsername = ""; // @username für Anzeige + public String discordUsername = ""; // für Anzeige + } + + private static class PendingToken { + UUID uuid; + String playerName; + String type; // "discord" oder "telegram" + long expiresAt; // Unix-Millis + + PendingToken(UUID uuid, String playerName, String type) { + this.uuid = uuid; + this.playerName = playerName; + this.type = type; + this.expiresAt = System.currentTimeMillis() + (10 * 60 * 1000L); // 10 Min + } + + boolean isExpired() { + return System.currentTimeMillis() > expiresAt; + } + } + + // ===== Token-Generierung ===== + + /** + * Generiert einen neuen Verknüpfungs-Token für einen Spieler. + * Bestehende Token für denselben Spieler+Typ werden überschrieben. + * + * @param uuid UUID des Spielers + * @param playerName Anzeigename + * @param type "discord" oder "telegram" + * @return 6-stelliger alphanumerischer Token (z.B. "A3F9K2") + */ + public String generateToken(UUID uuid, String playerName, String type) { + // Alte Token für diesen Spieler+Typ entfernen + pendingTokens.entrySet().removeIf(e -> + e.getValue().uuid.equals(uuid) && e.getValue().type.equals(type)); + + // Abgelaufene Token bereinigen + pendingTokens.entrySet().removeIf(e -> e.getValue().isExpired()); + + String token; + do { + token = generateRandomToken(); + } while (pendingTokens.containsKey(token)); + + pendingTokens.put(token, new PendingToken(uuid, playerName, type)); + return token; + } + + private String generateRandomToken() { + String chars = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // ohne I,O,0,1 (Verwechslungsgefahr) + Random rnd = new Random(); + StringBuilder sb = new StringBuilder(6); + for (int i = 0; i < 6; i++) sb.append(chars.charAt(rnd.nextInt(chars.length()))); + return sb.toString(); + } + + // ===== Token einlösen ===== + + /** + * Versucht einen Token einzulösen (aufgerufen wenn Bot eine Nachricht empfängt). + * + * @param token Der eingesendete Token + * @param externalId Discord User-ID oder Telegram User-ID (als String) + * @param externalName Discord-Username oder Telegram-@username + * @return LinkedAccount wenn erfolgreich, null wenn Token ungültig/abgelaufen + */ + public LinkedAccount redeemToken(String token, String externalId, String externalName, String type) { + token = token.trim().toUpperCase(); + PendingToken pending = pendingTokens.get(token); + + if (pending == null || pending.isExpired()) { + pendingTokens.remove(token); + return null; + } + // Typ muss übereinstimmen + if (!pending.type.equals(type)) return null; + + pendingTokens.remove(token); + + // Bestehenden Account holen oder neu anlegen + LinkedAccount account = links.computeIfAbsent(pending.uuid, k -> { + LinkedAccount a = new LinkedAccount(); + a.minecraftUUID = pending.uuid; + a.minecraftName = pending.playerName; + return a; + }); + account.minecraftName = pending.playerName; // aktuell halten + + if ("discord".equals(pending.type)) { + account.discordUserId = externalId; + account.discordUsername = externalName; + } else if ("telegram".equals(pending.type)) { + account.telegramUserId = externalId; + account.telegramUsername = externalName; + } + + save(); + return account; + } + + // ===== Lookup ===== + + public LinkedAccount getByUUID(UUID uuid) { + return links.get(uuid); + } + + public LinkedAccount getByDiscordId(String discordUserId) { + for (LinkedAccount a : links.values()) { + if (discordUserId.equals(a.discordUserId)) return a; + } + return null; + } + + public LinkedAccount getByTelegramId(String telegramUserId) { + for (LinkedAccount a : links.values()) { + if (telegramUserId.equals(a.telegramUserId)) return a; + } + return null; + } + + /** Gibt den Minecraft-Namen für eine Discord-User-ID zurück, oder null. */ + public String getMinecraftNameByDiscordId(String discordUserId) { + LinkedAccount a = getByDiscordId(discordUserId); + return a != null ? a.minecraftName : null; + } + + /** Gibt den Minecraft-Namen für eine Telegram-User-ID zurück, oder null. */ + public String getMinecraftNameByTelegramId(String telegramUserId) { + LinkedAccount a = getByTelegramId(telegramUserId); + return a != null ? a.minecraftName : null; + } + + /** Prüft ob ein Token gerade aussteht (für Tab-Complete etc.). */ + public boolean hasPendingToken(UUID uuid, String type) { + for (PendingToken t : pendingTokens.values()) { + if (t.uuid.equals(uuid) && t.type.equals(type) && !t.isExpired()) return true; + } + return false; + } + + // ===== Verknüpfung aufheben ===== + + public boolean unlinkDiscord(UUID uuid) { + LinkedAccount a = links.get(uuid); + if (a == null || a.discordUserId.isEmpty()) return false; + a.discordUserId = ""; + a.discordUsername = ""; + cleanupEmpty(uuid); + save(); + return true; + } + + public boolean unlinkTelegram(UUID uuid) { + LinkedAccount a = links.get(uuid); + if (a == null || a.telegramUserId.isEmpty()) return false; + a.telegramUserId = ""; + a.telegramUsername = ""; + cleanupEmpty(uuid); + save(); + return true; + } + + private void cleanupEmpty(UUID uuid) { + LinkedAccount a = links.get(uuid); + if (a != null && a.discordUserId.isEmpty() && a.telegramUserId.isEmpty()) { + links.remove(uuid); + } + } + + // ===== Convenience-Methoden für Bridges ===== + + /** + * Löst einen Telegram-Token ein. + * Wrapper für redeemToken mit type="telegram". + */ + public LinkedAccount redeemTelegram(String token, String telegramUserId, String telegramUsername) { + return redeemToken(token, telegramUserId, telegramUsername, "telegram"); + } + + /** + * Löst einen Discord-Token ein. + * Wrapper für redeemToken mit type="discord". + */ + public LinkedAccount redeemDiscord(String token, String discordUserId, String discordUsername) { + return redeemToken(token, discordUserId, discordUsername, "discord"); + } + + /** + * Gibt den Anzeigenamen für einen Telegram-Nutzer zurück. + * Wenn verknüpft: "MinecraftName (@telegram)", sonst: "@telegram" + */ + public String resolveTelegramName(String telegramUserId, String fallbackName) { + String mc = getMinecraftNameByTelegramId(telegramUserId); + return mc != null ? mc : fallbackName; + } + + /** + * Gibt den Anzeigenamen für einen Discord-Nutzer zurück. + * Wenn verknüpft: Minecraft-Name, sonst: Discord-Username + */ + public String resolveDiscordName(String discordUserId, String fallbackName) { + String mc = getMinecraftNameByDiscordId(discordUserId); + return mc != null ? mc : fallbackName; + } + + /** + * Gibt das korrekte Format-String zurück abhängig ob Account verknüpft. + * linked=true → linkedFormat (mit {player}), false → unlinkedFormat (mit {user}) + */ + public boolean isLinkedTelegram(String telegramUserId) { + return getByTelegramId(telegramUserId) != null; + } + + public boolean isLinkedDiscord(String discordUserId) { + return getByDiscordId(discordUserId) != null; + } + + // ===== Persistenz ===== + + public void save() { + try (BufferedWriter bw = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + for (LinkedAccount a : links.values()) { + bw.write(a.minecraftUUID + + "|" + esc(a.minecraftName) + + "|" + esc(a.discordUserId) + + "|" + esc(a.discordUsername) + + "|" + esc(a.telegramUserId) + + "|" + esc(a.telegramUsername)); + bw.newLine(); + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Speichern der Account-Links: " + e.getMessage()); + } + } + + public void load() { + links.clear(); + if (!file.exists()) return; + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] p = line.split("\\|", -1); + if (p.length < 6) continue; + try { + LinkedAccount a = new LinkedAccount(); + a.minecraftUUID = UUID.fromString(p[0]); + a.minecraftName = unesc(p[1]); + a.discordUserId = unesc(p[2]); + a.discordUsername = unesc(p[3]); + a.telegramUserId = unesc(p[4]); + a.telegramUsername = unesc(p[5]); + if (!a.discordUserId.isEmpty() || !a.telegramUserId.isEmpty()) { + links.put(a.minecraftUUID, a); + } + } catch (Exception ignored) {} + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Laden der Account-Links: " + e.getMessage()); + } + logger.info("[ChatModule] " + links.size() + " Account-Verknüpfungen geladen."); + } + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("|", "\\p"); + } + + private static String unesc(String s) { + if (s == null) return ""; + return s.replace("\\p", "|").replace("\\\\", "\\"); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/BlockManager.java b/src/main/java/net/viper/status/modules/chat/BlockManager.java new file mode 100644 index 0000000..c67994c --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/BlockManager.java @@ -0,0 +1,124 @@ +package net.viper.status.modules.chat; + +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Verwaltet den gegenseitigen Blockier-/Ignore-Status zwischen Spielern. + * + * Admins/OPs mit dem Bypass-Permission sind nicht blockierbar. + * + * Format der Speicherdatei: + * |,,... + */ +public class BlockManager { + + private final File file; + private final Logger logger; + + // blocker UUID → Set der blockierten UUIDs + private final ConcurrentHashMap> blocked = new ConcurrentHashMap<>(); + + public BlockManager(File dataFolder, Logger logger) { + this.file = new File(dataFolder, "chat_blocked.dat"); + this.logger = logger; + } + + // ===== Block-Logik ===== + + /** Spieler `blocker` blockiert Spieler `target`. */ + public void block(UUID blocker, UUID target) { + blocked.computeIfAbsent(blocker, k -> Collections.newSetFromMap(new ConcurrentHashMap<>())) + .add(target); + save(); + } + + /** Spieler `blocker` hebt den Block für `target` auf. */ + public void unblock(UUID blocker, UUID target) { + Set set = blocked.get(blocker); + if (set != null) { + set.remove(target); + if (set.isEmpty()) blocked.remove(blocker); + } + save(); + } + + /** + * Prüft ob `blocker` den Spieler `target` blockiert hat. + * Admins (isAdmin=true) sind niemals blockiert. + */ + public boolean isBlocked(UUID blocker, UUID target) { + Set set = blocked.get(blocker); + return set != null && set.contains(target); + } + + /** + * Prüft ob eine Nachricht von `sender` an `receiver` zugestellt werden soll. + * Gibt false zurück, wenn einer der beiden den anderen blockiert. + */ + public boolean canReceive(UUID sender, UUID receiver) { + // receiver hat sender blockiert → keine Nachricht + if (isBlocked(receiver, sender)) return false; + // sender hat receiver blockiert → keine Nachricht (Komfort) + if (isBlocked(sender, receiver)) return false; + return true; + } + + /** Gibt alle UUIDs zurück, die `blocker` blockiert hat. */ + public Set getBlockedBy(UUID blocker) { + Set set = blocked.get(blocker); + if (set == null) return Collections.emptySet(); + return Collections.unmodifiableSet(set); + } + + // ===== Persistenz ===== + + public void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + for (Map.Entry> e : blocked.entrySet()) { + if (e.getValue().isEmpty()) continue; + StringBuilder sb = new StringBuilder(); + sb.append(e.getKey()).append("|"); + Iterator it = e.getValue().iterator(); + while (it.hasNext()) { + sb.append(it.next()); + if (it.hasNext()) sb.append(","); + } + bw.write(sb.toString()); + bw.newLine(); + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Speichern der Block-Liste: " + e.getMessage()); + } + } + + public void load() { + blocked.clear(); + if (!file.exists()) return; + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("\\|", 2); + if (parts.length < 2) continue; + try { + UUID blocker = UUID.fromString(parts[0]); + Set targets = Collections.newSetFromMap(new ConcurrentHashMap<>()); + for (String rawUUID : parts[1].split(",")) { + rawUUID = rawUUID.trim(); + if (!rawUUID.isEmpty()) { + try { targets.add(UUID.fromString(rawUUID)); } + catch (Exception ignored) {} + } + } + if (!targets.isEmpty()) blocked.put(blocker, targets); + } catch (Exception ignored) {} + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Laden der Block-Liste: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatChannel.java b/src/main/java/net/viper/status/modules/chat/ChatChannel.java new file mode 100644 index 0000000..def2022 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatChannel.java @@ -0,0 +1,69 @@ +package net.viper.status.modules.chat; + +/** + * Repräsentiert einen Chat-Kanal mit allen zugehörigen Einstellungen. + */ +public class ChatChannel { + + private final String id; + private final String name; + private final String symbol; + private final String permission; + private final String color; + private final String format; + private final boolean localOnly; + + // Bridge-Einstellungen + private final String discordWebhook; + private final String discordChannelId; + private final String telegramChatId; + private final int telegramThreadId; // 0 = kein Thema, >0 = Themen-ID + private final boolean useAdminBridge; + + public ChatChannel(String id, String name, String symbol, String permission, + String color, String format, boolean localOnly, + String discordWebhook, String discordChannelId, + String telegramChatId, int telegramThreadId, boolean useAdminBridge) { + this.id = id; + this.name = name; + this.symbol = symbol; + this.permission = permission; + this.color = color; + this.format = format; + this.localOnly = localOnly; + this.discordWebhook = discordWebhook; + this.discordChannelId = discordChannelId; + this.telegramChatId = telegramChatId; + this.telegramThreadId = telegramThreadId; + this.useAdminBridge = useAdminBridge; + } + + public String getId() { return id; } + public String getName() { return name; } + public String getSymbol() { return symbol; } + public String getPermission() { return permission; } + public String getColor() { return color; } + public String getFormat() { return format; } + public boolean isLocalOnly() { return localOnly; } + public String getDiscordWebhook() { return discordWebhook; } + public String getDiscordChannelId() { return discordChannelId; } + public String getTelegramChatId() { return telegramChatId; } + public int getTelegramThreadId() { return telegramThreadId; } + public boolean isUseAdminBridge() { return useAdminBridge; } + + /** Prüft ob der Kanal eine Permission erfordert. */ + public boolean hasPermission() { + return permission != null && !permission.isEmpty(); + } + + /** Gibt das formatierte Kanalprefix zurück, z.B. §a[G] */ + public String getFormattedTag() { + return net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', + color + "[" + symbol + "]"); + } + + @Override + public String toString() { + return "ChatChannel{id=" + id + ", name=" + name + "}"; + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatConfig.java b/src/main/java/net/viper/status/modules/chat/ChatConfig.java new file mode 100644 index 0000000..f4e4796 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatConfig.java @@ -0,0 +1,542 @@ +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; + +import java.io.*; +import java.nio.file.Files; +import java.util.*; + +/** + * Lädt und verwaltet die chat.yml Konfiguration. + */ +public class ChatConfig { + + private final Plugin plugin; + private Configuration config; + + // Geladene Kanäle + private final Map channels = new LinkedHashMap<>(); + private String defaultChannel; + + // HelpOp + private String helpopFormat; + private String helpopPermission; + private int helpopCooldown; + private String helpopConfirm; + private String helpopDiscordWebhook; + private String helpopTelegramChatId; + + // Broadcast + private String broadcastFormat; + private String broadcastPermission; + + // Private Messages + private boolean pmEnabled; + private String pmFormatSender; + private String pmFormatReceiver; + private String pmFormatSpy; + private String pmSpyPermission; + + // Mute + private int defaultMuteDuration; + private String mutedMessage; + + // Emoji + private boolean emojiEnabled; + private boolean emojiBedrockSupport; + private final Map emojiMappings = new LinkedHashMap<>(); + + // Discord + private boolean discordEnabled; + private String discordBotToken; + private String discordGuildId; + private int discordPollInterval; + private String discordFromFormat; + private String discordAdminChannelId; + private String discordEmbedColor; + + // Telegram + private boolean telegramEnabled; + private String telegramBotToken; + private int telegramPollInterval; + private String telegramFromFormat; + private String telegramAdminChatId; + private int telegramChatTopicId; + private int telegramAdminTopicId; + + // Account-Linking + private boolean linkingEnabled; + private String linkDiscordMessage; + private String linkTelegramMessage; + private String linkSuccessDiscord; + private String linkSuccessTelegram; + private String linkBotSuccessDiscord; + private String linkBotSuccessTelegram; + private String linkedDiscordFormat; + private String linkedTelegramFormat; + private int telegramAdminThreadId; + + // Admin + private String adminBypassPermission; + private String adminNotifyPermission; + + // ===== NEU: Chatlog ===== + private boolean chatlogEnabled; + private int chatlogRetentionDays; + + // ===== NEU: Server-Farben ===== + private final Map serverColors = new LinkedHashMap<>(); + private final Map serverDisplayNames = new LinkedHashMap<>(); + private String serverColorDefault; + + // ===== NEU: Reports ===== + private boolean reportsEnabled; + private String reportConfirm; + + // ===== Chat-Filter ===== + private ChatFilter.ChatFilterConfig filterConfig = new ChatFilter.ChatFilterConfig(); + + // ===== Mentions ===== + private boolean mentionsEnabled; + private String mentionsHighlightColor; + private String mentionsSound; + private boolean mentionsAllowToggle; + private String mentionsNotifyPrefix; + + // ===== Chat-History ===== + private int historyMaxLines; + private int historyDefaultLines; + private String reportPermission; + private String reportClosePermission; + private String reportViewPermission; + private int reportCooldown; + private String reportDiscordWebhook; + private String reportTelegramChatId; + private boolean reportWebhookEnabled; + + public ChatConfig(Plugin plugin) { + this.plugin = plugin; + } + + public void load() { + File file = new File(plugin.getDataFolder(), "chat.yml"); + if (!file.exists()) { + plugin.getDataFolder().mkdirs(); + InputStream in = plugin.getResourceAsStream("chat.yml"); + if (in != null) { + try { + Files.copy(in, file.toPath()); + } catch (IOException e) { + plugin.getLogger().severe("[ChatModule] Konnte chat.yml nicht erstellen: " + e.getMessage()); + } + } else { + plugin.getLogger().warning("[ChatModule] chat.yml nicht in JAR gefunden, erstelle leere Datei."); + try { file.createNewFile(); } catch (IOException ignored) {} + } + } + + try { + config = ConfigurationProvider.getProvider(YamlConfiguration.class).load(file); + } catch (IOException e) { + plugin.getLogger().severe("[ChatModule] Fehler beim Laden der chat.yml: " + e.getMessage()); + config = new Configuration(); + } + + parseConfig(); + plugin.getLogger().info("[ChatModule] " + channels.size() + " Kanäle geladen."); + } + + private void parseConfig() { + defaultChannel = config.getString("default-channel", "global"); + + // --- Kanäle --- + channels.clear(); + Configuration chSection = config.getSection("channels"); + if (chSection != null) { + for (String id : chSection.getKeys()) { + Configuration ch = chSection.getSection(id); + if (ch == null) continue; + channels.put(id.toLowerCase(), new ChatChannel( + id.toLowerCase(), + ch.getString("name", id), + ch.getString("symbol", id.substring(0, 1).toUpperCase()), + ch.getString("permission", ""), + ch.getString("color", "&f"), + ch.getString("format", "&8[&7{server}&8] {prefix}&r{player}{suffix}&8: &f{message}"), + ch.getBoolean("local-only", false), + ch.getString("discord-webhook", ""), + ch.getString("discord-channel-id", ""), + ch.getString("telegram-chat-id", ""), + ch.getInt("telegram-thread-id", 0), + ch.getBoolean("use-admin-bridge", false) + )); + } + } + + // Fallback: global-Kanal immer vorhanden + if (!channels.containsKey("global")) { + channels.put("global", new ChatChannel( + "global", "Global", "G", "", "&a", + "&8[&a{server}&8] {prefix}&r{player}{suffix}&8: &f{message}", + false, "", "", "", 0, false + )); + } + + // --- HelpOp --- + Configuration ho = config.getSection("helpop"); + if (ho != null) { + helpopFormat = ho.getString("format", "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}"); + helpopPermission = ho.getString("receive-permission", "chat.helpop.receive"); + helpopCooldown = ho.getInt("cooldown", 30); + helpopConfirm = ho.getString("confirm-message", "&aHilferuf gesendet!"); + helpopDiscordWebhook = ho.getString("discord-webhook", ""); + helpopTelegramChatId = ho.getString("telegram-chat-id", ""); + } + + // --- Broadcast --- + Configuration bc = config.getSection("broadcast"); + if (bc != null) { + broadcastFormat = bc.getString("format", "&c[&6Broadcast&c] &e{message}"); + broadcastPermission = bc.getString("permission", "chat.broadcast"); + } + + // --- Private Messages --- + Configuration pm = config.getSection("private-messages"); + if (pm != null) { + pmEnabled = pm.getBoolean("enabled", true); + pmFormatSender = pm.getString("format-sender", "&8[&7Du &8→ &b{player}&8] &f{message}"); + pmFormatReceiver = pm.getString("format-receiver", "&8[&b{player} &8→ &7Dir&8] &f{message}"); + pmFormatSpy = pm.getString("format-social-spy","&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}"); + pmSpyPermission = pm.getString("social-spy-permission", "chat.socialspy"); + } + + // --- Mute --- + Configuration mu = config.getSection("mute"); + if (mu != null) { + defaultMuteDuration = mu.getInt("default-duration-minutes", 60); + mutedMessage = mu.getString("muted-message", "&cDu bist stummgeschaltet. Noch: &f{time}"); + } + + // --- Emoji --- + Configuration em = config.getSection("emoji"); + if (em != null) { + emojiEnabled = em.getBoolean("enabled", true); + emojiBedrockSupport = em.getBoolean("bedrock-support", true); + emojiMappings.clear(); + Configuration map = em.getSection("mappings"); + if (map != null) { + for (String key : map.getKeys()) { + emojiMappings.put(key, map.getString(key, key)); + } + } + } + + // --- Discord --- + Configuration dc = config.getSection("discord"); + if (dc != null) { + discordEnabled = dc.getBoolean("enabled", false); + discordBotToken = dc.getString("bot-token", ""); + discordGuildId = dc.getString("guild-id", ""); + discordPollInterval = dc.getInt("poll-interval", 3); + discordFromFormat = dc.getString("from-discord-format", "&9[Discord] &b{user}&8: &f{message}"); + discordAdminChannelId = dc.getString("admin-channel-id", ""); + discordEmbedColor = dc.getString("embed-color", "5865F2"); + } + + // --- Telegram --- + Configuration tg = config.getSection("telegram"); + if (tg != null) { + telegramEnabled = tg.getBoolean("enabled", false); + telegramBotToken = tg.getString("bot-token", ""); + telegramPollInterval = tg.getInt("poll-interval", 3); + telegramFromFormat = tg.getString("from-telegram-format", "&3[Telegram] &b{user}&8: &f{message}"); + telegramAdminChatId = tg.getString("admin-chat-id", ""); + telegramChatTopicId = tg.getInt("chat-topic-id", 0); + telegramAdminTopicId = tg.getInt("admin-topic-id", 0); + } + + // --- Account-Linking --- + Configuration al = config.getSection("account-linking"); + if (al != null) { + linkingEnabled = al.getBoolean("enabled", true); + linkDiscordMessage = al.getString("discord-link-message", "&aCode: &f{token}"); + linkTelegramMessage = al.getString("telegram-link-message", "&aCode: &f{token}"); + linkSuccessDiscord = al.getString("success-discord", "&aDiscord verknüpft!"); + linkSuccessTelegram = al.getString("success-telegram", "&aTelegram verknüpft!"); + linkBotSuccessDiscord = al.getString("bot-success-discord", "✅ Verknüpft: {player}"); + linkBotSuccessTelegram = al.getString("bot-success-telegram", "✅ Verknüpft: {player}"); + linkedDiscordFormat = al.getString("linked-discord-format", + "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"); + linkedTelegramFormat = al.getString("linked-telegram-format", + "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}"); + } else { + linkingEnabled = true; + linkDiscordMessage = "&aCode: &f{token}"; + linkTelegramMessage = "&aCode: &f{token}"; + linkSuccessDiscord = "&aDiscord verknüpft!"; + linkSuccessTelegram = "&aTelegram verknüpft!"; + linkBotSuccessDiscord = "✅ Verknüpft: {player}"; + linkBotSuccessTelegram = "✅ Verknüpft: {player}"; + linkedDiscordFormat = "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"; + linkedTelegramFormat = "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}"; + } + + // --- Chat-Filter --- + filterConfig = new ChatFilter.ChatFilterConfig(); + Configuration cf = config.getSection("chat-filter"); + if (cf != null) { + Configuration spam = cf.getSection("anti-spam"); + if (spam != null) { + filterConfig.antiSpamEnabled = spam.getBoolean("enabled", true); + filterConfig.spamCooldownMs = spam.getInt("cooldown-ms", 1500); + filterConfig.spamMaxMessages = spam.getInt("max-messages", 3); + filterConfig.spamMessage = spam.getString("message", "&cNicht so schnell!"); + } + Configuration dup = cf.getSection("duplicate-check"); + if (dup != null) { + filterConfig.duplicateCheckEnabled = dup.getBoolean("enabled", true); + filterConfig.duplicateMessage = dup.getString("message", "&cKeine identischen Nachrichten."); + } + Configuration bl = cf.getSection("blacklist"); + if (bl != null) { + filterConfig.blacklistEnabled = bl.getBoolean("enabled", true); + filterConfig.blacklistWords.clear(); + // words ist eine YAML-Liste, nicht eine Section → getList() verwenden + try { + java.util.List wordList = bl.getList("words"); + if (wordList != null) { + for (Object o : wordList) { + if (o != null && !o.toString().trim().isEmpty()) { + filterConfig.blacklistWords.add(o.toString().trim()); + } + } + } + } catch (Exception ignored) {} + } + Configuration caps = cf.getSection("caps-filter"); + if (caps != null) { + filterConfig.capsFilterEnabled = caps.getBoolean("enabled", true); + filterConfig.capsMinLength = caps.getInt("min-length", 6); + filterConfig.capsMaxPercent = caps.getInt("max-percent", 70); + } + } + + // --- Mentions --- + Configuration mn = config.getSection("mentions"); + if (mn != null) { + mentionsEnabled = mn.getBoolean("enabled", true); + mentionsHighlightColor = mn.getString("highlight-color", "&e&l"); + mentionsSound = mn.getString("sound", "ENTITY_EXPERIENCE_ORB_PICKUP"); + mentionsAllowToggle = mn.getBoolean("allow-toggle", true); + mentionsNotifyPrefix = mn.getString("notify-prefix", "&e&l[Mention] &r"); + } else { + mentionsEnabled = true; + mentionsHighlightColor = "&e&l"; + mentionsSound = "ENTITY_EXPERIENCE_ORB_PICKUP"; + mentionsAllowToggle = true; + mentionsNotifyPrefix = "&e&l[Mention] &r"; + } + + // --- Chat-History --- + Configuration ch = config.getSection("chat-history"); + if (ch != null) { + historyMaxLines = ch.getInt("max-lines", 50); + historyDefaultLines = ch.getInt("default-lines", 10); + } else { + historyMaxLines = 50; + historyDefaultLines = 10; + } + + // --- Admin --- + Configuration adm = config.getSection("admin"); + if (adm != null) { + adminBypassPermission = adm.getString("bypass-permission", "chat.admin.bypass"); + adminNotifyPermission = adm.getString("notify-permission", "chat.admin.notify"); + } else { + adminBypassPermission = "chat.admin.bypass"; + adminNotifyPermission = "chat.admin.notify"; + } + + // --- Server-Farben --- + serverColors.clear(); + serverDisplayNames.clear(); + Configuration sc = config.getSection("server-colors"); + if (sc != null) { + serverColorDefault = sc.getString("default", "&7"); + for (String key : sc.getKeys()) { + if (key.equals("default")) continue; + // Neues Format: server hat Untersektion mit color + display + Configuration sub = sc.getSection(key); + if (sub != null) { + serverColors.put(key.toLowerCase(), sub.getString("color", "&7")); + String display = sub.getString("display", ""); + if (!display.isEmpty()) { + serverDisplayNames.put(key.toLowerCase(), display); + } + } else { + // Altes Format: server: "&a" (nur Farbe, kein display) + serverColors.put(key.toLowerCase(), sc.getString(key, "&7")); + } + } + } else { + serverColorDefault = "&7"; + } + + // --- Chatlog (NEU) --- + Configuration cl = config.getSection("chatlog"); + if (cl != null) { + chatlogEnabled = cl.getBoolean("enabled", true); + // Nur 7 oder 14 erlaubt; Standardwert 7 + int raw = cl.getInt("retention-days", 7); + chatlogRetentionDays = (raw == 14) ? 14 : 7; + } else { + chatlogEnabled = true; + chatlogRetentionDays = 7; + } + + // --- Reports (NEU) --- + Configuration rp = config.getSection("reports"); + if (rp != null) { + reportsEnabled = rp.getBoolean("enabled", true); + reportWebhookEnabled = rp.getBoolean("webhook-enabled", false); + reportConfirm = rp.getString("confirm-message", + "&aDein Report &8(§f{id}&8) &awurde eingereicht. Danke!"); + reportPermission = rp.getString("report-permission", ""); // leer = jeder + reportClosePermission= rp.getString("close-permission", "chat.admin.bypass"); + reportViewPermission = rp.getString("view-permission", "chat.admin.bypass"); + reportCooldown = rp.getInt("cooldown", 60); + reportDiscordWebhook = rp.getString("discord-webhook", ""); + reportTelegramChatId = rp.getString("telegram-chat-id", ""); + } else { + reportsEnabled = true; + reportWebhookEnabled = false; + reportConfirm = "&aDein Report &8({id}) &awurde eingereicht. Danke!"; + reportPermission = ""; + reportClosePermission = "chat.admin.bypass"; + reportViewPermission = "chat.admin.bypass"; + reportCooldown = 60; + reportDiscordWebhook = ""; + reportTelegramChatId = ""; + } + } + + // ===== Getter (bestehend) ===== + + public Map getChannels() { return Collections.unmodifiableMap(channels); } + public ChatChannel getChannel(String id) { return channels.get(id == null ? defaultChannel : id.toLowerCase()); } + public ChatChannel getDefaultChannel() { return channels.getOrDefault(defaultChannel, channels.values().iterator().next()); } + public String getDefaultChannelId() { return defaultChannel; } + + public String getHelpopFormat() { return helpopFormat; } + public String getHelpopPermission() { return helpopPermission; } + public int getHelpopCooldown() { return helpopCooldown; } + public String getHelpopConfirm() { return helpopConfirm; } + public String getHelpopDiscordWebhook() { return helpopDiscordWebhook; } + public String getHelpopTelegramChatId() { return helpopTelegramChatId; } + + public String getBroadcastFormat() { return broadcastFormat; } + public String getBroadcastPermission() { return broadcastPermission; } + + public boolean isPmEnabled() { return pmEnabled; } + public String getPmFormatSender() { return pmFormatSender; } + public String getPmFormatReceiver() { return pmFormatReceiver; } + public String getPmFormatSpy() { return pmFormatSpy; } + public String getPmSpyPermission() { return pmSpyPermission; } + + public int getDefaultMuteDuration() { return defaultMuteDuration; } + public String getMutedMessage() { return mutedMessage; } + + public boolean isEmojiEnabled() { return emojiEnabled; } + public boolean isEmojiBedrockSupport() { return emojiBedrockSupport; } + public Map getEmojiMappings() { return Collections.unmodifiableMap(emojiMappings); } + + public boolean isDiscordEnabled() { return discordEnabled; } + public String getDiscordBotToken() { return discordBotToken; } + public String getDiscordGuildId() { return discordGuildId; } + public int getDiscordPollInterval() { return discordPollInterval; } + public String getDiscordFromFormat() { return discordFromFormat; } + public String getDiscordAdminChannelId() { return discordAdminChannelId; } + public String getDiscordEmbedColor() { return discordEmbedColor; } + + public boolean isTelegramEnabled() { return telegramEnabled; } + public String getTelegramBotToken() { return telegramBotToken; } + public int getTelegramPollInterval() { return telegramPollInterval; } + public String getTelegramFromFormat() { return telegramFromFormat; } + public String getTelegramAdminChatId() { return telegramAdminChatId; } + public int getTelegramChatTopicId() { return telegramChatTopicId; } + public int getTelegramAdminTopicId() { return telegramAdminTopicId; } + + // ===== Getter (Account-Linking) ===== + public boolean isLinkingEnabled() { return linkingEnabled; } + public String getLinkDiscordMessage() { return linkDiscordMessage; } + public String getLinkTelegramMessage() { return linkTelegramMessage; } + public String getLinkSuccessDiscord() { return linkSuccessDiscord; } + public String getLinkSuccessTelegram() { return linkSuccessTelegram; } + public String getLinkBotSuccessDiscord() { return linkBotSuccessDiscord; } + public String getLinkBotSuccessTelegram() { return linkBotSuccessTelegram; } + public String getLinkedDiscordFormat() { return linkedDiscordFormat; } + public String getLinkedTelegramFormat() { return linkedTelegramFormat; } + + public String getAdminBypassPermission() { return adminBypassPermission; } + public String getAdminNotifyPermission() { return adminNotifyPermission; } + + // ===== Getter (NEU: Server-Farben) ===== + + /** + * Gibt den konfigurierten Farb-Code für einen Server zurück. + * Unterstützt &-Codes und &#RRGGBB HEX-Codes. + * Fallback: "default"-Eintrag, dann "&7". + */ + public String getServerColor(String serverName) { + if (serverName == null) return serverColorDefault; + String color = serverColors.get(serverName.toLowerCase()); + return color != null ? color : serverColorDefault; + } + + public Map getServerColors() { return Collections.unmodifiableMap(serverColors); } + public String getServerColorDefault() { return serverColorDefault; } + + /** + * Gibt den konfigurierten Anzeigenamen für einen Server zurück. + * Fallback: echter Servername (unverändert). + */ + public String getServerDisplay(String serverName) { + if (serverName == null) return ""; + String display = serverDisplayNames.get(serverName.toLowerCase()); + return display != null ? display : serverName; + } + + // ===== Getter (NEU: Chatlog) ===== + + public boolean isChatlogEnabled() { return chatlogEnabled; } + public int getChatlogRetentionDays() { return chatlogRetentionDays; } + + // ===== Getter (NEU: Reports) ===== + + public boolean isReportsEnabled() { return reportsEnabled; } + public String getReportConfirm() { return reportConfirm; } + public String getReportPermission() { return reportPermission; } + public String getReportClosePermission() { return reportClosePermission; } + public String getReportViewPermission() { return reportViewPermission; } + public int getReportCooldown() { return reportCooldown; } + public String getReportDiscordWebhook() { return reportDiscordWebhook; } + public String getReportTelegramChatId() { return reportTelegramChatId; } + public boolean isReportWebhookEnabled() { return reportWebhookEnabled; } + + // ===== Getter (Chat-Filter) ===== + public ChatFilter.ChatFilterConfig getFilterConfig() { return filterConfig; } + + // ===== Getter (Mentions) ===== + public boolean isMentionsEnabled() { return mentionsEnabled; } + public String getMentionsHighlightColor() { return mentionsHighlightColor; } + public String getMentionsSound() { return mentionsSound; } + public boolean isMentionsAllowToggle() { return mentionsAllowToggle; } + public String getMentionsNotifyPrefix() { return mentionsNotifyPrefix; } + + // ===== Getter (Chat-History) ===== + public int getHistoryMaxLines() { return historyMaxLines; } + public int getHistoryDefaultLines() { return historyDefaultLines; } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatFilter.java b/src/main/java/net/viper/status/modules/chat/ChatFilter.java new file mode 100644 index 0000000..c44063f --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatFilter.java @@ -0,0 +1,231 @@ +package net.viper.status.modules.chat; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Pattern; + +/** + * Chat-Filter: Anti-Spam, Caps-Filter, Wort-Blacklist, Farbcode-Filter. + * + * Reihenfolge der Prüfungen in processChat(): + * 1. Spam-Cooldown (zu schnell geschrieben?) + * 2. Gleiche Nachricht wiederholt? + * 3. Zu viele Großbuchstaben? + * 4. Verbotene Wörter → ersetzen durch **** + * 5. Farbcodes (& Codes) → nur mit Permission erlaubt + */ +public class ChatFilter { + + private final ChatFilterConfig cfg; + + // UUID → letzter Nachricht-Zeitstempel (ms) + private final Map lastMessageTime = new ConcurrentHashMap<>(); + // UUID → letzte Nachricht (für Duplikat-Check) + private final Map lastMessageText = new ConcurrentHashMap<>(); + // UUID → Spam-Zähler (aufeinanderfolgende schnelle Nachrichten) + private final Map spamCount = new ConcurrentHashMap<>(); + + // Kompilierte Regex-Pattern für Blacklist-Wörter + private final List blacklistPatterns = new ArrayList<>(); + + public ChatFilter(ChatFilterConfig cfg) { + this.cfg = cfg; + compilePatterns(); + } + + private void compilePatterns() { + blacklistPatterns.clear(); + for (String word : cfg.blacklistWords) { + // Case-insensitiv, ganzes Wort oder Teilwort je nach Config + blacklistPatterns.add(Pattern.compile( + "(?i)" + Pattern.quote(word))); + } + } + + // ===== Ergebnis-Klasse ===== + + public enum FilterResult { + ALLOWED, // Nachricht darf durch + BLOCKED, // Nachricht blockiert (Spam/Flood) + MODIFIED // Nachricht wurde verändert (Wörter ersetzt / Caps reduziert) + } + + public static class FilterResponse { + public final FilterResult result; + public final String message; // ggf. modifizierte Nachricht + public final String denyReason; // Nachricht an den Spieler wenn BLOCKED + + FilterResponse(FilterResult result, String message, String denyReason) { + this.result = result; + this.message = message; + this.denyReason = denyReason; + } + } + + // ===== Haupt-Filtermethode ===== + + /** + * Wendet alle aktiven Filter auf eine Nachricht an. + * + * @param uuid UUID des sendenden Spielers + * @param message Originalnachricht + * @param isAdmin true → Farbcodes und Caps-Filter überspringen + * @param hasColorPerm true → &-Farbcodes erlaubt + * @param hasFormatPerm true → &l, &o etc. erlaubt + * @return FilterResponse mit Ergebnis und ggf. modifizierter Nachricht + */ + public FilterResponse filter(UUID uuid, String message, boolean isAdmin, + boolean hasColorPerm, boolean hasFormatPerm) { + + // ── 1. Spam-Cooldown ── + if (cfg.antiSpamEnabled && !isAdmin) { + long now = System.currentTimeMillis(); + Long last = lastMessageTime.get(uuid); + if (last != null && (now - last) < cfg.spamCooldownMs) { + int count = spamCount.merge(uuid, 1, Integer::sum); + if (count >= cfg.spamMaxMessages) { + return new FilterResponse(FilterResult.BLOCKED, message, cfg.spamMessage); + } + } else { + spamCount.put(uuid, 0); + } + lastMessageTime.put(uuid, now); + } + + // ── 2. Duplikat-Check ── + if (cfg.duplicateCheckEnabled && !isAdmin) { + String lastText = lastMessageText.get(uuid); + if (message.equalsIgnoreCase(lastText)) { + return new FilterResponse(FilterResult.BLOCKED, message, cfg.duplicateMessage); + } + lastMessageText.put(uuid, message); + } + + String result = message; + boolean modified = false; + + // ── 3. Blacklist ── + if (cfg.blacklistEnabled) { + String filtered = applyBlacklist(result); + if (!filtered.equals(result)) { + result = filtered; + modified = true; + } + } + + // ── 4. Caps-Filter ── + if (cfg.capsFilterEnabled && !isAdmin) { + String capped = applyCapsFilter(result); + if (!capped.equals(result)) { + result = capped; + modified = true; + } + } + + // ── 5. Farbcodes filtern (nur wenn keine Permission) ── + if (!isAdmin) { + String colorFiltered = applyColorFilter(result, hasColorPerm, hasFormatPerm); + if (!colorFiltered.equals(result)) { + result = colorFiltered; + modified = true; + } + } + + return new FilterResponse( + modified ? FilterResult.MODIFIED : FilterResult.ALLOWED, + result, + null + ); + } + + // ===== Einzelne Filter ===== + + private String applyBlacklist(String message) { + String result = message; + for (Pattern p : blacklistPatterns) { + result = p.matcher(result).replaceAll(buildStars(p.pattern() + .replace("(?i)", "").replace("\\Q", "").replace("\\E", "").length())); + } + return result; + } + + private String applyCapsFilter(String message) { + // Zähle Großbuchstaben + int total = 0, upper = 0; + for (char c : message.toCharArray()) { + if (Character.isLetter(c)) { total++; if (Character.isUpperCase(c)) upper++; } + } + if (total < cfg.capsMinLength) return message; // Kurze Nachrichten ignorieren + double ratio = total > 0 ? (double) upper / total : 0; + if (ratio < cfg.capsMaxPercent / 100.0) return message; + // Zu viele Caps → alles lowercase + return message.toLowerCase(); + } + + /** + * Entfernt &-Farbcodes je nach Permission. + * hasColorPerm → &0-&9, &a-&f erlaubt + * hasFormatPerm → &l, &o, &n, &m, &k erlaubt + * Beide false → alle &-Codes entfernen + */ + private String applyColorFilter(String message, boolean hasColorPerm, boolean hasFormatPerm) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < message.length(); i++) { + char c = message.charAt(i); + if (c == '&' && i + 1 < message.length()) { + char next = Character.toLowerCase(message.charAt(i + 1)); + boolean isColor = (next >= '0' && next <= '9') || (next >= 'a' && next <= 'f'); + boolean isFormat = "lonmkr".indexOf(next) >= 0; + boolean isHex = next == '#'; + + if (isColor && hasColorPerm) { sb.append(c); continue; } + if (isFormat && hasFormatPerm) { sb.append(c); continue; } + if (isHex && hasColorPerm) { sb.append(c); continue; } + + // Kein Recht → & und nächstes Zeichen überspringen + if (isColor || isFormat) { i++; continue; } + // Hex: &# + 6 Zeichen überspringen + if (isHex && i + 7 < message.length()) { i += 7; continue; } + } + sb.append(c); + } + return sb.toString(); + } + + private static String buildStars(int length) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < Math.max(length, 4); i++) sb.append('*'); + return sb.toString(); + } + + // ===== Cleanup beim Logout ===== + + public void cleanup(UUID uuid) { + lastMessageTime.remove(uuid); + lastMessageText.remove(uuid); + spamCount.remove(uuid); + } + + // ===== Konfigurationsklasse ===== + + public static class ChatFilterConfig { + // Anti-Spam + public boolean antiSpamEnabled = true; + public long spamCooldownMs = 1500; // ms zwischen Nachrichten + public int spamMaxMessages = 3; // max. Nachrichten innerhalb Cooldown + public String spamMessage = "&cBitte nicht so schnell schreiben!"; + + // Duplikat + public boolean duplicateCheckEnabled = true; + public String duplicateMessage = "&cBitte keine identischen Nachrichten senden."; + + // Blacklist + public boolean blacklistEnabled = true; + public List blacklistWords = new ArrayList<>(); + + // Caps + public boolean capsFilterEnabled = true; + public int capsMinLength = 6; // Mindestlänge für Caps-Check + public int capsMaxPercent = 70; // Max. % Großbuchstaben + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatLogger.java b/src/main/java/net/viper/status/modules/chat/ChatLogger.java new file mode 100644 index 0000000..c5e2685 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatLogger.java @@ -0,0 +1,152 @@ +package net.viper.status.modules.chat; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +/** + * Protokolliert alle Chat-Nachrichten in tagesweise rotierende Logdateien. + * + * Verzeichnis: plugins/StatusAPI/chatlogs/chatlog_YYYY-MM-DD.log + * Format: [HH:mm:ss] [MSG-XXXXXX] [SERVER] [CHANNEL] Spieler: Nachricht + * + * Alte Logs werden beim Start und täglich automatisch bereinigt. + * Die Aufbewahrungsdauer ist in der chat.yml konfigurierbar (7 oder 14 Tage). + */ +public class ChatLogger { + + private final File logDir; + private final Logger logger; + private final int retentionDays; + private final AtomicInteger counter = new AtomicInteger(0); + + private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("yyyy-MM-dd"); + private static final SimpleDateFormat TIME_FMT = new SimpleDateFormat("HH:mm:ss"); + + public ChatLogger(File dataFolder, Logger logger, int retentionDays) { + this.logDir = new File(dataFolder, "chatlogs"); + this.logger = logger; + this.retentionDays = Math.max(1, retentionDays); + this.logDir.mkdirs(); + cleanup(); + } + + // ===== Nachrichten-ID ===== + + /** + * Generiert eine eindeutige Nachrichten-ID (z.B. MSG-A3F2B1). + * Kombiniert Zeitstempel + inkrementellen Zähler für Eindeutigkeit. + */ + public String generateMessageId() { + int seq = counter.incrementAndGet(); + long ts = System.currentTimeMillis(); + int hash = (int)(ts ^ (ts >>> 32)) ^ (seq * 0x9E3779B9); + return "MSG-" + String.format("%06X", hash & 0xFFFFFF); + } + + // ===== Logging ===== + + /** + * Loggt eine Nachricht und gibt die generierte Nachrichten-ID zurück. + * + * @param msgId Vorher generierte ID (aus generateMessageId()) + * @param server Servername des Absenders + * @param channel Kanal-ID + * @param player Spielername + * @param message Nachrichtentext (Rohtext, ohne Farbcodes) + */ + public void log(String msgId, String server, String channel, String player, String message) { + String date = DATE_FMT.format(new Date()); + String time = TIME_FMT.format(new Date()); + + // Minecraft-Farbcodes aus dem Log entfernen + String cleanMsg = stripColor(message); + + String line = "[" + time + "] [" + msgId + "] [" + server + "] [" + channel + "] " + + player + ": " + cleanMsg; + + File logFile = new File(logDir, "chatlog_" + date + ".log"); + try (BufferedWriter bw = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(logFile, true), "UTF-8"))) { + bw.write(line); + bw.newLine(); + } catch (IOException e) { + logger.warning("[ChatLogger] Fehler beim Schreiben: " + e.getMessage()); + } + } + + // ===== Cleanup ===== + + /** + * Löscht Log-Dateien, die älter als retentionDays Tage sind. + * Wird beim Start und kann manuell aufgerufen werden. + */ + public void cleanup() { + if (!logDir.exists()) return; + long cutoff = System.currentTimeMillis() - ((long) retentionDays * 24L * 60L * 60L * 1000L); + File[] files = logDir.listFiles((dir, name) -> + name.startsWith("chatlog_") && name.endsWith(".log")); + if (files == null) return; + for (File f : files) { + if (f.lastModified() < cutoff) { + if (f.delete()) { + logger.info("[ChatLogger] Altes Log gelöscht: " + f.getName()); + } + } + } + } + + // ===== Hilfsmethoden ===== + + /** Entfernt §-Farbcodes aus dem Text. */ + private static String stripColor(String input) { + if (input == null) return ""; + return input.replaceAll("(?i)§[0-9A-FK-OR]", "") + .replaceAll("(?i)&[0-9A-FK-OR]", ""); + } + + public int getRetentionDays() { return retentionDays; } + public File getLogDir() { return logDir; } + + /** + * Liest die letzten `maxLines` Zeilen aus dem heutigen Chatlog. + * Wenn ein Spielername angegeben ist, werden nur seine Zeilen zurückgegeben. + * + * @param playerFilter Spielername (case-insensitiv) oder null für alle + * @param maxLines Maximale Anzahl zurückgegebener Zeilen + * @return Liste der Logzeilen (älteste zuerst) + */ + public List readLastLines(String playerFilter, int maxLines) { + String date = DATE_FMT.format(new Date()); + File logFile = new File(logDir, "chatlog_" + date + ".log"); + if (!logFile.exists()) return Collections.emptyList(); + + List allLines = new ArrayList<>(); + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(logFile), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + if (line.trim().isEmpty()) continue; + // Spieler-Filter: Format ist [...] [...] [...] [...] Spieler: Nachricht + if (playerFilter != null) { + // Spielername steht nach dem 4. [...]-Block + int lastBracket = line.indexOf("] ", line.lastIndexOf("[")); + if (lastBracket >= 0) { + String rest = line.substring(lastBracket + 2); + String name = rest.contains(":") ? rest.substring(0, rest.indexOf(":")).trim() : ""; + if (!name.equalsIgnoreCase(playerFilter)) continue; + } + } + allLines.add(line); + } + } catch (IOException e) { + logger.warning("[ChatLogger] Fehler beim Lesen: " + e.getMessage()); + } + + // Letzte maxLines zurückgeben + if (allLines.size() <= maxLines) return allLines; + return allLines.subList(allLines.size() - maxLines, allLines.size()); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ChatModule.java b/src/main/java/net/viper/status/modules/chat/ChatModule.java new file mode 100644 index 0000000..c3af97d --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatModule.java @@ -0,0 +1,1364 @@ +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.*; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.ChatEvent; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.event.EventPriority; +import net.viper.status.module.Module; +import net.viper.status.modules.chat.bridge.DiscordBridge; +import net.viper.status.modules.chat.bridge.TelegramBridge; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * ChatModule für StatusAPI (BungeeCord) + * + * Features: + * ✅ Mehrere Kanäle mit Permissions + * ✅ Server-Erkennung im Chat-Format + * ✅ /helpop für Spieler + * ✅ Emoji-Unterstützung (:smile: → 😊) + * ✅ Admin-Mute / Spieler-eigener Chat-Mute + * ✅ CMI & Plugin-kompatibel (kein Eingriff in SubServer-Befehle) + * ✅ Privat-Nachrichten (/msg, /r) + * ✅ Spieler-Blocking (/ignore, /unignore) + * ✅ Discord & Telegram Integration + * ✅ Admin-Bypass (kann nicht gemutet/geblockt werden) + * ✅ /broadcast für Admins + * ✅ Secure-Chat kompatibel (1.19+ & Bedrock/Geyser) + * ✅ Chat-Log mit konfigurierbarer Aufbewahrung (7 / 14 Tage) + * ✅ Nachrichten-IDs (klickbar → Zwischenablage) + * ✅ Report-System (/report, /reports, /reportclose) + * ✅ Admin-Benachrichtigung bei Reports (sofort oder verzögert nach Login) + * ✅ Server-Farben pro Server (&-Codes und &#RRGGBB HEX) + */ +public class ChatModule implements Module, Listener { + + private Plugin plugin; + private Logger logger; + + private ChatConfig config; + private MuteManager muteManager; + private BlockManager blockManager; + private PrivateMsgManager pmManager; + private EmojiParser emojiParser; + private ChatFilter chatFilter; + private DiscordBridge discordBridge; + private TelegramBridge telegramBridge; + private ChatLogger chatLogger; + private ReportManager reportManager; + private AccountLinkManager linkManager; + + // UUID → aktiver Kanal-ID + private final Map playerChannels = new ConcurrentHashMap<>(); + + // UUIDs die ihren eigenen Chat-Empfang deaktiviert haben + private final Set selfChatMuted = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // UUIDs die Mentions für sich deaktiviert haben + private final Set mentionsDisabled = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // HelpOp Cooldown: UUID → letzter Zeitstempel (Sekunden) + private final Map helpopCooldowns = new ConcurrentHashMap<>(); + + // Report-Cooldown: UUID → letzter Report-Zeitstempel (Sekunden) + private final Map reportCooldowns = new ConcurrentHashMap<>(); // NEU + + // Letzte Chatnachricht pro Spieler (für Report-Kontext): name.toLowerCase() → message + private final Map lastChatMessages = new ConcurrentHashMap<>(); // NEU + + // Geyser-Präfix für Bedrock-Spieler (Standard: ".") + private static final String GEYSER_PREFIX = "."; + + @Override + public String getName() { return "ChatModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + + // Konfiguration laden + config = new ChatConfig(plugin); + config.load(); + + // Manager initialisieren + muteManager = new MuteManager(plugin.getDataFolder(), logger); + muteManager.load(); + + blockManager = new BlockManager(plugin.getDataFolder(), logger); + blockManager.load(); + + pmManager = new PrivateMsgManager(blockManager); + emojiParser = new EmojiParser(config.getEmojiMappings(), config.isEmojiEnabled()); + chatFilter = new ChatFilter(config.getFilterConfig()); + + // NEU: ChatLogger + if (config.isChatlogEnabled()) { + chatLogger = new ChatLogger(plugin.getDataFolder(), logger, config.getChatlogRetentionDays()); + logger.info("[ChatModule] Chat-Log aktiviert (" + config.getChatlogRetentionDays() + " Tage Aufbewahrung)."); + } + + // NEU: ReportManager + if (config.isReportsEnabled()) { + reportManager = new ReportManager(plugin.getDataFolder(), logger); + reportManager.load(); + } + + // AccountLinkManager + linkManager = new AccountLinkManager(plugin.getDataFolder(), logger); + linkManager.load(); + + // Externe Brücken + if (config.isDiscordEnabled() || config.isReportWebhookEnabled()) { + discordBridge = new DiscordBridge(plugin, config); + discordBridge.setLinkManager(linkManager); + if (config.isDiscordEnabled()) { + discordBridge.start(); + } + } + if (config.isTelegramEnabled()) { + telegramBridge = new TelegramBridge(plugin, config); + telegramBridge.setLinkManager(linkManager); + telegramBridge.start(); + } + + // Listener & Befehle registrieren + ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + registerCommands(); + + logger.info("[ChatModule] Aktiviert – " + config.getChannels().size() + " Kanäle geladen."); + } + + @Override + public void onDisable(Plugin plugin) { + if (discordBridge != null) discordBridge.stop(); + if (telegramBridge != null) telegramBridge.stop(); + if (muteManager != null) muteManager.save(); + if (blockManager != null) blockManager.save(); + if (reportManager != null) reportManager.save(); + if (linkManager != null) linkManager.save(); + playerChannels.clear(); + selfChatMuted.clear(); + helpopCooldowns.clear(); + reportCooldowns.clear(); + lastChatMessages.clear(); + logger.info("[ChatModule] Deaktiviert."); + } + + // ========================================================= + // CHAT-EVENTS (BungeeCord original, 1.20+) + // + // Das Bypass-Problem mit Paper: + // Wenn BungeeCord eine signierte Nachricht unverändert durchlässt + // (kein setCancelled), prüft Paper die Signatur → ungültig → Fehler. + // Wenn wir setCancelled(true) setzen und die Nachricht selbst senden, + // fehlt die Signatur → Paper lehnt sie ebenfalls ab. + // + // Lösung: In der paper-global.yml auf JEDEM Sub-Server: + // messages: + // reject-chat-unsigned: false + // Das erlaubt unsignierte Nachrichten vom Proxy durch. + // Alternativ: In spigot.yml → settings: bungeecord: true (bereits nötig) + // kombiniert mit BungeeCord IP-Forwarding deaktiviert Paper die Prüfung. + // ========================================================= + + @EventHandler(priority = EventPriority.HIGHEST) + public void onChat(ChatEvent e) { + if (e.isCancelled()) return; + if (e.isCommand()) return; + if (!(e.getSender() instanceof ProxiedPlayer)) return; + ProxiedPlayer player = (ProxiedPlayer) e.getSender(); + + // Bypass: Spieler wartet auf Plugin-Eingabe (CMI etc.) + // Event komplett unberührt lassen → Originalnachricht mit Signatur + // geht direkt zum Sub-Server. Funktioniert auf Spigot ohne Einschränkung. + // Auf Paper-Sub-Servern muss reject-chat-unsigned: false gesetzt sein — + // das ist eine Paper-Limitierung, nicht lösbar auf BungeeCord-Ebene. + if (awaitingInput.contains(player.getUniqueId())) { + awaitingInput.remove(player.getUniqueId()); + return; // Event NICHT cancellen → Nachricht geht mit Originalsignatur durch + } + + e.setCancelled(true); + processChat(player, e.getMessage()); + } + + /** + * Zentrale Chat-Verarbeitungslogik. + * Wird von beiden Event-Handlern aufgerufen. + */ + private void processChat(ProxiedPlayer player, String rawMessage) { + if (rawMessage == null || rawMessage.trim().isEmpty()) return; + UUID uuid = player.getUniqueId(); + + boolean isAdmin = player.hasPermission(config.getAdminBypassPermission()); + + if (!isAdmin && muteManager.isMuted(uuid)) { + String remaining = muteManager.getRemainingTime(uuid); + player.sendMessage(color(config.getMutedMessage().replace("{time}", remaining))); + return; + } + + String channelId = playerChannels.getOrDefault(uuid, config.getDefaultChannelId()); + ChatChannel channel = config.getChannel(channelId); + if (channel == null) channel = config.getDefaultChannel(); + + if (channel.hasPermission() && !player.hasPermission(channel.getPermission())) { + player.sendMessage(color("&cDu hast keine Berechtigung für den Kanal &f" + channel.getName() + "&c.")); + channelId = config.getDefaultChannelId(); + channel = config.getDefaultChannel(); + playerChannels.put(uuid, channelId); + } + + String message = emojiParser.parse(rawMessage); + + // ── Chat-Filter ── + boolean hasColorPerm = player.hasPermission("chat.color"); + boolean hasFormatPerm = player.hasPermission("chat.color.format"); + boolean filterBypass = isAdmin || player.hasPermission("chat.filter.bypass"); + ChatFilter.FilterResponse filterResp = chatFilter.filter( + uuid, message, filterBypass, hasColorPerm, hasFormatPerm); + + if (filterResp.result == ChatFilter.FilterResult.BLOCKED) { + player.sendMessage(color(filterResp.denyReason)); + return; + } + message = filterResp.message; // ggf. modifiziert (Caps, Blacklist) + + String serverName = player.getServer() != null + ? player.getServer().getInfo().getName() + : "Proxy"; + + String prefix = getLuckPermsPrefix(player); + String suffix = getLuckPermsSuffix(player); + + // ── Mentions erkennen (@Spielername) ── + final Set mentionedPlayers = new java.util.HashSet<>(); + final String messageWithMentions; + if (config.isMentionsEnabled()) { + String[] words = message.split(" "); + StringBuilder mentionBuilder = new StringBuilder(); + String highlightColor = config.getMentionsHighlightColor(); + for (int wi = 0; wi < words.length; wi++) { + String word = words[wi]; + if (word.startsWith("@") && word.length() > 1) { + String targetName = word.substring(1); + ProxiedPlayer mentioned = ProxyServer.getInstance().getPlayer(targetName); + if (mentioned != null && !mentioned.getUniqueId().equals(uuid)) { + mentionedPlayers.add(mentioned.getUniqueId()); + // Wort hervorheben + word = translateColors(highlightColor + word + "&r"); + } + } + mentionBuilder.append(word); + if (wi < words.length - 1) mentionBuilder.append(" "); + } + messageWithMentions = mentionBuilder.toString(); + } else { + messageWithMentions = message; + } + + final String finalMessage = messageWithMentions; + final String formatted = buildFormat(channel.getFormat(), serverName, prefix, + player.getName(), suffix, finalMessage); + + // Nachricht loggen und ID generieren + final String msgId; + if (chatLogger != null) { + msgId = chatLogger.generateMessageId(); + chatLogger.log(msgId, serverName, channel.getId(), player.getName(), finalMessage); + } else { + msgId = null; + } + + // Letzte Nachricht des Spielers speichern (für Report-Kontext) + lastChatMessages.put(player.getName().toLowerCase(), finalMessage); + + final ChatChannel finalChannel = channel; + final String finalFormatted = formatted; + final String finalSenderName = player.getName(); + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + for (ProxiedPlayer recipient : ProxyServer.getInstance().getPlayers()) { + boolean isSelf = recipient.getUniqueId().equals(uuid); + + if (!isSelf && selfChatMuted.contains(recipient.getUniqueId())) continue; + + boolean recipientIsAdmin = recipient.hasPermission(config.getAdminBypassPermission()); + if (!recipientIsAdmin && !blockManager.canReceive(recipient.getUniqueId(), uuid)) continue; + + if (finalChannel.isLocalOnly()) { + if (player.getServer() == null || recipient.getServer() == null) continue; + if (!player.getServer().getInfo().getName() + .equals(recipient.getServer().getInfo().getName())) continue; + } + + if (finalChannel.hasPermission() && !recipient.hasPermission(finalChannel.getPermission())) { + if (!recipient.getUniqueId().equals(uuid)) continue; + } + + // Mention-Benachrichtigung + boolean isMentioned = mentionedPlayers.contains(recipient.getUniqueId()) + && config.isMentionsEnabled() + && !mentionsDisabled.contains(recipient.getUniqueId()); + + if (isMentioned) { + // Prefix-Nachricht über der Chat-Zeile + recipient.sendMessage(color(config.getMentionsNotifyPrefix() + + "&7" + finalSenderName + " &7hat dich erwähnt!")); + // Sound via Plugin-Messaging an Sub-Server senden + sendMentionSound(recipient, config.getMentionsSound()); + } + + if (msgId != null) { + recipient.sendMessage(buildClickableMessage(msgId, finalFormatted, finalSenderName)); + } else { + recipient.sendMessage(color(finalFormatted)); + } + } + + bridgeToDiscord(finalChannel, player.getName(), finalMessage, serverName); + bridgeToTelegram(finalChannel, player.getName(), finalMessage, serverName); + }); + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + UUID uuid = e.getPlayer().getUniqueId(); + chatFilter.cleanup(uuid); + playerChannels.remove(uuid); + mentionsDisabled.remove(uuid); + awaitingInput.remove(uuid); + } + + // ========================================================= + // LOGIN-EVENT: Kanal setzen + Report-Benachrichtigung + // ========================================================= + + @EventHandler + public void onLogin(PostLoginEvent e) { + ProxiedPlayer player = e.getPlayer(); + playerChannels.put(player.getUniqueId(), config.getDefaultChannelId()); + + // NEU: Offene Reports nach 2 Sekunden anzeigen (damit Update-Meldungen nicht überlagert werden) + if (reportManager == null) return; + if (!player.hasPermission(config.getAdminNotifyPermission()) + && !player.hasPermission(config.getAdminBypassPermission())) return; + + int openCount = reportManager.getOpenCount(); + if (openCount == 0) return; + + plugin.getProxy().getScheduler().schedule(plugin, () -> { + if (!player.isConnected()) return; + int count = reportManager.getOpenCount(); + if (count == 0) return; + + player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + player.sendMessage(color("&c&l⚑ OFFENE REPORTS &8| &f" + count + " ausstehend")); + player.sendMessage(color("&7Tippe &f/reports &7für eine Übersicht.")); + player.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + }, 2, TimeUnit.SECONDS); + } + + // ========================================================= + // BEFEHLE REGISTRIEREN + // ========================================================= + + private void registerCommands() { + + // /channel | /ch + Command chCmd = new Command("channel", null, "ch", "kanal") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (args.length == 0) { + p.sendMessage(color("&8▸ &eVerfügbare Kanäle:")); + for (ChatChannel ch : config.getChannels().values()) { + boolean hasPerm = !ch.hasPermission() || p.hasPermission(ch.getPermission()); + String active = ch.getId().equals(playerChannels.getOrDefault(p.getUniqueId(), config.getDefaultChannelId())) ? " &a✔" : ""; + p.sendMessage(color( + " " + ch.getFormattedTag() + + " &f" + ch.getName() + + (hasPerm ? active : " &8(keine Berechtigung)"))); + } + return; + } + String target = args[0].toLowerCase(); + ChatChannel ch = config.getChannel(target); + if (ch == null) { p.sendMessage(color("&cKanal &f" + args[0] + " &cnicht gefunden.")); return; } + if (ch.hasPermission() && !p.hasPermission(ch.getPermission())) { + p.sendMessage(color("&cDu hast keine Berechtigung für diesen Kanal.")); return; + } + playerChannels.put(p.getUniqueId(), ch.getId()); + p.sendMessage(color("&aKanal gewechselt: " + ch.getFormattedTag() + " &a" + ch.getName())); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, chCmd); + + // /helpop + Command helpop = new Command("helpop") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (args.length == 0) { p.sendMessage(color("&cBenutzung: /helpop ")); return; } + + long now = System.currentTimeMillis() / 1000L; + Long last = helpopCooldowns.get(p.getUniqueId()); + if (last != null && (now - last) < config.getHelpopCooldown()) { + long wait = config.getHelpopCooldown() - (now - last); + p.sendMessage(color("&cBitte warte noch &f" + wait + "s &cvor dem nächsten HelpOp.")); + return; + } + helpopCooldowns.put(p.getUniqueId(), now); + + String msg = String.join(" ", args); + String server = p.getServer() != null ? p.getServer().getInfo().getName() : "Proxy"; + String formatted = buildSimpleFormat(config.getHelpopFormat(), + "player", p.getName(), "server", server, "message", msg); + + for (ProxiedPlayer op : ProxyServer.getInstance().getPlayers()) { + if (op.hasPermission(config.getHelpopPermission())) { + op.sendMessage(color(formatted)); + } + } + ProxyServer.getInstance().getConsole().sendMessage(color(formatted)); + p.sendMessage(color(config.getHelpopConfirm())); + + if (discordBridge != null && !config.getHelpopDiscordWebhook().isEmpty()) { + discordBridge.sendEmbedToDiscord(config.getHelpopDiscordWebhook(), + "🆘 HelpOp von " + p.getName() + " (" + server + ")", msg, "FFAA00"); + } + if (telegramBridge != null && !config.getHelpopTelegramChatId().isEmpty()) { + telegramBridge.sendFormattedToTelegram(config.getHelpopTelegramChatId(), + "🆘 HelpOp: " + p.getName() + " @" + server, msg); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, helpop); + + // /msg + Command msgCmd = new Command("msg", null, "tell", "w", "whisper") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!config.isPmEnabled()) { sender.sendMessage(color("&cPrivat-Nachrichten sind deaktiviert.")); return; } + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (args.length < 2) { sender.sendMessage(color("&cBenutzung: /msg ")); return; } + ProxiedPlayer from = (ProxiedPlayer) sender; + ProxiedPlayer to = ProxyServer.getInstance().getPlayer(args[0]); + if (to == null || !to.isConnected()) { + from.sendMessage(color("&cSpieler &f" + args[0] + " &cnicht gefunden.")); return; + } + String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + pmManager.send(from, to, message, config, config.getAdminBypassPermission()); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, msgCmd); + + // /r + Command replyCmd = new Command("r", null, "reply", "antwort") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!config.isPmEnabled()) { sender.sendMessage(color("&cPrivat-Nachrichten sind deaktiviert.")); return; } + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /r ")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + pmManager.reply(p, String.join(" ", args), config, config.getAdminBypassPermission()); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, replyCmd); + + // /ignore | /unignore + Command ignoreCmd = new Command("ignore", null, "block") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /ignore ")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { p.sendMessage(color("&cSpieler nicht gefunden.")); return; } + if (target.hasPermission(config.getAdminBypassPermission())) { + p.sendMessage(color("&cAdmins können nicht ignoriert werden.")); return; + } + if (blockManager.isBlocked(p.getUniqueId(), target.getUniqueId())) { + p.sendMessage(color("&cDu hast &f" + target.getName() + " &cbereits ignoriert.")); return; + } + blockManager.block(p.getUniqueId(), target.getUniqueId()); + p.sendMessage(color("&aDu ignorierst jetzt &f" + target.getName() + "&a.")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, ignoreCmd); + + Command unignoreCmd = new Command("unignore", null, "unblock") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /unignore ")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { + p.sendMessage(color("&cSpieler nicht online.")); + return; + } + if (!blockManager.isBlocked(p.getUniqueId(), target.getUniqueId())) { + p.sendMessage(color("&cDu hast diesen Spieler nicht ignoriert.")); return; + } + blockManager.unblock(p.getUniqueId(), target.getUniqueId()); + p.sendMessage(color("&aIgnore für &f" + args[0] + " &aaufgehoben.")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, unignoreCmd); + + // /chatmute | /chatunmute + Command muteCmd = new Command("chatmute", "chat.mute", "gmute") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /chatmute [Minuten]")); return; } + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { sender.sendMessage(color("&cSpieler &f" + args[0] + " &cist nicht online.")); return; } + if (target.hasPermission(config.getAdminBypassPermission())) { + sender.sendMessage(color("&cDieser Spieler kann nicht gemutet werden.")); return; + } + int duration = config.getDefaultMuteDuration(); + if (args.length >= 2) { + try { duration = Integer.parseInt(args[1]); } + catch (NumberFormatException ex) { sender.sendMessage(color("&cUngültige Dauer. Bitte Zahl eingeben.")); return; } + } + muteManager.mute(target.getUniqueId(), duration); + String durationStr = duration <= 0 ? "permanent" : duration + " Minuten"; + target.sendMessage(color("&cDu wurdest für " + durationStr + " stummgeschaltet.")); + sender.sendMessage(color("&a" + target.getName() + " wurde für " + durationStr + " gemutet.")); + notifyAdmins("&8[&cMute&8] &f" + (sender instanceof ProxiedPlayer ? sender.getName() : "Konsole") + + " &7hat &f" + target.getName() + " &7für &f" + durationStr + " &7gemutet."); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, muteCmd); + + Command unmuteCmd = new Command("chatunmute", "chat.mute", "gunmute") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /chatunmute ")); return; } + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { sender.sendMessage(color("&cSpieler nicht online.")); return; } + if (!muteManager.isMuted(target.getUniqueId())) { + sender.sendMessage(color("&cDieser Spieler ist nicht gemutet.")); return; + } + muteManager.unmute(target.getUniqueId()); + target.sendMessage(color("&aDeine Stummschaltung wurde aufgehoben.")); + sender.sendMessage(color("&a" + target.getName() + " wurde entmutet.")); + notifyAdmins("&8[&aUnmute&8] &f" + (sender instanceof ProxiedPlayer ? sender.getName() : "Konsole") + + " &7hat &f" + target.getName() + " &7entmutet."); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, unmuteCmd); + + // /chataus (Selbst-Mute) + Command selfMuteCmd = new Command("chataus", null, "togglechat", "chaton", "chatoff") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (selfChatMuted.contains(p.getUniqueId())) { + selfChatMuted.remove(p.getUniqueId()); + p.sendMessage(color("&aChat &l✔ eingeschaltet.")); + } else { + selfChatMuted.add(p.getUniqueId()); + p.sendMessage(color("&cChat &l✘ ausgeschaltet. &7Mit &f/chataus &7wieder einschalten.")); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, selfMuteCmd); + + // /broadcast + Command broadcastCmd = new Command("broadcast", config.getBroadcastPermission(), "bc", "alert") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /broadcast ")); return; } + String message = String.join(" ", args); + String formatted = config.getBroadcastFormat().replace("{message}", message); + ProxyServer.getInstance().broadcast(color(formatted)); + + if (discordBridge != null) { + if (!config.getDiscordAdminChannelId().isEmpty()) { + discordBridge.sendToChannel(config.getDiscordAdminChannelId(), + "📢 **Broadcast:** " + ChatColor.stripColor( + ChatColor.translateAlternateColorCodes('&', message))); + } + } + if (telegramBridge != null && !config.getTelegramAdminChatId().isEmpty()) { + telegramBridge.sendFormattedToTelegram(config.getTelegramAdminChatId(), + "📢 Broadcast", + ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', message))); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, broadcastCmd); + + // /emoji + Command emojiCmd = new Command("emoji", null, "emojis") { + @Override + public void execute(CommandSender sender, String[] args) { + sender.sendMessage(color(emojiParser.buildEmojiList())); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, emojiCmd); + + // /socialspy + Command spyCmd = new Command("socialspy", "chat.socialspy", "spy") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + boolean now = pmManager.toggleSpy(p.getUniqueId()); + p.sendMessage(color(now ? "&aSocial-Spy aktiviert." : "&cSocial-Spy deaktiviert.")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, spyCmd); + + // /chatreload + Command reloadCmd = new Command("chatreload", "chat.admin.bypass") { + @Override + public void execute(CommandSender sender, String[] args) { + config.load(); + emojiParser = new EmojiParser(config.getEmojiMappings(), config.isEmojiEnabled()); + chatFilter = new ChatFilter(config.getFilterConfig()); + sender.sendMessage(color("&aChat-Konfiguration neu geladen.")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reloadCmd); + + // /chatinfo – Admin-Info über einen Spieler + Command chatInfoCmd = new Command("chatinfo", "chat.admin.bypass") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /chatinfo ")); return; } + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { sender.sendMessage(color("&cSpieler &f" + args[0] + " &cnicht online.")); return; } + + UUID tUUID = target.getUniqueId(); + String tServer = target.getServer() != null ? target.getServer().getInfo().getName() : "Proxy"; + + // Kanal + String channelId = playerChannels.getOrDefault(tUUID, config.getDefaultChannelId()); + ChatChannel ch = config.getChannel(channelId); + String channelName = ch != null ? ch.getFormattedTag() + " &f" + ch.getName() : "&f" + channelId; + + // Mute-Status + String muteStatus = muteManager.isMuted(tUUID) + ? "&cJa &8(noch: &f" + muteManager.getRemainingTime(tUUID) + "&8)" + : "&aKein"; + + // Blockierungen + Set blocked = blockManager.getBlockedBy(tUUID); + + // Account-Links + AccountLinkManager.LinkedAccount link = linkManager.getByUUID(tUUID); + String discordInfo = (link != null && !link.discordUserId.isEmpty()) + ? "&a" + link.discordUsername + " &8(" + link.discordUserId + ")" : "&7Nicht verknüpft"; + String telegramInfo = (link != null && !link.telegramUserId.isEmpty()) + ? "&a" + link.telegramUsername + " &8(" + link.telegramUserId + ")" : "&7Nicht verknüpft"; + + // Ausgabe + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + sender.sendMessage(color("&eChatInfo: &f" + target.getName() + " &8@ &7" + tServer)); + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + sender.sendMessage(color("&7Kanal: " + channelName)); + sender.sendMessage(color("&7Mute: " + muteStatus)); + sender.sendMessage(color("&7Chat-aus: " + (selfChatMuted.contains(tUUID) ? "&cJa" : "&aKein"))); + sender.sendMessage(color("&7Mentions: " + (mentionsDisabled.contains(tUUID) ? "&cDeaktiviert" : "&aAktiv"))); + sender.sendMessage(color("&7Blockiert: &f" + blocked.size() + " Spieler")); + if (!blocked.isEmpty()) { + for (UUID bUUID : blocked) { + ProxiedPlayer bp = ProxyServer.getInstance().getPlayer(bUUID); + String bName = bp != null ? bp.getName() : bUUID.toString().substring(0, 8) + "..."; + sender.sendMessage(color(" &8- &7" + bName)); + } + } + sender.sendMessage(color("&7Discord: " + discordInfo)); + sender.sendMessage(color("&7Telegram: " + telegramInfo)); + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, chatInfoCmd); + + // /chathist [spieler] [anzahl] – Chat-History aus dem Logfile + Command chatHistCmd = new Command("chathist", "chat.admin.bypass") { + @Override + public void execute(CommandSender sender, String[] args) { + if (chatLogger == null) { + sender.sendMessage(color("&cChat-Log ist deaktiviert.")); return; + } + + String playerFilter = null; + int lines = config.getHistoryDefaultLines(); + + if (args.length >= 1) { + // Erstes Arg: Spielername oder Zahl? + try { + lines = Math.min(Integer.parseInt(args[0]), config.getHistoryMaxLines()); + } catch (NumberFormatException ex) { + playerFilter = args[0]; + } + } + if (args.length >= 2) { + try { + lines = Math.min(Integer.parseInt(args[1]), config.getHistoryMaxLines()); + } catch (NumberFormatException ignored) {} + } + + final String finalFilter = playerFilter; + final int finalLines = lines; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + List history = chatLogger.readLastLines(finalFilter, finalLines); + + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + sender.sendMessage(color("&eChatHistory &8| &f" + history.size() + " Zeilen" + + (finalFilter != null ? " &8| &7Spieler: &f" + finalFilter : ""))); + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + + if (history.isEmpty()) { + sender.sendMessage(color("&7Keine Einträge gefunden.")); + } else { + for (String line : history) { + sender.sendMessage(color("&8" + line)); + } + } + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + }); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, chatHistCmd); + + // /mentions – Mentions ein-/ausschalten + Command mentionsCmd = new Command("mentions", null, "mention") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (!config.isMentionsEnabled()) { sender.sendMessage(color("&cMentions sind deaktiviert.")); return; } + if (!config.isMentionsAllowToggle()) { sender.sendMessage(color("&cDas Deaktivieren von Mentions ist nicht erlaubt.")); return; } + + ProxiedPlayer p = (ProxiedPlayer) sender; + if (mentionsDisabled.contains(p.getUniqueId())) { + mentionsDisabled.remove(p.getUniqueId()); + p.sendMessage(color("&aMentions &l✔ &aaktiviert. Du wirst benachrichtigt wenn jemand @" + p.getName() + " schreibt.")); + } else { + mentionsDisabled.add(p.getUniqueId()); + p.sendMessage(color("&cMentions &l✘ &cdeaktiviert. Du wirst nicht mehr benachrichtigt.")); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, mentionsCmd); + + // /chatbypass – Chat-Verarbeitung für nächste Eingabe(n) überspringen + // Nützlich wenn ein Plugin (CMI, Shop, etc.) auf eine Chat-Eingabe wartet + Command bypassCmd = new Command("chatbypass", null, "cbp") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + + if (awaitingInput.contains(p.getUniqueId())) { + awaitingInput.remove(p.getUniqueId()); + p.sendMessage(color("&aChatModule &l✔ &aaktiv.")); + } else { + awaitingInput.add(p.getUniqueId()); + p.sendMessage(color("&eChatModule &l⏸ &eüberbrückt. &7Nächste Nachricht geht direkt an den Server.")); + p.sendMessage(color("&7Mit &f/chatbypass &7wieder einschalten.")); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, bypassCmd); + + // ───────────────────────────────────────────────────── + // /discordlink – Discord-Account verknüpfen + // ───────────────────────────────────────────────────── + Command discordLinkCmd = new Command("discordlink", null, "dlink") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (!config.isDiscordEnabled()) { sender.sendMessage(color("&cDiscord ist nicht aktiviert.")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + + String token = linkManager.generateToken(p.getUniqueId(), p.getName(), "discord"); + + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + p.sendMessage(color("&9&lDiscord-Verknüpfung")); + p.sendMessage(color("&7Schreibe dem Bot auf Discord:")); + p.sendMessage(color("&f!link &b" + token)); + p.sendMessage(color("&7Token gültig für &f10 Minuten&7.")); + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, discordLinkCmd); + + // ───────────────────────────────────────────────────── + // /telegramlink – Telegram-Account verknüpfen + // ───────────────────────────────────────────────────── + Command telegramLinkCmd = new Command("telegramlink", null, "tlink") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + if (!config.isTelegramEnabled()) { sender.sendMessage(color("&cTelegram ist nicht aktiviert.")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + + String token = linkManager.generateToken(p.getUniqueId(), p.getName(), "telegram"); + + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + p.sendMessage(color("&3&lTelegram-Verknüpfung")); + p.sendMessage(color("&7Schreibe dem Bot auf Telegram:")); + p.sendMessage(color("&f/link &b" + token)); + p.sendMessage(color("&7Token gültig für &f10 Minuten&7.")); + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, telegramLinkCmd); + + // ───────────────────────────────────────────────────── + // /unlink – Verknüpfung aufheben + // ───────────────────────────────────────────────────── + Command unlinkCmd = new Command("unlink") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + + if (args.length == 0) { + p.sendMessage(color("&cBenutzung: /unlink ")); + return; + } + + switch (args[0].toLowerCase()) { + case "discord": + if (linkManager.unlinkDiscord(p.getUniqueId())) + p.sendMessage(color("&aDiscord-Verknüpfung aufgehoben.")); + else + p.sendMessage(color("&cKein Discord-Account verknüpft.")); + break; + case "telegram": + if (linkManager.unlinkTelegram(p.getUniqueId())) + p.sendMessage(color("&aTelegram-Verknüpfung aufgehoben.")); + else + p.sendMessage(color("&cKein Telegram-Account verknüpft.")); + break; + case "all": + boolean d = linkManager.unlinkDiscord(p.getUniqueId()); + boolean t = linkManager.unlinkTelegram(p.getUniqueId()); + if (d || t) p.sendMessage(color("&aAlle Verknüpfungen aufgehoben.")); + else p.sendMessage(color("&cKeine Verknüpfungen vorhanden.")); + break; + default: + p.sendMessage(color("&cBenutzung: /unlink ")); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, unlinkCmd); + + // ───────────────────────────────────────────────────── + // NEU: /report + // ───────────────────────────────────────────────────── + Command reportCmd = new Command("report") { + @Override + public void execute(CommandSender sender, String[] args) { + if (reportManager == null) { sender.sendMessage(color("&cDas Report-System ist deaktiviert.")); return; } + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(color("&cNur Spieler!")); return; } + + ProxiedPlayer p = (ProxiedPlayer) sender; + + // Permission prüfen (optional) + String reqPerm = config.getReportPermission(); + if (reqPerm != null && !reqPerm.isEmpty() && !p.hasPermission(reqPerm)) { + p.sendMessage(color("&cDu hast keine Berechtigung für /report.")); return; + } + + if (args.length < 2) { + p.sendMessage(color("&cBenutzung: /report ")); + return; + } + + // Cooldown + long now = System.currentTimeMillis() / 1000L; + Long last = reportCooldowns.get(p.getUniqueId()); + if (last != null && (now - last) < config.getReportCooldown()) { + long wait = config.getReportCooldown() - (now - last); + p.sendMessage(color("&cBitte warte noch &f" + wait + "s &cvor dem nächsten Report.")); + return; + } + + String reportedName = args[0]; + String reason = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + String server = p.getServer() != null ? p.getServer().getInfo().getName() : "Proxy"; + + // Letzte Nachricht des Gemeldeten als Kontext + String msgContext = lastChatMessages.getOrDefault(reportedName.toLowerCase(), "(keine Chat-Nachricht bekannt)"); + + // Report erstellen + String reportId = reportManager.createReport( + p.getName(), p.getUniqueId(), reportedName, server, msgContext, reason); + + // Report auch ins Chatlog schreiben (ID sichtbar) + if (chatLogger != null) { + String logMsg = "[REPORT] Reporter: " + p.getName() + ", Gemeldet: " + reportedName + ", Grund: " + reason + + " | Letzte Nachricht: " + msgContext + " | Report-ID: " + reportId; + String msgId = reportId; // Damit die ID im Chatlog und im Report identisch ist + chatLogger.log(msgId, server, "report", p.getName(), logMsg); + } + + // ==== Discord/Telegram Benachrichtigung ==== + // Discord Webhook + String reportWebhook = config.getReportDiscordWebhook(); + logger.info("[Debug] DiscordWebhookEnabled=" + config.isReportWebhookEnabled() + + ", discordBridge=" + (discordBridge != null) + + ", reportWebhook=" + reportWebhook); + if (config.isReportWebhookEnabled() && discordBridge != null && reportWebhook != null && !reportWebhook.isEmpty()) { + String title = "Neuer Report eingegangen"; + String desc = "**Reporter:** " + p.getName() + + "\n**Gemeldet:** " + reportedName + + "\n**Server:** " + server + + "\n**Grund:** " + reason + + "\n**Letzte Nachricht:** " + msgContext + + "\n**Report-ID:** " + reportId; + discordBridge.sendEmbedToDiscord(reportWebhook, title, desc, config.getDiscordEmbedColor()); + } + + // Telegram Benachrichtigung + String reportTgChatId = config.getReportTelegramChatId(); + if (telegramBridge != null && reportTgChatId != null && !reportTgChatId.isEmpty()) { + String header = "Neuer Report eingegangen"; + String content = "Reporter: " + p.getName() + + "\nGemeldet: " + reportedName + + "\nServer: " + server + + "\nGrund: " + reason + + "\nLetzte Nachricht: " + msgContext + + "\nReport-ID: " + reportId; + telegramBridge.sendFormattedToTelegram(reportTgChatId, header, content); + } + + reportCooldowns.put(p.getUniqueId(), now); + + // Bestätigung an Reporter + String confirm = config.getReportConfirm().replace("{id}", reportId); + p.sendMessage(color(confirm)); + + // ── Online-Admins sofort benachrichtigen ── + notifyAdminsReport(reportId, p.getName(), reportedName, server, reason, msgContext); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportCmd); + + // ───────────────────────────────────────────────────── + // NEU: /reports [all] – Admin-Übersicht + // ───────────────────────────────────────────────────── + Command reportsCmd = new Command("reports", config.getReportViewPermission()) { + @Override + public void execute(CommandSender sender, String[] args) { + if (reportManager == null) { sender.sendMessage(color("&cDas Report-System ist deaktiviert.")); return; } + + boolean showAll = args.length > 0 && args[0].equalsIgnoreCase("all"); + List list = showAll + ? reportManager.getAllReports() + : reportManager.getOpenReports(); + + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + sender.sendMessage(color("&c&l⚑ REPORTS &8| &f" + list.size() + + (showAll ? " gesamt" : " offen") + + " &8| &7/reports all für alle")); + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + + if (list.isEmpty()) { + sender.sendMessage(color("&7Keine " + (showAll ? "" : "offenen ") + "Reports vorhanden.")); + return; + } + + for (ReportManager.ChatReport r : list) { + String statusColor = r.closed ? "&a✔" : "&c✘"; + + if (sender instanceof ProxiedPlayer) { + // Klickbare Zeile: ID-Click kopiert ID in Zwischenablage + ComponentBuilder line = new ComponentBuilder(""); + + // Status + line.append(ChatColor.translateAlternateColorCodes('&', statusColor + " ")) + .event((ClickEvent) null) + .event((HoverEvent) null); + + // Klickbare Report-ID + line.append(ChatColor.translateAlternateColorCodes('&', "&8[&f" + r.id + "&8]")) + .event(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, r.id)) + .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(ChatColor.GRAY + "Klicken zum Kopieren: " + r.id + + "\n" + ChatColor.YELLOW + "Reporter: " + ChatColor.WHITE + r.reporterName + + "\n" + ChatColor.YELLOW + "Gemeldet: " + ChatColor.RED + r.reportedName + + "\n" + ChatColor.YELLOW + "Server: " + ChatColor.GREEN + r.server + + "\n" + ChatColor.YELLOW + "Zeit: " + ChatColor.WHITE + r.getFormattedTime() + + "\n" + ChatColor.YELLOW + "Kontext: " + ChatColor.GRAY + r.messageContext + + "\n" + ChatColor.YELLOW + "Grund: " + ChatColor.RED + r.reason + + (r.closed ? "\n" + ChatColor.GREEN + "Geschlossen von: " + r.closedBy : "")).create())); + + // Rest der Zeile + line.append(ChatColor.translateAlternateColorCodes('&', + " &b" + r.reportedName + " &8← &7" + r.reporterName + + " &8@ &a" + r.server + + " &8| &e" + r.getFormattedTime())) + .event((ClickEvent) null) + .event((HoverEvent) null); + + ((ProxiedPlayer) sender).sendMessage(line.create()); + } else { + // Konsole: plain text + sender.sendMessage(color(statusColor + " &8[&f" + r.id + "&8] &b" + r.reportedName + + " &8← &7" + r.reporterName + " &8@ &a" + r.server + + " &8| &e" + r.getFormattedTime() + + " &8| &cGrund: &f" + r.reason)); + } + } + + sender.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + if (!showAll && sender instanceof ProxiedPlayer) { + sender.sendMessage(color("&7Tipp: &f/reportclose &7zum Schließen.")); + } + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportsCmd); + + // ───────────────────────────────────────────────────── + // NEU: /reportclose + // ───────────────────────────────────────────────────── + Command reportCloseCmd = new Command("reportclose", config.getReportClosePermission()) { + @Override + public void execute(CommandSender sender, String[] args) { + if (reportManager == null) { sender.sendMessage(color("&cDas Report-System ist deaktiviert.")); return; } + if (args.length == 0) { sender.sendMessage(color("&cBenutzung: /reportclose ")); return; } + + String id = args[0].toUpperCase(); + ReportManager.ChatReport report = reportManager.getReport(id); + + if (report == null) { + sender.sendMessage(color("&cReport &f" + id + " &cnicht gefunden.")); return; + } + if (report.closed) { + sender.sendMessage(color("&cReport &f" + id + " &cist bereits geschlossen" + + (report.closedBy != null && !report.closedBy.isEmpty() + ? " &8(von &7" + report.closedBy + "&8)" : "") + ".")); + return; + } + + String adminName = (sender instanceof ProxiedPlayer) ? sender.getName() : "Konsole"; + reportManager.closeReport(id, adminName); + + sender.sendMessage(color("&aReport &f" + id + " &awurde geschlossen.")); + + // Reporter benachrichtigen, falls online + ProxiedPlayer reporter = ProxyServer.getInstance().getPlayer(report.reporterUUID); + if (reporter != null && reporter.isConnected()) { + reporter.sendMessage(color("&8[&aReport&8] &7Dein Report &f" + id + + " &7gegen &c" + report.reportedName + " &7wurde von &b" + adminName + " &7bearbeitet.")); + } + + // Andere Admins informieren + notifyAdmins("&8[&aReport geschlossen&8] &f" + adminName + " &7hat Report &f" + id + " &7geschlossen."); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportCloseCmd); + } + + // ========================================================= + // PLUGIN-INPUT BYPASS + // + // Spieler die gerade auf eine Chat-Eingabe eines Sub-Server- + // Plugins warten (CMI, Shops, etc.) werden vom ChatModule + // übersprungen. Die Nachricht geht direkt an den Sub-Server. + // + // Drei Erkennungsmethoden: + // 1. Manueller Bypass-Toggle via /chatbypass (für Admins) + // 2. Programmatische API: ChatModule.setAwaitingInput(uuid, true) + // 3. Automatische Erkennung bekannter Plugin-Nachrichten + // ========================================================= + + // UUIDs die gerade auf Plugin-Chat-Eingabe warten + private final Set awaitingInput = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + /** + * Prüft ob ein Spieler gerade auf eine Chat-Eingabe eines + * Sub-Server-Plugins wartet und das ChatModule überspringen soll. + */ + private boolean isAwaitingPluginInput(ProxiedPlayer player) { + // 1. Manuell / programmatisch gesetzt + if (awaitingInput.contains(player.getUniqueId())) return true; + + // 2. Automatische Erkennung: BungeeCord leitet SubServer-Nachrichten + // via PluginChannel weiter – wir prüfen bekannte CMI-Patterns nicht, + // da wir keinen Zugriff auf SubServer-Metadaten haben. + // Stattdessen: Spieler kann selbst /chatbypass togglen oder + // Sub-Server-Plugin ruft setAwaitingInput() auf. + return false; + } + + /** + * Öffentliche API für Sub-Server-Plugins oder BungeeCord-eigene Plugins: + * Setzt den Bypass-Status für einen Spieler. + * + * Beispiel aus einem anderen BungeeCord-Plugin: + * ChatModule chatModule = (ChatModule) proxy.getPluginManager() + * .getPlugin("StatusAPI").getModule("ChatModule"); + * chatModule.setAwaitingInput(player.getUniqueId(), true); + */ + public void setAwaitingInput(UUID uuid, boolean awaiting) { + if (awaiting) awaitingInput.add(uuid); + else awaitingInput.remove(uuid); + } + + public boolean isAwaitingInput(UUID uuid) { + return awaitingInput.contains(uuid); + } + + // ========================================================= + // HILFSMETHODEN + // ========================================================= + + private String buildFormat(String format, String server, String prefix, + String player, String suffix, String message) { + String serverColor = config.getServerColor(server); + String serverDisplay = config.getServerDisplay(server); // Anzeigename aus config + String coloredServer = translateColors(serverColor + serverDisplay + "&r"); + + return format + .replace("{server}", coloredServer) + .replace("{prefix}", prefix != null ? prefix : "") + .replace("{player}", player) + .replace("{suffix}", suffix != null ? suffix : "") + .replace("{message}", message); + } + + private String buildSimpleFormat(String format, String... kvPairs) { + String result = format; + for (int i = 0; i + 1 < kvPairs.length; i += 2) { + result = result.replace("{" + kvPairs[i] + "}", kvPairs[i + 1]); + } + return result; + } + + private TextComponent color(String text) { + return new TextComponent(translateColors(text)); + } + + /** + * Übersetzt sowohl klassische &-Farbcodes als auch HEX-Codes im Format &#RRGGBB. + * + * Beispiele: + * &a → §a (Grün) + * &#FF5500 → BungeeCord HEX-Farbe Orange + * &l&#FF5500Text → Fett + Orange + * + * BungeeCord unterstützt ChatColor.of("#RRGGBB") ab 1.16-kompatiblen Builds. + * Ältere Builds erhalten automatisch den nächsten &-Code als Fallback. + */ + private String translateColors(String text) { + if (text == null) return ""; + + // 1. Schritt: &#RRGGBB → BungeeCord ChatColor + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + // Prüfe auf &#RRGGBB (8 Zeichen: & # R R G G B B) + if (i + 7 < text.length() + && text.charAt(i) == '&' + && text.charAt(i + 1) == '#') { + String hex = text.substring(i + 2, i + 8); + boolean validHex = hex.chars().allMatch(c -> + (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')); + if (validHex) { + try { + sb.append(ChatColor.of("#" + hex).toString()); + i += 8; + continue; + } catch (Exception ignored) { + // Ungültige Farbe → als normalen Text behandeln + } + } + } + sb.append(text.charAt(i)); + i++; + } + + // 2. Schritt: Standard &-Codes übersetzen + return ChatColor.translateAlternateColorCodes('&', sb.toString()); + } + + private void notifyAdmins(String message) { + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.hasPermission(config.getAdminNotifyPermission())) { + p.sendMessage(color(message)); + } + } + } + + /** + * Benachrichtigt alle online Admins über einen neuen Report. + * Baut eine mehrzeilige, klickbare Nachricht. + */ + private void notifyAdminsReport(String reportId, String reporter, String reported, + String server, String reason, String msgContext) { + // Zeitstempel + java.text.SimpleDateFormat sdf = new java.text.SimpleDateFormat("dd.MM.yyyy HH:mm:ss"); + String zeit = sdf.format(new java.util.Date()); + + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.hasPermission(config.getAdminNotifyPermission()) + && !p.hasPermission(config.getAdminBypassPermission())) continue; + + // Mehrzeilige Report-Notification + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + p.sendMessage(color("&8[&cReport&8] &7Zeit: &e" + zeit)); + p.sendMessage(color("&7Reporter: &b" + reporter)); + p.sendMessage(color("&7Server: &a" + server)); + p.sendMessage(color("&7Letzte Nachricht: &f" + msgContext)); + p.sendMessage(color("&7Grund: &c" + reason)); + + // Klickbare ID-Zeile + ComponentBuilder idLine = new ComponentBuilder(ChatColor.GRAY + "ID: "); + idLine.append(ChatColor.WHITE + "" + ChatColor.BOLD + reportId) + .event(new ClickEvent(ClickEvent.Action.COPY_TO_CLIPBOARD, reportId)) + .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(ChatColor.GRAY + "Klicken zum Kopieren · " + + ChatColor.YELLOW + "/reportclose " + reportId).create())) + .append(ChatColor.GRAY + " (klicken zum Kopieren)", ComponentBuilder.FormatRetention.NONE) + .event((ClickEvent) null) + .event((HoverEvent) null); + p.sendMessage(idLine.create()); + + p.sendMessage(color("&8▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + + // Konsole ebenfalls informieren + ProxyServer.getInstance().getConsole().sendMessage(color( + "&8[&cReport " + reportId + "&8] &7Reporter: &b" + reporter + + " &7→ &c" + reported + " &7@ &a" + server + " &8| &cGrund: &f" + reason)); + } + + /** + * Baut eine BungeeCord-Nachricht ohne sichtbare ID. + * Am Ende erscheint ein klickbarer [⚑] Melden-Button (nur wenn Reports aktiviert). + * + * Layout: §8[§c⚑§8] + */ + private BaseComponent[] buildClickableMessage(String msgId, String formatted, String senderName) { + ComponentBuilder builder = new ComponentBuilder(""); + + // Eigentliche Nachricht (kein ID-Tag mehr sichtbar) + builder.append(ChatColor.translateAlternateColorCodes('&', formatted), + ComponentBuilder.FormatRetention.NONE) + .event((ClickEvent) null) + .event((HoverEvent) null); + + // [⚑] Melden-Button am Ende (nur wenn Report-System aktiv und Sender bekannt) + if (msgId != null && senderName != null && reportManager != null) { + builder.append(" ", ComponentBuilder.FormatRetention.NONE) + .event((ClickEvent) null) + .event((HoverEvent) null); + builder.append(ChatColor.DARK_GRAY + "[" + ChatColor.RED + "⚑" + ChatColor.DARK_GRAY + "]", + ComponentBuilder.FormatRetention.NONE) + .event(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, + "/report " + senderName + " ")) + .event(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(ChatColor.GRAY + "Spieler melden\n" + + ChatColor.YELLOW + "/report " + senderName + " ").create())); + } + + return builder.create(); + } + + private boolean isBedrock(ProxiedPlayer player) { + return player.getName().startsWith(GEYSER_PREFIX); + } + + /** + * Sendet einen Sound an einen Spieler via Plugin-Messaging. + * Der Sub-Server muss den Kanal "BungeeCord" registriert haben (standard). + * Sound wird als Proxy-Message gesendet → Sub-Server-Plugin nötig für echten Sound. + * Als Fallback: Actionbar-Nachricht mit ♪-Symbol. + */ + private void sendMentionSound(ProxiedPlayer player, String soundName) { + if (soundName == null || soundName.isEmpty()) return; + try { + // Actionbar als visuellen Feedback (funktioniert ohne Sub-Server-Plugin) + net.md_5.bungee.api.chat.TextComponent actionBar = + new net.md_5.bungee.api.chat.TextComponent( + ChatColor.translateAlternateColorCodes('&', "&e♪ Mention!")); + player.sendMessage(net.md_5.bungee.api.ChatMessageType.ACTION_BAR, actionBar); + } catch (Exception ignored) {} + } + + private String getLuckPermsPrefix(ProxiedPlayer player) { + try { + net.luckperms.api.LuckPerms lp = net.luckperms.api.LuckPermsProvider.get(); + net.luckperms.api.model.user.User user = lp.getUserManager().getUser(player.getUniqueId()); + if (user == null) return ""; + String prefix = user.getCachedData().getMetaData().getPrefix(); + if (prefix == null) return ""; + return ChatColor.translateAlternateColorCodes('&', prefix); + } catch (Exception e) { + return ""; + } + } + + private String getLuckPermsSuffix(ProxiedPlayer player) { + try { + net.luckperms.api.LuckPerms lp = net.luckperms.api.LuckPermsProvider.get(); + net.luckperms.api.model.user.User user = lp.getUserManager().getUser(player.getUniqueId()); + if (user == null) return ""; + String suffix = user.getCachedData().getMetaData().getSuffix(); + if (suffix == null) return ""; + return ChatColor.translateAlternateColorCodes('&', suffix); + } catch (Exception e) { + return ""; + } + } + + // ========================================================= + // EXTERNE BRÜCKEN + // ========================================================= + + private void bridgeToDiscord(ChatChannel channel, String playerName, String message, String server) { + if (discordBridge == null) return; + String cleanMessage = "[" + server + "] " + playerName + ": " + + ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', message)); + if (!channel.getDiscordWebhook().isEmpty()) { + discordBridge.sendToDiscord(channel.getDiscordWebhook(), playerName, cleanMessage, null); + } + if (!channel.getDiscordChannelId().isEmpty()) { + discordBridge.sendToChannel(channel.getDiscordChannelId(), cleanMessage); + } + if (channel.isUseAdminBridge() && !config.getDiscordAdminChannelId().isEmpty()) { + discordBridge.sendToChannel(config.getDiscordAdminChannelId(), cleanMessage); + } + } + + private void bridgeToTelegram(ChatChannel channel, String playerName, String message, String server) { + if (telegramBridge == null) return; + String cleanMessage = "[" + server + "] " + playerName + ": " + + ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', message)); + if (!channel.getTelegramChatId().isEmpty()) { + telegramBridge.sendToTelegram(channel.getTelegramChatId(), + channel.getTelegramThreadId(), cleanMessage); + } + if (channel.isUseAdminBridge() && !config.getTelegramAdminChatId().isEmpty()) { + telegramBridge.sendToTelegram(config.getTelegramAdminChatId(), + config.getTelegramAdminTopicId(), cleanMessage); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/EmojiParser.java b/src/main/java/net/viper/status/modules/chat/EmojiParser.java new file mode 100644 index 0000000..b705f8e --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/EmojiParser.java @@ -0,0 +1,53 @@ +package net.viper.status.modules.chat; + +import java.util.Map; + +/** + * Ersetzt Emoji-Shortcuts (:smile:, :heart:, …) durch Unicode-Zeichen. + * + * Bedrock-Spieler (Geyser) unterstützen Unicode-Emojis ebenfalls, + * da sie als reguläre UTF-8 Zeichen in TextComponents übertragen werden. + */ +public class EmojiParser { + + private final Map mappings; + private final boolean enabled; + + public EmojiParser(Map mappings, boolean enabled) { + this.mappings = mappings; + this.enabled = enabled; + } + + /** + * Konvertiert alle bekannten Emoji-Shortcuts in der Nachricht zu Unicode. + * Nicht erkannte Shortcuts bleiben unverändert. + * + * @param message Die Originalnachricht des Spielers + * @return Nachricht mit ersetzten Emojis + */ + public String parse(String message) { + if (!enabled || message == null || message.isEmpty()) return message; + + String result = message; + for (Map.Entry entry : mappings.entrySet()) { + result = result.replace(entry.getKey(), entry.getValue()); + } + return result; + } + + /** + * Gibt eine lesbare Liste aller Emojis zurück (für /emoji list). + */ + public String buildEmojiList() { + if (mappings.isEmpty()) return "&cKeine Emojis konfiguriert."; + StringBuilder sb = new StringBuilder(); + sb.append("&eVerfügbare Emojis:\n"); + int i = 0; + for (Map.Entry entry : mappings.entrySet()) { + sb.append("&7").append(entry.getKey()).append(" &f→ ").append(entry.getValue()); + if (i < mappings.size() - 1) sb.append(" "); + i++; + } + return sb.toString(); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/MuteManager.java b/src/main/java/net/viper/status/modules/chat/MuteManager.java new file mode 100644 index 0000000..2360417 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/MuteManager.java @@ -0,0 +1,124 @@ +package net.viper.status.modules.chat; + +import java.io.*; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * Verwaltet Mutes von Spielern. + * Speichert: UUID → Ablaufzeitpunkt (Unix-Sekunden, 0 = permanent) + * + * Admins/OPs mit dem Bypass-Permission können nicht gemutet werden. + */ +public class MuteManager { + + private final File file; + private final Logger logger; + + // UUID → Ablaufzeitpunkt (0 = permanent) + private final ConcurrentHashMap mutes = new ConcurrentHashMap<>(); + + public MuteManager(File dataFolder, Logger logger) { + this.file = new File(dataFolder, "chat_mutes.dat"); + this.logger = logger; + } + + // ===== Mute-Logik ===== + + /** + * Mutet einen Spieler für durationMinutes Minuten. + * durationMinutes = 0 → permanent + */ + public void mute(UUID uuid, int durationMinutes) { + long expiry = (durationMinutes <= 0) + ? 0L + : (System.currentTimeMillis() / 1000L) + ((long) durationMinutes * 60); + mutes.put(uuid, expiry); + save(); + } + + /** Hebt den Mute auf. */ + public void unmute(UUID uuid) { + mutes.remove(uuid); + save(); + } + + /** Prüft ob ein Spieler aktuell gemutet ist. */ + public boolean isMuted(UUID uuid) { + Long expiry = mutes.get(uuid); + if (expiry == null) return false; + if (expiry == 0L) return true; // permanent + if (System.currentTimeMillis() / 1000L >= expiry) { + // Abgelaufen → entfernen + mutes.remove(uuid); + save(); + return false; + } + return true; + } + + /** + * Gibt die verbleibende Zeit als lesbaren String zurück. + * Gibt "permanent" zurück bei dauerhaftem Mute. + */ + public String getRemainingTime(UUID uuid) { + Long expiry = mutes.get(uuid); + if (expiry == null) return "0"; + if (expiry == 0L) return "permanent"; + + long remaining = expiry - (System.currentTimeMillis() / 1000L); + if (remaining <= 0) return "0"; + + long hours = remaining / 3600; + long minutes = (remaining % 3600) / 60; + long seconds = remaining % 60; + + if (hours > 0) return hours + "h " + minutes + "m"; + if (minutes > 0) return minutes + "m " + seconds + "s"; + return seconds + "s"; + } + + // ===== Persistenz ===== + + public void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + long now = System.currentTimeMillis() / 1000L; + for (Map.Entry e : mutes.entrySet()) { + // Nur aktive Mutes speichern + if (e.getValue() == 0L || e.getValue() > now) { + bw.write(e.getKey() + "|" + e.getValue()); + bw.newLine(); + } + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Speichern der Mutes: " + e.getMessage()); + } + } + + public void load() { + mutes.clear(); + if (!file.exists()) return; + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line; + long now = System.currentTimeMillis() / 1000L; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] parts = line.split("\\|"); + if (parts.length < 2) continue; + try { + UUID uuid = UUID.fromString(parts[0]); + long expiry = Long.parseLong(parts[1]); + // Nur laden wenn noch aktiv + if (expiry == 0L || expiry > now) { + mutes.put(uuid, expiry); + } + } catch (Exception ignored) {} + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Laden der Mutes: " + e.getMessage()); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java b/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java new file mode 100644 index 0000000..c40ff2f --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java @@ -0,0 +1,143 @@ +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.ChatColor; +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.connection.ProxiedPlayer; + +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Verwaltet private Nachrichten (/msg, /r) und Social-Spy. + */ +public class PrivateMsgManager { + + private final BlockManager blockManager; + + // UUID → letzte PM-Gesprächspartner UUID (für /r) + private final Map lastPartner = new ConcurrentHashMap<>(); + + // UUIDs die Social-Spy aktiviert haben + private final java.util.Set spyEnabled = + java.util.Collections.newSetFromMap(new ConcurrentHashMap<>()); + + public PrivateMsgManager(BlockManager blockManager) { + this.blockManager = blockManager; + } + + /** + * Sendet eine private Nachricht von `sender` an `receiver`. + * + * @param sender Der sendende Spieler + * @param receiver Der empfangende Spieler + * @param message Die Nachricht + * @param config Chat-Konfiguration (Formate) + * @param bypassPermission Permission für Admin-Bypass (kann nicht geblockt werden) + * @return true wenn erfolgreich gesendet + */ + public boolean send(ProxiedPlayer sender, ProxiedPlayer receiver, + String message, ChatConfig config, String bypassPermission) { + + // Selbst anschreiben verhindern + if (sender.getUniqueId().equals(receiver.getUniqueId())) { + sender.sendMessage(color("&cDu kannst dir nicht selbst schreiben.")); + return false; + } + + // Admin-Bypass: Wenn Sender Admin ist, kann er nicht geblockt werden + boolean senderIsAdmin = sender.hasPermission(bypassPermission); + + // Block-Check (nur wenn Sender kein Admin) + if (!senderIsAdmin) { + if (!blockManager.canReceive(sender.getUniqueId(), receiver.getUniqueId())) { + sender.sendMessage(color("&cDieser Spieler hat dich blockiert oder du hast ihn blockiert.")); + return false; + } + } + + // Formatierung + String toSender = format(config.getPmFormatSender(), sender.getName(), receiver.getName(), message, true); + String toReceiver = format(config.getPmFormatReceiver(), sender.getName(), receiver.getName(), message, false); + + sender.sendMessage(color(toSender)); + receiver.sendMessage(color(toReceiver)); + + // Letzte Partner speichern (für /r) + lastPartner.put(sender.getUniqueId(), receiver.getUniqueId()); + lastPartner.put(receiver.getUniqueId(), sender.getUniqueId()); + + // Social Spy + String spyMsg = format(config.getPmFormatSpy(), sender.getName(), receiver.getName(), message, true); + broadcastSpy(spyMsg, config.getPmSpyPermission(), sender.getUniqueId(), receiver.getUniqueId()); + + return true; + } + + /** + * Antwort-Funktion (/r). + * Sucht den letzten Gesprächspartner des Senders. + */ + public void reply(ProxiedPlayer sender, String message, ChatConfig config, String bypassPermission) { + UUID partnerUuid = lastPartner.get(sender.getUniqueId()); + if (partnerUuid == null) { + sender.sendMessage(color("&cDu hast noch keine Nachricht erhalten.")); + return; + } + ProxiedPlayer partner = ProxyServer.getInstance().getPlayer(partnerUuid); + if (partner == null || !partner.isConnected()) { + sender.sendMessage(color("&cDieser Spieler ist nicht mehr online.")); + return; + } + send(sender, partner, message, config, bypassPermission); + } + + /** Social-Spy umschalten. */ + public boolean toggleSpy(UUID uuid) { + if (spyEnabled.contains(uuid)) { + spyEnabled.remove(uuid); + return false; + } else { + spyEnabled.add(uuid); + return true; + } + } + + private void broadcastSpy(String formatted, String spyPermission, UUID... exclude) { + java.util.Set excl = new java.util.HashSet<>(java.util.Arrays.asList(exclude)); + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (excl.contains(p.getUniqueId())) continue; + // Spy muss entweder via Permission aktiv oder manuell aktiviert haben + boolean hasPerm = p.hasPermission(spyPermission); + boolean hasToggle= spyEnabled.contains(p.getUniqueId()); + if (hasPerm || hasToggle) { + p.sendMessage(color(formatted)); + } + } + } + + /** + * Formatiert eine PM-Nachricht. + * {sender} → Name des Absenders + * {receiver} → Name des Empfängers + * {player} → Gesprächspartner aus Sicht des jeweiligen Empfängers: + * Beim Sender: der Empfänger (an wen schreibt er?) + * Beim Empfänger: der Sender (von wem kommt es?) + * + * @param viewerIsSender true wenn der aktuelle Betrachter der Absender ist + */ + private String format(String template, String sender, String receiver, + String message, boolean viewerIsSender) { + String partner = viewerIsSender ? receiver : sender; + return template + .replace("{sender}", sender) + .replace("{receiver}", receiver) + .replace("{player}", partner) // Gesprächspartner aus Sicht des Betrachters + .replace("{message}", message); + } + + private TextComponent color(String text) { + return new TextComponent(ChatColor.translateAlternateColorCodes('&', text)); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/ReportManager.java b/src/main/java/net/viper/status/modules/chat/ReportManager.java new file mode 100644 index 0000000..311b529 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ReportManager.java @@ -0,0 +1,227 @@ +package net.viper.status.modules.chat; + +import java.io.*; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Logger; + +/** + * Verwaltet Spieler-Reports (/report). + * + * Reports werden mit einer eindeutigen ID (z.B. RPT-0001) gespeichert und + * bleiben offen, bis ein Admin sie explizit mit /reportclose schließt. + * + * Online-Admins werden sofort benachrichtigt. + * Offline-Admins erhalten eine verzögerte Benachrichtigung beim nächsten Login + * (gesteuert von außen via getPendingNotificationFor()). + * + * Speicherformat (chat_reports.dat): + * id|reporter|reporterUUID|reported|server|messageContext|reason|timestamp|closed|closedBy + */ +public class ReportManager { + + private final File file; + private final Logger logger; + + /** Alle Reports (offen und geschlossen). */ + private final ConcurrentHashMap reports = new ConcurrentHashMap<>(); + + /** Zähler für Report-IDs. Wird beim Laden synchronisiert. */ + private final AtomicInteger idCounter = new AtomicInteger(0); + + private static final SimpleDateFormat DATE_FMT = new SimpleDateFormat("dd.MM.yyyy HH:mm:ss"); + + // ===== Report-Datenklasse ===== + + public static class ChatReport { + public String id; + public String reporterName; + public UUID reporterUUID; + public String reportedName; + public String server; + public String messageContext; // letzte bekannte Chatnachricht des Gemeldeten + public String reason; + public long timestamp; + public boolean closed; + public String closedBy; // Name des schließenden Admins (oder leer) + + public String getFormattedTime() { + return DATE_FMT.format(new Date(timestamp)); + } + } + + // ===== Konstruktor ===== + + public ReportManager(File dataFolder, Logger logger) { + this.file = new File(dataFolder, "chat_reports.dat"); + this.logger = logger; + } + + // ===== Report-Logik ===== + + /** + * Erstellt einen neuen Report. + * + * @param reporterName Name des meldenden Spielers + * @param reporterUUID UUID des meldenden Spielers + * @param reportedName Name des gemeldeten Spielers + * @param server Server, auf dem sich der Reporter befand + * @param messageContext Letzte bekannte Nachricht des Gemeldeten (für Kontext) + * @param reason Freitext-Begründung + * @return die neue Report-ID (z.B. RPT-0001) + */ + public String createReport(String reporterName, UUID reporterUUID, + String reportedName, String server, + String messageContext, String reason) { + String id = String.format("RPT-%04d", idCounter.incrementAndGet()); + + ChatReport report = new ChatReport(); + report.id = id; + report.reporterName = reporterName; + report.reporterUUID = reporterUUID; + report.reportedName = reportedName; + report.server = server; + report.messageContext = messageContext != null ? messageContext : ""; + report.reason = reason; + report.timestamp = System.currentTimeMillis(); + report.closed = false; + report.closedBy = ""; + + reports.put(id, report); + save(); + return id; + } + + /** + * Schließt einen Report. + * + * @param id Report-ID (z.B. RPT-0001, case-insensitiv) + * @param adminName Name des Admins, der den Report schließt + * @return true wenn erfolgreich geschlossen, false wenn nicht gefunden / bereits geschlossen + */ + public boolean closeReport(String id, String adminName) { + ChatReport report = getReport(id); + if (report == null || report.closed) return false; + report.closed = true; + report.closedBy = adminName; + save(); + return true; + } + + /** Gibt einen Report nach ID zurück (case-insensitiv). */ + public ChatReport getReport(String id) { + if (id == null) return null; + return reports.get(id.toUpperCase()); + } + + /** Gibt alle offenen Reports chronologisch (älteste zuerst) zurück. */ + public List getOpenReports() { + List list = new ArrayList<>(); + for (ChatReport r : reports.values()) { + if (!r.closed) list.add(r); + } + list.sort(Comparator.comparingLong(r -> r.timestamp)); + return list; + } + + /** Gibt alle Reports chronologisch zurück (auch geschlossene). */ + public List getAllReports() { + List list = new ArrayList<>(reports.values()); + list.sort(Comparator.comparingLong(r -> r.timestamp)); + return list; + } + + /** Anzahl offener Reports. */ + public int getOpenCount() { + int count = 0; + for (ChatReport r : reports.values()) if (!r.closed) count++; + return count; + } + + // ===== Persistenz ===== + + public void save() { + try (BufferedWriter bw = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + for (ChatReport r : reports.values()) { + bw.write( + esc(r.id) + "|" + + esc(r.reporterName) + "|" + + r.reporterUUID + "|" + + esc(r.reportedName) + "|" + + esc(r.server) + "|" + + esc(r.messageContext) + "|" + + esc(r.reason) + "|" + + r.timestamp + "|" + + r.closed + "|" + + esc(r.closedBy != null ? r.closedBy : "") + ); + bw.newLine(); + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Speichern der Reports: " + e.getMessage()); + } + } + + public void load() { + reports.clear(); + if (!file.exists()) return; + + int maxNum = 0; + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + String[] p = line.split("\\|", -1); + if (p.length < 10) continue; + try { + ChatReport r = new ChatReport(); + r.id = unesc(p[0]); + r.reporterName = unesc(p[1]); + r.reporterUUID = UUID.fromString(p[2]); + r.reportedName = unesc(p[3]); + r.server = unesc(p[4]); + r.messageContext = unesc(p[5]); + r.reason = unesc(p[6]); + r.timestamp = Long.parseLong(p[7]); + r.closed = Boolean.parseBoolean(p[8]); + r.closedBy = unesc(p[9]); + reports.put(r.id.toUpperCase(), r); + + // Zähler auf höchste bekannte Nummer synchronisieren + if (r.id.toUpperCase().startsWith("RPT-")) { + try { + int num = Integer.parseInt(r.id.substring(4)); + if (num > maxNum) maxNum = num; + } catch (NumberFormatException ignored) {} + } + } catch (Exception ignored) {} + } + } catch (IOException e) { + logger.warning("[ChatModule] Fehler beim Laden der Reports: " + e.getMessage()); + } + idCounter.set(maxNum); + logger.info("[ChatModule] " + reports.size() + " Reports geladen (" + getOpenCount() + " offen)."); + } + + // ===== Escape-Helfer (Pipe-Zeichen und Zeilenumbrüche escapen) ===== + + private static String esc(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("|", "\\p") + .replace("\n", "\\n") + .replace("\r", ""); + } + + private static String unesc(String s) { + if (s == null) return ""; + return s.replace("\\n", "\n") + .replace("\\p", "|") + .replace("\\\\", "\\"); + } +} \ No newline at end of file