diff --git a/src/main/java/net/viper/status/StatusAPI.java b/src/main/java/net/viper/status/StatusAPI.java new file mode 100644 index 0000000..97bf03a --- /dev/null +++ b/src/main/java/net/viper/status/StatusAPI.java @@ -0,0 +1,1451 @@ +package net.viper.status; + +import net.md_5.bungee.api.ProxyServer; +import net.md_5.bungee.api.config.ListenerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.module.ModuleManager; +import net.viper.status.modules.economy.EconomyModule; +import net.viper.status.modules.tablist.TablistModule; +import net.viper.status.modules.scoreboard.ScoreboardModule; +import net.viper.status.modules.antibot.AntiBotModule; +import net.viper.status.modules.network.NetworkInfoModule; +import net.viper.status.modules.network.MultiAccountGuard; +import net.viper.status.modules.AutoMessage.AutoMessageModule; +import net.viper.status.modules.customcommands.CustomCommandModule; +import net.viper.status.modules.serverswitcher.ServerSwitcherModule; +import net.viper.status.stats.PlayerStats; +import net.viper.status.stats.StatsModule; +import net.viper.status.modules.verify.VerifyModule; +import net.viper.status.modules.commandblocker.CommandBlockerModule; +import net.viper.status.modules.broadcast.BroadcastModule; +import net.viper.status.modules.chat.ChatModule; +import net.viper.status.modules.vanish.VanishModule; +import net.viper.status.modules.help.HelpModule; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.StringReader; +import java.net.InetSocketAddress; +import java.net.ServerSocket; +import java.net.Socket; +import java.net.SocketTimeoutException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.RejectedExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import net.md_5.bungee.api.scheduler.ScheduledTask; + +/** + * StatusAPI - zentraler BungeeCord HTTP-Status- und Broadcast-Endpunkt + */ +public class StatusAPI extends Plugin implements Runnable { + + // Welt pro Spieler (UUID -> Weltname), wird von StatusAPIBridge gepusht + public static final ConcurrentHashMap playerWorlds = new ConcurrentHashMap<>(); + + // Kontostand pro Spieler (UUID -> Balance), wird von StatusAPIBridge gepusht + public static final ConcurrentHashMap playerBalances = new ConcurrentHashMap<>(); + + // PlaceholderAPI-Werte pro Spieler (UUID -> (placeholder -> aufgelöster Wert)) + public static final ConcurrentHashMap> playerPapi = new ConcurrentHashMap<>(); + + /** Alle %token%-Tokens aus den Config-Dateien – als JSON-Array für GET /papi/tokens */ + public static volatile String papiTokensJson = "[]"; + + // Debug-Modus (aus verify.properties) + public static boolean DEBUG = false; + + /** Gibt eine Info-Meldung nur im Debug-Modus aus */ + public static void debugLog(Plugin plugin, String message) { + if (DEBUG) plugin.getLogger().info(message); + } + + private volatile Thread thread; + private volatile ServerSocket serverSocket; + private volatile boolean shuttingDown = false; + private int port = 9191; + private ScheduledTask httpWatchdogTask; + private ExecutorService requestExecutor; + private final AtomicLong lastHttpRequestAt = new AtomicLong(0L); + + private ModuleManager moduleManager; + private UpdateChecker updateChecker; + private Properties verifyProperties; + + @Override + public void onEnable() { + + if (!getDataFolder().exists()) { + getDataFolder().mkdirs(); + } + + mergeVerifyConfig(); + + // Port aus verify.properties lesen + String portStr = verifyProperties != null ? verifyProperties.getProperty("statusapi.port", "9191") : "9191"; + try { + port = Integer.parseInt(portStr); + } catch (NumberFormatException e) { + getLogger().warning("Ungültiger Port in verify.properties, nutze Standard-Port 9191."); + port = 9191; + } + + // Debug-Modus + DEBUG = verifyProperties != null && Boolean.parseBoolean(verifyProperties.getProperty("debug", "false")); + + moduleManager = new ModuleManager(); + + // Module in korrekter Reihenfolge registrieren + // VanishModule MUSS vor ChatModule registriert werden (VanishProvider-Abhängigkeit) + moduleManager.registerModule(new StatsModule()); + moduleManager.registerModule(new HelpModule()); + moduleManager.registerModule(new VerifyModule()); + moduleManager.registerModule(new BroadcastModule()); + moduleManager.registerModule(new CommandBlockerModule()); + moduleManager.registerModule(new VanishModule()); + moduleManager.registerModule(new ChatModule()); + moduleManager.registerModule(new AntiBotModule()); + moduleManager.registerModule(new NetworkInfoModule()); + moduleManager.registerModule(new MultiAccountGuard()); + moduleManager.registerModule(new AutoMessageModule()); + moduleManager.registerModule(new CustomCommandModule()); + moduleManager.registerModule(new ServerSwitcherModule()); + moduleManager.registerModule(new EconomyModule()); + moduleManager.registerModule(new TablistModule()); + moduleManager.registerModule(new ScoreboardModule()); + + try { + Class forumBridge = Class.forName("net.viper.status.modules.forum.ForumBridgeModule"); + Object forumBridgeInstance = forumBridge.getDeclaredConstructor().newInstance(); + moduleManager.registerModule((net.viper.status.module.Module) forumBridgeInstance); + } catch (Exception e) { + getLogger().warning("ForumBridgeModule konnte nicht geladen werden: " + e.getMessage()); + } + + moduleManager.enableAll(this); + + // PAPI-Tokens sofort scannen + nochmal nach 5s als Fallback (falls Configs erst beim Enable erstellt) + scanAndPublishPapiTokens(); + ProxyServer.getInstance().getScheduler().schedule(this, this::scanAndPublishPapiTokens, 5, TimeUnit.SECONDS); + + // /statusapi reload Befehl registrieren + ProxyServer.getInstance().getPluginManager().registerCommand(this, new StatusAPICommand(this)); + + // FIX: ScoreboardModule mit NetworkInfoModule verbinden (TPS-Fallback) + try { + net.viper.status.modules.scoreboard.ScoreboardModule sbMod = + (net.viper.status.modules.scoreboard.ScoreboardModule) moduleManager.getModule("ScoreboardModule"); + net.viper.status.modules.network.NetworkInfoModule nimMod = + (net.viper.status.modules.network.NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (sbMod != null && nimMod != null) { + sbMod.setNetworkInfoModule(nimMod); + getLogger().info("[StatusAPI] ScoreboardModule → NetworkInfoModule TPS-Fallback verbunden."); + } + } catch (Exception e) { + getLogger().warning("[StatusAPI] TPS-Fallback konnte nicht verbunden werden: " + e.getMessage()); + } + + // WebServer starten + shuttingDown = false; + requestExecutor = Executors.newFixedThreadPool(4, r -> { + Thread t = new Thread(r, "StatusAPI-HTTP-Worker"); + t.setDaemon(true); + return t; + }); + startHttpServerThread(); + httpWatchdogTask = ProxyServer.getInstance().getScheduler().schedule( + this, this::ensureHttpServerAlive, 15, 15, TimeUnit.SECONDS); + + // Update-Checker + String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; + updateChecker = new UpdateChecker(this, currentVersion, 6); + checkAndMaybeUpdate(); + ProxyServer.getInstance().getScheduler().schedule(this, this::checkAndMaybeUpdate, 6, 6, TimeUnit.HOURS); + } + + @Override + public void onDisable() { + shuttingDown = true; + if (moduleManager != null) { + moduleManager.disableAll(this); + } + if (httpWatchdogTask != null) { + httpWatchdogTask.cancel(); + httpWatchdogTask = null; + } + stopHttpServerThread(); + if (requestExecutor != null) { + requestExecutor.shutdownNow(); + requestExecutor = null; + } + } + + private synchronized void startHttpServerThread() { + if (thread != null && thread.isAlive()) { + return; + } + thread = new Thread(this, "StatusAPI-HTTP-Server"); + thread.setDaemon(true); + thread.start(); + } + + private synchronized void stopHttpServerThread() { + Thread localThread = thread; + if (localThread != null) { + localThread.interrupt(); + } + ServerSocket localServerSocket = serverSocket; + if (localServerSocket != null) { + try { + localServerSocket.close(); + } catch (IOException ignored) { + } + } + if (localThread != null) { + try { + localThread.join(1500); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + thread = null; + } + + private void ensureHttpServerAlive() { + if (shuttingDown) return; + Thread t = thread; + if (t == null || !t.isAlive()) { + getLogger().warning("HTTP-Server-Thread war gestoppt und wird neu gestartet."); + startHttpServerThread(); + } + } + + // --- MERGE LOGIK --- + private void mergeVerifyConfig() { + try { + File file = new File(getDataFolder(), "verify.properties"); + verifyProperties = new Properties(); + if (file.exists()) { + try (java.io.InputStreamReader reader = new java.io.InputStreamReader( + new FileInputStream(file), StandardCharsets.UTF_8)) { + verifyProperties.load(reader); + } + } else { + getLogger().warning("verify.properties nicht gefunden."); + } + } catch (IOException e) { + getLogger().severe("Fehler beim Laden der verify.properties: " + e.getMessage()); + } + } + + public Properties getVerifyProperties() { + synchronized (this) { + return verifyProperties; + } + } + + public ModuleManager getModuleManager() { + return moduleManager; + } + + // --- Update-Logik --- + private void checkAndMaybeUpdate() { + try { + updateChecker.checkNow(); + String currentVersion = getDescription() != null ? getDescription().getVersion() : "0.0.0"; + if (updateChecker.isUpdateAvailable(currentVersion)) { + String newVersion = updateChecker.getLatestVersion(); + getLogger().warning("----------------------------------------"); + getLogger().warning("Neue Version verfügbar: " + newVersion); + getLogger().warning("Download: " + updateChecker.getLatestUrl()); + getLogger().warning("----------------------------------------"); + } + } catch (Exception e) { + getLogger().severe("Fehler beim Update-Check: " + e.getMessage()); + } + } + + // --- WebServer --- + @Override + public void run() { + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + // FIX #1: reuseAddress muss VOR dem bind() gesetzt werden. + // new ServerSocket(port) bindet sofort → stattdessen unboundenen Socket anlegen. + ServerSocket localServerSocket = null; + try { + localServerSocket = new ServerSocket(); + localServerSocket.setReuseAddress(true); + localServerSocket.setSoTimeout(1000); + localServerSocket.bind(new InetSocketAddress(port)); + this.serverSocket = localServerSocket; + + while (!shuttingDown && !Thread.currentThread().isInterrupted()) { + try { + Socket clientSocket = localServerSocket.accept(); + submitConnection(clientSocket); + } catch (SocketTimeoutException ignored) { + // Poll-Schleife für Interrupt/Shutdown + } catch (IOException e) { + if (!shuttingDown) { + getLogger().warning("Fehler beim Akzeptieren einer Verbindung: " + e.getMessage()); + } + } catch (Throwable t) { + if (!shuttingDown) { + getLogger().severe("Unbehandelter Fehler im HTTP-Accept-Loop: " + t.getMessage()); + } + } + } + } catch (IOException e) { + if (!shuttingDown) { + getLogger().severe("Konnte ServerSocket nicht starten: " + e.getMessage()); + try { + Thread.sleep(2000L); + } catch (InterruptedException ignored) { + Thread.currentThread().interrupt(); + } + } + } finally { + if (localServerSocket != null) { + try { localServerSocket.close(); } catch (IOException ignored) {} + } + serverSocket = null; + } + } + } + + private void submitConnection(Socket clientSocket) { + if (clientSocket == null) return; + try { + clientSocket.setSoTimeout(5000); + clientSocket.setTcpNoDelay(true); + } catch (Exception ignored) {} + + ExecutorService executor = requestExecutor; + if (executor == null || executor.isShutdown()) { + try { clientSocket.close(); } catch (IOException ignored) {} + return; + } + + try { + executor.execute(() -> { + try { + handleConnection(clientSocket); + } finally { + try { clientSocket.close(); } catch (IOException ignored) {} + } + }); + } catch (RejectedExecutionException ex) { + try { clientSocket.close(); } catch (IOException ignored) {} + } + } + + private void handleConnection(Socket clientSocket) { + try (BufferedReader in = new BufferedReader( + new InputStreamReader(clientSocket.getInputStream(), StandardCharsets.UTF_8)); + OutputStream out = clientSocket.getOutputStream()) { + + String inputLine = in.readLine(); + if (inputLine == null) return; + lastHttpRequestAt.set(System.currentTimeMillis()); + + String[] reqParts = inputLine.split(" "); + if (reqParts.length < 2) return; + String method = reqParts[0].trim(); + String path = reqParts[1].trim(); + String pathOnly = path; + int queryIndex = path.indexOf('?'); + if (queryIndex >= 0) pathOnly = path.substring(0, queryIndex); + + // GET /health + if ("GET".equalsIgnoreCase(method) && "/health".equalsIgnoreCase(path)) { + long lastMs = lastHttpRequestAt.get(); + long age = lastMs <= 0L ? -1L : (System.currentTimeMillis() - lastMs); + sendHttpResponse(out, "{\"success\":true,\"online\":true,\"last_request_age_ms\":" + age + "}", 200); + return; + } + + // GET /antibot/security-log + if ("GET".equalsIgnoreCase(method) && "/antibot/security-log".equalsIgnoreCase(pathOnly)) { + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + payload.put("events", loadAntiBotSecurityEvents(250)); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // Headers lesen + Map headers = new HashMap<>(); + String line; + while ((line = in.readLine()) != null && !line.isEmpty()) { + int idx = line.indexOf(':'); + if (idx > 0) { + headers.put(line.substring(0, idx).trim().toLowerCase(Locale.ROOT), + line.substring(idx + 1).trim()); + } + } + + // GET /network/backendguard/config + if ("GET".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/backendguard/config")) { + Properties guardProps = loadNetworkGuardProperties(); + String requiredApiKey = guardProps.getProperty("backendguard.sync.api_key", "").trim(); + String providedApiKey = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + if (!requiredApiKey.isEmpty() && !requiredApiKey.equals(providedApiKey == null ? "" : providedApiKey.trim())) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + Map guard = new LinkedHashMap<>(); + guard.put("enforcement_enabled", Boolean.parseBoolean(guardProps.getProperty("backendguard.enforcement_enabled", "true"))); + guard.put("log_blocked_attempts", Boolean.parseBoolean(guardProps.getProperty("backendguard.log_blocked_attempts", "true"))); + guard.put("kick_message", guardProps.getProperty("backendguard.kick_message", "&cBitte verbinde dich nur ueber den Proxy.")); + guard.put("allowed_proxy_ips", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_ips", "127.0.0.1,::1"))); + guard.put("allowed_proxy_cidrs", parseCommaListProperty(guardProps.getProperty("backendguard.allowed_proxy_cidrs", ""))); + payload.put("backend_guard", guard); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // POST /forum/notify + if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/forum/notify")) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + Object mod = moduleManager.getModule("ForumBridgeModule"); + if (mod != null) { + try { + java.lang.reflect.Method m = mod.getClass().getMethod("handleNotify", String.class, String.class); + String resp = (String) m.invoke(mod, body, apiKeyHeader); + sendHttpResponse(out, resp, 200); + } catch (Exception e) { + e.printStackTrace(); + sendHttpResponse(out, "{\"success\":false,\"error\":\"internal\"}", 500); + } + } else { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_forum_module\"}", 500); + } + return; + } + + // POST /network/attack + if ("POST".equalsIgnoreCase(method) && path.equalsIgnoreCase("/network/attack")) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + NetworkInfoModule mod = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (mod == null || !mod.isEnabled() || !mod.isAttackNotificationsEnabled()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"network_module_disabled\"}", 403); + return; + } + if (!mod.isAttackApiKeyValid(apiKeyHeader)) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_api_key\"}", 403); + return; + } + String eventType = extractJsonString(body, "event"); + if (eventType == null || eventType.trim().isEmpty()) eventType = "detected"; + String source = extractJsonString(body, "source"); + + Integer cps = null, blockedIps = null; + Long blockedConnections = null; + String cpsStr = extractJsonString(body, "connectionsPerSecond"); + if (cpsStr == null || cpsStr.isEmpty()) cpsStr = extractJsonString(body, "cps"); + try { if (cpsStr != null && !cpsStr.isEmpty()) cps = Integer.valueOf(cpsStr.trim()); } catch (Exception ignored) {} + String blockedIpsStr = extractJsonString(body, "ipAddressesBlocked"); + if (blockedIpsStr == null || blockedIpsStr.isEmpty()) blockedIpsStr = extractJsonString(body, "blockedIps"); + try { if (blockedIpsStr != null && !blockedIpsStr.isEmpty()) blockedIps = Integer.valueOf(blockedIpsStr.trim()); } catch (Exception ignored) {} + String blockedConnectionsStr = extractJsonString(body, "connectionsBlocked"); + if (blockedConnectionsStr == null || blockedConnectionsStr.isEmpty()) blockedConnectionsStr = extractJsonString(body, "blockedConnections"); + try { if (blockedConnectionsStr != null && !blockedConnectionsStr.isEmpty()) blockedConnections = Long.valueOf(blockedConnectionsStr.trim()); } catch (Exception ignored) {} + + boolean sent = mod.sendAttackNotification(eventType, cps, blockedIps, blockedConnections, source); + sendHttpResponse(out, sent ? "{\"success\":true}" : "{\"success\":false,\"error\":\"webhook_disabled_or_missing\"}", sent ? 200 : 400); + return; + } + + // POST /broadcast/cancel + if ("POST".equalsIgnoreCase(method) && (path.equalsIgnoreCase("/broadcast/cancel") || path.equalsIgnoreCase("/cancel"))) { + String body = readBody(in, headers); + String clientScheduleId = extractJsonString(body, "clientScheduleId"); + if (clientScheduleId == null || clientScheduleId.isEmpty()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"missing_clientScheduleId\"}", 400); + return; + } + Object mod = moduleManager.getModule("BroadcastModule"); + if (mod instanceof BroadcastModule) { + boolean ok = ((BroadcastModule) mod).cancelScheduled(clientScheduleId); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"not_found\"}", ok ? 200 : 404); + } else { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); + } + return; + } + + // POST /broadcast + if ("POST".equalsIgnoreCase(method) && ("/broadcast".equalsIgnoreCase(path) || "/".equals(path) || path.isEmpty())) { + String body = readBody(in, headers); + String apiKeyHeader = headers.getOrDefault("x-api-key", headers.getOrDefault("x-apikey", "")); + String message = extractJsonString(body, "message"); + String type = extractJsonString(body, "type"); + String prefix = extractJsonString(body, "prefix"); + String prefixColor = extractJsonString(body, "prefixColor"); + String bracketColor = extractJsonString(body, "bracketColor"); + String messageColor = extractJsonString(body, "messageColor"); + String sourceName = extractJsonString(body, "source"); + String scheduleTimeStr= extractJsonString(body, "scheduleTime"); + String recur = extractJsonString(body, "recur"); + String clientScheduleId = extractJsonString(body, "clientScheduleId"); + + if (sourceName == null || sourceName.isEmpty()) sourceName = "PulseCast"; + if (type == null || type.isEmpty()) type = "global"; + + Object mod = moduleManager.getModule("BroadcastModule"); + if (!(mod instanceof BroadcastModule)) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"no_broadcast_module\"}", 500); + return; + } + BroadcastModule bm = (BroadcastModule) mod; + + if (scheduleTimeStr != null && !scheduleTimeStr.trim().isEmpty()) { + long scheduleMillis; + try { + scheduleMillis = Long.parseLong(scheduleTimeStr.trim()); + if (scheduleMillis < 1_000_000_000_000L) scheduleMillis *= 1000L; + } catch (NumberFormatException ignored) { + try { + long v = (long) Double.parseDouble(scheduleTimeStr.trim()); + scheduleMillis = v < 1_000_000_000_000L ? v * 1000L : v; + } catch (Exception ex) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"bad_scheduleTime\"}", 400); + return; + } + } + boolean ok = bm.scheduleBroadcast(scheduleMillis, sourceName, message, type, apiKeyHeader, + prefix, prefixColor, bracketColor, messageColor, + recur == null ? "none" : recur, clientScheduleId); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"rejected\"}", ok ? 200 : 403); + return; + } + + if (message == null || message.isEmpty()) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"missing_message\"}", 400); + return; + } + boolean ok = bm.handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); + sendHttpResponse(out, ok ? "{\"success\":true}" : "{\"success\":false,\"error\":\"rejected\"}", ok ? 200 : 403); + return; + } + + // GET /stats/player?uuid=... oder ?name=... + if ("GET".equalsIgnoreCase(method) && "/stats/player".equalsIgnoreCase(pathOnly)) { + Map qp = parseQueryParams(path); + StatsModule statsMod = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats ps = resolvePlayer(qp.get("uuid"), qp.get("name"), statsMod); + if (ps == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + Map playerMap = new LinkedHashMap<>(); + playerMap.put("uuid", ps.uuid.toString()); + playerMap.put("name", ps.name); + playerMap.put("first_seen", ps.firstSeen); + playerMap.put("last_seen", ps.lastSeen); + playerMap.put("playtime", ps.getPlaytimeWithCurrentSession()); + playerMap.put("joins", ps.joins); + playerMap.put("kills", ps.kills); + playerMap.put("deaths", ps.deaths); + playerMap.put("online", ProxyServer.getInstance().getPlayer(ps.uuid) != null); + // Balance direkt aus MySQL (serverübergreifend) + double playerBalance = playerBalances.getOrDefault(ps.uuid, 0.0); + Map economy = new LinkedHashMap<>(); + economy.put("balance", playerBalance); + economy.put("total_earned", ps.totalEarned); + economy.put("total_spent", ps.totalSpent); + economy.put("transactions_count", ps.transactionsCount); + playerMap.put("economy", economy); + Map punishments = new LinkedHashMap<>(); + punishments.put("bans", ps.bansCount); + punishments.put("mutes", ps.mutesCount); + punishments.put("warns", ps.warnsCount); + punishments.put("last_punishment_at", ps.lastPunishmentAt); + punishments.put("last_punishment_type", ps.lastPunishmentType != null ? ps.lastPunishmentType : ""); + punishments.put("punishment_score", ps.punishmentScore); + playerMap.put("punishments", punishments); + payload.put("player", playerMap); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // GET /economy/player?uuid=... oder ?name=... + // Kein Cache – UUID und Balance kommen direkt aus der DB + if ("GET".equalsIgnoreCase(method) && "/economy/player".equalsIgnoreCase(pathOnly)) { + Map qp = parseQueryParams(path); + // UUID auflösen aus Query-Param + UUID ecoUuid = null; + String ecoName = null; + String uuidParam = qp.get("uuid"); + String nameParam = qp.get("name"); + if (uuidParam != null && !uuidParam.isEmpty()) { + try { ecoUuid = UUID.fromString(uuidParam.trim()); } catch (IllegalArgumentException ignored) {} + } + if (ecoUuid == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + if (ecoName == null) ecoName = uuidParam; + // Balance aus playerBalances Map lesen (befüllt von StatusAPIBridge via NexEco) + double directBalance = playerBalances.getOrDefault(ecoUuid, 0.0); + Map payload = new LinkedHashMap<>(); + payload.put("success", true); + payload.put("uuid", ecoUuid.toString()); + payload.put("name", ecoName); + Map economy = new LinkedHashMap<>(); + economy.put("balance", directBalance); + payload.put("economy", economy); + sendHttpResponse(out, buildJsonString(payload), 200); + return; + } + + // POST /economy/update + // Empfängt Balance-Updates von StatusAPIBridge (Vault/NexEco → HTTP) + // Schreibt NUR in playerBalances für Tablist/Scoreboard – KEINE DB-Schreiboperationen + if ("POST".equalsIgnoreCase(method) && "/economy/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + // UUID auflösen + UUID ecoUpdUuid = null; + String uuidBody = extractJsonString(body, "uuid"); + if (uuidBody != null && !uuidBody.isEmpty()) { + try { ecoUpdUuid = UUID.fromString(uuidBody.trim()); } catch (IllegalArgumentException ignored) {} + } + if (ecoUpdUuid == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + // Balance NUR in playerBalances Map speichern (für Tablist/Scoreboard) + // Die echte DB-Verwaltung macht ausschließlich NexEco + String balStr = extractJsonString(body, "balance"); + if (balStr != null && !balStr.isEmpty()) { + try { + double newBal = Double.parseDouble(balStr); + playerBalances.put(ecoUpdUuid, newBal); + } catch (NumberFormatException ignored) {} + } + // Stats-Felder (total_earned etc.) im Cache aktualisieren, falls vorhanden + StatsModule statsModEco = (StatsModule) moduleManager.getModule("StatsModule"); + if (statsModEco != null) { + PlayerStats psEco = statsModEco.getManager().getIfPresent(ecoUpdUuid); + if (psEco != null) { + String earnStr = extractJsonString(body, "total_earned"); + if (earnStr == null) earnStr = extractJsonString(body, "totalEarned"); + String spentStr = extractJsonString(body, "total_spent"); + if (spentStr == null) spentStr = extractJsonString(body, "totalSpent"); + String txStr = extractJsonString(body, "transactions_count"); + if (txStr == null) txStr = extractJsonString(body, "transactionsCount"); + synchronized (psEco) { + try { if (balStr != null && !balStr.isEmpty()) psEco.balance = Double.parseDouble(balStr); } catch (Exception ignored) {} + try { if (earnStr != null && !earnStr.isEmpty()) psEco.totalEarned = Double.parseDouble(earnStr); } catch (Exception ignored) {} + try { if (spentStr != null && !spentStr.isEmpty()) psEco.totalSpent = Double.parseDouble(spentStr); } catch (Exception ignored) {} + try { if (txStr != null && !txStr.isEmpty()) psEco.transactionsCount = Integer.parseInt(txStr); } catch (Exception ignored) {} + } + } + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /punishment/update + if ("POST".equalsIgnoreCase(method) && "/punishment/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + StatsModule statsModPun = (StatsModule) moduleManager.getModule("StatsModule"); + PlayerStats psPun = resolvePlayer(extractJsonString(body, "uuid"), extractJsonString(body, "name"), statsModPun); + if (psPun == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + String bansStr = extractJsonString(body, "bans"); + String mutesStr = extractJsonString(body, "mutes"); + String warnsStr = extractJsonString(body, "warns"); + String lastAtStr = extractJsonString(body, "last_punishment_at"); + if (lastAtStr == null) lastAtStr = extractJsonString(body, "lastPunishmentAt"); + String typeStr = extractJsonString(body, "last_punishment_type"); + if (typeStr == null) typeStr = extractJsonString(body, "lastPunishmentType"); + String scoreStr = extractJsonString(body, "punishment_score"); + if (scoreStr == null) scoreStr = extractJsonString(body, "punishmentScore"); + // Typ auf erlaubte Werte begrenzen + String safeType = null; + if (typeStr != null) { + String t = typeStr.trim().toLowerCase(Locale.ROOT); + safeType = (t.equals("ban") || t.equals("mute") || t.equals("warn") || t.equals("kick")) ? t : ""; + } + synchronized (psPun) { + try { if (bansStr != null && !bansStr.isEmpty()) psPun.bansCount = Integer.parseInt(bansStr); } catch (Exception ignored) {} + try { if (mutesStr != null && !mutesStr.isEmpty()) psPun.mutesCount = Integer.parseInt(mutesStr); } catch (Exception ignored) {} + try { if (warnsStr != null && !warnsStr.isEmpty()) psPun.warnsCount = Integer.parseInt(warnsStr); } catch (Exception ignored) {} + try { if (lastAtStr != null && !lastAtStr.isEmpty()) psPun.lastPunishmentAt = Long.parseLong(lastAtStr); } catch (Exception ignored) {} + if (safeType != null) psPun.lastPunishmentType = safeType; + try { if (scoreStr != null && !scoreStr.isEmpty()) psPun.punishmentScore = Integer.parseInt(scoreStr); } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /stats/update – Kills und Deaths eines Spielers aktualisieren (von Backend-Server via StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/stats/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + StatsModule statsModUpd = (StatsModule) moduleManager.getModule("StatsModule"); + if (statsModUpd == null) { + sendHttpResponse(out, "{\"success\":false,\"error\":\"stats_module_unavailable\"}", 503); + return; + } + String uuidStr = extractJsonString(body, "uuid"); + String nameStr = extractJsonString(body, "name"); + PlayerStats psUpd = resolvePlayer(uuidStr, nameStr, statsModUpd); + if (psUpd == null) { + // Spieler noch nicht bekannt → ignorieren (er hat sich noch nicht eingeloggt) + sendHttpResponse(out, "{\"success\":false,\"error\":\"player_not_found\"}", 404); + return; + } + String killsStr = extractJsonString(body, "kills"); + String deathsStr = extractJsonString(body, "deaths"); + // Playtime auch updaten + String playtimeStr = extractJsonString(body, "playtime"); + synchronized (psUpd) { + // HÖCHSTER WERT gewinnt – mehrere Unterserver können unterschiedliche Werte haben + try { if (killsStr != null && !killsStr.isEmpty()) { + int v = Integer.parseInt(killsStr.trim()); + if (v > psUpd.kills) psUpd.kills = v; + }} catch (Exception ignored) {} + try { if (deathsStr != null && !deathsStr.isEmpty()) { + int v = Integer.parseInt(deathsStr.trim()); + if (v > psUpd.deaths) psUpd.deaths = v; + }} catch (Exception ignored) {} + try { if (playtimeStr != null && !playtimeStr.isEmpty()) { + long v = Long.parseLong(playtimeStr.trim()); + if (v > psUpd.totalPlaytime) psUpd.totalPlaytime = v; + }} catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/health – Leben eines Spielers aktualisieren (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/health".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String healthStr = extractJsonString(body, "health"); + if (uuidStr != null && !uuidStr.isEmpty() && healthStr != null && !healthStr.isEmpty()) { + try { + UUID hUuid = UUID.fromString(uuidStr.trim()); + double health = Double.parseDouble(healthStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerHealth.put(hUuid, health); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/compass – Himmelsrichtung eines Spielers (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/compass".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String compassStr = extractJsonString(body, "compass"); + if (uuidStr != null && !uuidStr.isEmpty() && compassStr != null && !compassStr.isEmpty()) { + try { + UUID cUuid = UUID.fromString(uuidStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerCompass.put(cUuid, compassStr.trim()); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /scoreboard/tps – TPS des Spieler-Servers (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/scoreboard/tps".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String tpsStr = extractJsonString(body, "tps"); + if (uuidStr != null && !uuidStr.isEmpty() && tpsStr != null && !tpsStr.isEmpty()) { + try { + UUID tUuid = UUID.fromString(uuidStr.trim()); + double tps = Double.parseDouble(tpsStr.trim()); + net.viper.status.modules.scoreboard.ScoreboardModule.playerTps.put(tUuid, tps); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /player/world – Welt eines Spielers aktualisieren (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/player/world".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + String worldStr = extractJsonString(body, "world"); + if (uuidStr != null && !uuidStr.isEmpty() && worldStr != null && !worldStr.isEmpty()) { + try { + playerWorlds.put(UUID.fromString(uuidStr.trim()), worldStr.trim()); + } catch (IllegalArgumentException ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /ticket/update – TicketSystem Daten (von StatusAPIBridge) + if ("POST".equalsIgnoreCase(method) && "/ticket/update".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + UUID uid = UUID.fromString(uuidStr.trim()); + String myOpen = extractJsonString(body, "my_open"); + if (myOpen != null) + net.viper.status.modules.scoreboard.ScoreboardModule.ticketMyOpen.put(uid, Integer.parseInt(myOpen)); + } catch (Exception ignored) {} + } + try { + String totOpen = extractJsonString(body, "total_open"); + String totClaimed = extractJsonString(body, "total_claimed"); + String ratGood = extractJsonString(body, "rating_good"); + String ratBad = extractJsonString(body, "rating_bad"); + if (totOpen != null) net.viper.status.modules.scoreboard.ScoreboardModule.ticketTotalOpen.set(Integer.parseInt(totOpen)); + if (totClaimed != null) net.viper.status.modules.scoreboard.ScoreboardModule.ticketTotalClaimed.set(Integer.parseInt(totClaimed)); + if (ratGood != null) net.viper.status.modules.scoreboard.ScoreboardModule.ticketRatingGood.set(Integer.parseInt(ratGood)); + if (ratBad != null) net.viper.status.modules.scoreboard.ScoreboardModule.ticketRatingBad.set(Integer.parseInt(ratBad)); + } catch (Exception ignored) {} + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // POST /player/data – Koordinaten, Gamemode, Exp, Food, Speed + if ("POST".equalsIgnoreCase(method) && "/player/data".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + UUID uid = UUID.fromString(uuidStr.trim()); + String xS = extractJsonString(body, "x"); + String yS = extractJsonString(body, "y"); + String zS = extractJsonString(body, "z"); + String gm = extractJsonString(body, "gamemode"); + String expS= extractJsonString(body, "exp"); + String fdS = extractJsonString(body, "food"); + String spS = extractJsonString(body, "speed"); + String wld = extractJsonString(body, "world"); + if (xS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerX.put(uid, (int)Double.parseDouble(xS)); + if (yS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerY.put(uid, (int)Double.parseDouble(yS)); + if (zS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerZ.put(uid, (int)Double.parseDouble(zS)); + if (gm != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerGamemode.put(uid, gm); + if (expS!= null) net.viper.status.modules.scoreboard.ScoreboardModule.playerExp.put(uid, (int)Double.parseDouble(expS)); + if (fdS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerFood.put(uid, (int)Double.parseDouble(fdS)); + if (spS != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerSpeed.put(uid, Double.parseDouble(spS)); + if (wld != null) net.viper.status.modules.scoreboard.ScoreboardModule.playerWorld.put(uid, wld); + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // GET /papi/tokens – liefert alle erkannten %token%-Placeholder als JSON-Array + if ("GET".equalsIgnoreCase(method) && "/papi/tokens".equalsIgnoreCase(pathOnly)) { + sendHttpResponse(out, papiTokensJson, 200); + return; + } + + // POST /player/papi – empfängt von StatusAPIBridge aufgelöste PAPI-Werte + if ("POST".equalsIgnoreCase(method) && "/player/papi".equalsIgnoreCase(pathOnly)) { + String body = readBody(in, headers); + String uuidStr = extractJsonString(body, "uuid"); + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + UUID papiUuid = UUID.fromString(uuidStr.trim()); + Map map = playerPapi.computeIfAbsent(papiUuid, k -> new ConcurrentHashMap<>()); + // "placeholders"-Objekt manuell parsen + int start = body.indexOf("\"placeholders\""); + if (start >= 0) { + int brace = body.indexOf('{', start + 14); + if (brace >= 0) { + int i = brace + 1; + while (i < body.length()) { + while (i < body.length() && Character.isWhitespace(body.charAt(i))) i++; + if (i >= body.length() || body.charAt(i) == '}') break; + if (body.charAt(i) != '"') { i++; continue; } + i++; + StringBuilder key = new StringBuilder(); + while (i < body.length() && body.charAt(i) != '"') { + char ch = body.charAt(i++); + if (ch == '\\' && i < body.length()) i++; else key.append(ch); + } + i++; + while (i < body.length() && (body.charAt(i) == ':' || Character.isWhitespace(body.charAt(i)))) i++; + if (i < body.length() && body.charAt(i) == '"') { + i++; + StringBuilder val = new StringBuilder(); + boolean esc = false; + while (i < body.length()) { + char ch = body.charAt(i++); + if (esc) { val.append(ch == 'n' ? '\n' : ch == 't' ? '\t' : ch); esc = false; } + else if (ch == '\\') esc = true; + else if (ch == '"') break; + else val.append(ch); + } + if (key.length() > 0) map.put(key.toString(), val.toString()); + } + while (i < body.length() && (body.charAt(i) == ',' || Character.isWhitespace(body.charAt(i)))) i++; + } + } + } + } catch (Exception ignored) {} + } + sendHttpResponse(out, "{\"success\":true}", 200); + return; + } + + // GET – Status-Endpunkt + if (inputLine.startsWith("GET")) { + Map data = new LinkedHashMap<>(); + data.put("online", true); + + String versionRaw = ProxyServer.getInstance().getVersion(); + String versionClean = (versionRaw != null && versionRaw.contains(":")) ? versionRaw.split(":")[2].trim() : versionRaw; + data.put("version", versionClean); + + int globalLimit = ProxyServer.getInstance().getConfig().getPlayerLimit(); + if (globalLimit <= 0) { + try { + Iterator limIt = ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (limIt.hasNext()) { + int listenerMax = limIt.next().getMaxPlayers(); + if (listenerMax > 0) globalLimit = listenerMax; + } + } catch (Exception ignored) {} + } + data.put("max_players", String.valueOf(globalLimit)); + + String motd = "BungeeCord"; + try { + Iterator it = ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (it.hasNext()) motd = it.next().getMotd(); + } catch (Exception ignored) {} + data.put("motd", motd); + + StatsModule statsModule = (StatsModule) moduleManager.getModule("StatsModule"); + boolean luckPermsEnabled = ProxyServer.getInstance().getPluginManager().getPlugin("LuckPerms") != null; + List> playersList = new ArrayList<>(); + + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + Map playerInfo = new LinkedHashMap<>(); + playerInfo.put("name", p.getName()); + try { playerInfo.put("uuid", p.getUniqueId().toString()); } catch (Exception ignored) {} + + boolean isBedrock = false; + String bedrockId = null; + try { + Class floodgateApi = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + Object api = floodgateApi.getMethod("getInstance").invoke(null); + isBedrock = (boolean) api.getClass().getMethod("isBedrockPlayer", java.util.UUID.class).invoke(api, p.getUniqueId()); + if (isBedrock) { + bedrockId = (String) api.getClass().getMethod("getBedrockId", java.util.UUID.class).invoke(api, p.getUniqueId()); + } + } catch (Exception ignored) {} + // Fallback: Floodgate-UUIDs haben MSB == 0 (00000000-0000-0000-xxxx-xxxxxxxxxxxx) + if (!isBedrock) { + isBedrock = p.getUniqueId().getMostSignificantBits() == 0L; + } + playerInfo.put("isBedrock", isBedrock); + if (bedrockId != null) playerInfo.put("bedrockId", bedrockId); + + String prefix = ""; + if (luckPermsEnabled) { + try { + Class providerClass = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object luckPermsApi = providerClass.getMethod("get").invoke(null); + Object userManager = luckPermsApi.getClass().getMethod("getUserManager").invoke(luckPermsApi); + Object user = userManager.getClass().getMethod("getUser", java.util.UUID.class).invoke(userManager, p.getUniqueId()); + if (user != null) { + Class queryOptionsClass = Class.forName("net.luckperms.api.query.QueryOptions"); + Object queryOptions = queryOptionsClass.getMethod("defaultContextualOptions").invoke(null); + Object cachedData = user.getClass().getMethod("getCachedData").invoke(user); + Object metaData = cachedData.getClass().getMethod("getMetaData", queryOptionsClass).invoke(cachedData, queryOptions); + Object result = metaData.getClass().getMethod("getPrefix").invoke(metaData); + if (result != null) prefix = (String) result; + } + } catch (Exception ignored) {} + } + playerInfo.put("prefix", prefix); + + // Aktueller Sub-Server des Spielers (z.B. "Lobby", "Survival") + try { + if (p.getServer() != null && p.getServer().getInfo() != null) { + playerInfo.put("server", p.getServer().getInfo().getName()); + } + } catch (Exception ignored) {} + + if (statsModule != null) { + PlayerStats ps = statsModule.getManager().getIfPresent(p.getUniqueId()); + if (ps != null) { + playerInfo.put("playtime", ps.getPlaytimeWithCurrentSession()); + playerInfo.put("joins", ps.joins); + playerInfo.put("kills", ps.kills); + playerInfo.put("deaths", ps.deaths); + playerInfo.put("first_seen", ps.firstSeen); + playerInfo.put("last_seen", ps.lastSeen); + // Balance direkt aus MySQL (serverübergreifend) + double statusBalance = playerBalances.getOrDefault(p.getUniqueId(), 0.0); + Map eco = new LinkedHashMap<>(); + eco.put("balance", statusBalance); + eco.put("total_earned", ps.totalEarned); + eco.put("total_spent", ps.totalSpent); + eco.put("transactions_count", ps.transactionsCount); + playerInfo.put("economy", eco); + Map pun = new LinkedHashMap<>(); + pun.put("bans", ps.bansCount); + pun.put("mutes", ps.mutesCount); + pun.put("warns", ps.warnsCount); + pun.put("last_punishment_at", ps.lastPunishmentAt); + pun.put("last_punishment_type", ps.lastPunishmentType != null ? ps.lastPunishmentType : ""); + pun.put("punishment_score", ps.punishmentScore); + playerInfo.put("punishments", pun); + } + } + playersList.add(playerInfo); + } + data.put("players", playersList); + + NetworkInfoModule networkInfoModule = (NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (networkInfoModule != null && networkInfoModule.isEnabled()) { + data.put("network", networkInfoModule.buildSnapshot()); + } + + AntiBotModule antiBotModule = (AntiBotModule) moduleManager.getModule("AntiBotModule"); + if (antiBotModule != null && antiBotModule.isEnabled()) { + data.put("antibot", antiBotModule.buildSnapshot()); + } + + String json = buildJsonString(data); + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + StringBuilder response = new StringBuilder(); + response.append("HTTP/1.1 200 OK\r\n"); + response.append("Content-Type: application/json; charset=UTF-8\r\n"); + response.append("Access-Control-Allow-Origin: *\r\n"); + response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); + response.append("Connection: close\r\n\r\n"); + out.write(response.toString().getBytes(StandardCharsets.UTF_8)); + out.write(jsonBytes); + out.flush(); + } + + } catch (Exception e) { + getLogger().severe("Fehler beim Verarbeiten der Anfrage: " + e.getMessage()); + } + } + + // --- Hilfsmethoden --- + + /** + * Liest den HTTP-Body basierend auf Content-Length. + */ + private String readBody(BufferedReader in, Map headers) throws IOException { + int contentLength = 0; + if (headers.containsKey("content-length")) { + try { contentLength = Integer.parseInt(headers.get("content-length")); } catch (NumberFormatException ignored) {} + } + if (contentLength <= 0) return ""; + char[] bodyChars = new char[contentLength]; + int read = 0; + while (read < contentLength) { + int r = in.read(bodyChars, read, contentLength - read); + if (r == -1) break; + read += r; + } + return new String(bodyChars); + } + + private String extractJsonString(String json, String key) { + if (json == null || key == null) return null; + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) return null; + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) return null; + int i = colon + 1; + while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++; + if (i >= json.length()) return null; + char c = json.charAt(i); + if (c == '"') { + i++; + StringBuilder sb = new StringBuilder(); + boolean escape = false; + while (i < json.length()) { + char ch = json.charAt(i++); + if (escape) { + switch (ch) { + case 'n': sb.append('\n'); break; + case 'r': sb.append('\r'); break; + case 't': sb.append('\t'); break; + case '"': sb.append('"'); break; + case '\\': sb.append('\\'); break; + case '/': sb.append('/'); break; + case 'b': sb.append('\b'); break; + case 'f': sb.append('\f'); break; + case 'u': + if (i + 4 <= json.length()) { + try { + int cp = Integer.parseInt(json.substring(i, i + 4), 16); + sb.append((char) cp); + i += 4; + } catch (NumberFormatException ignored) { + sb.append("\\u"); + } + } + break; + default: sb.append(ch); break; + } + escape = false; + } else { + if (ch == '\\') escape = true; + else if (ch == '"') break; + else sb.append(ch); + } + } + return sb.toString(); + } else { + StringBuilder sb = new StringBuilder(); + while (i < json.length()) { + char ch = json.charAt(i); + if (ch == ',' || ch == '}' || ch == '\n' || ch == '\r') break; + sb.append(ch); + i++; + } + return sb.toString().trim(); + } + } + + private Properties loadNetworkGuardProperties() { + Properties props = new Properties(); + File file = new File(getDataFolder(), "network-guard.properties"); + if (!file.exists()) return props; + try (FileInputStream fis = new FileInputStream(file)) { + props.load(new java.io.InputStreamReader(fis, StandardCharsets.UTF_8)); + } catch (Exception e) { + getLogger().warning("Konnte network-guard.properties nicht laden: " + e.getMessage()); + } + return props; + } + + private List parseCommaListProperty(String raw) { + List out = new ArrayList<>(); + if (raw == null || raw.trim().isEmpty()) return out; + for (String p : raw.split(",")) { + String trimmed = p == null ? "" : p.trim(); + if (!trimmed.isEmpty()) out.add(trimmed); + } + return out; + } + + private Map parseQueryParams(String path) { + Map params = new HashMap<>(); + int q = path.indexOf('?'); + if (q < 0) return params; + String query = path.substring(q + 1); + for (String pair : query.split("&")) { + if (pair.isEmpty()) continue; + int eq = pair.indexOf('='); + if (eq <= 0) continue; + params.put(pair.substring(0, eq).trim().toLowerCase(Locale.ROOT), pair.substring(eq + 1).trim()); + } + return params; + } + + private PlayerStats resolvePlayer(String uuidStr, String name, StatsModule statsModule) { + if (statsModule == null) return null; + if (uuidStr != null && !uuidStr.isEmpty()) { + try { + return statsModule.getManager().getIfPresent(UUID.fromString(uuidStr.trim())); + } catch (IllegalArgumentException ignored) {} + } + if (name != null && !name.isEmpty()) { + String lower = name.trim().toLowerCase(Locale.ROOT); + for (PlayerStats ps : statsModule.getManager().all()) { + if (ps.name.toLowerCase(Locale.ROOT).equals(lower)) return ps; + } + } + return null; + } + + private void sendHttpResponse(OutputStream out, String json, int code) throws IOException { + byte[] jsonBytes = json.getBytes(StandardCharsets.UTF_8); + String status = code == 200 ? "OK" : (code == 403 ? "Forbidden" : code == 404 ? "Not Found" : code == 400 ? "Bad Request" : "Error"); + StringBuilder response = new StringBuilder(); + response.append("HTTP/1.1 ").append(code).append(" ").append(status).append("\r\n"); + response.append("Content-Type: application/json; charset=UTF-8\r\n"); + response.append("Access-Control-Allow-Origin: *\r\n"); + response.append("Content-Length: ").append(jsonBytes.length).append("\r\n"); + response.append("Connection: close\r\n\r\n"); + out.write(response.toString().getBytes(StandardCharsets.UTF_8)); + out.write(jsonBytes); + out.flush(); + } + + private String buildJsonString(Map data) { + StringBuilder sb = new StringBuilder("{"); + boolean first = true; + for (Map.Entry entry : data.entrySet()) { + if (!first) sb.append(","); + first = false; + sb.append("\"").append(escapeJson(entry.getKey())).append("\":").append(valueToString(entry.getValue())); + } + sb.append("}"); + return sb.toString(); + } + + @SuppressWarnings("unchecked") + private String valueToString(Object value) { + if (value == null) return "null"; + else if (value instanceof Boolean) return value.toString(); + else if (value instanceof Number) return value.toString(); + else if (value instanceof Map) return buildJsonString((Map) value); + else if (value instanceof List) { + StringBuilder sb = new StringBuilder("["); + List list = (List) value; + for (int i = 0; i < list.size(); i++) { + if (i > 0) sb.append(","); + Object item = list.get(i); + if (item instanceof Map) sb.append(buildJsonString((Map) item)); + else if (item instanceof Number) sb.append(item.toString()); + else sb.append("\"").append(escapeJson(String.valueOf(item))).append("\""); + } + sb.append("]"); + return sb.toString(); + } + return "\"" + escapeJson(String.valueOf(value)) + "\""; + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private List> loadAntiBotSecurityEvents(int maxEntries) { + List> out = new ArrayList<>(); + File logFile = new File(getDataFolder(), "antibot-security.log"); + if (!logFile.exists() || maxEntries <= 0) return out; + try { + List lines = Files.readAllLines(logFile.toPath(), StandardCharsets.UTF_8); + for (int i = lines.size() - 1; i >= 0 && out.size() < maxEntries; i--) { + String line = lines.get(i); + if (line == null || line.trim().isEmpty()) continue; + Map parsed = parseSecurityLogLine(line); + String event = parsed.get("event"); + if (!isAttackSecurityEvent(event)) continue; + String player = parsed.get("player"); + String uuid = parsed.get("uuid"); + if (player == null || player.trim().isEmpty() || "-".equals(player) || "unknown".equalsIgnoreCase(player)) continue; + if (uuid == null || uuid.trim().isEmpty()) uuid = "-"; + Map row = new LinkedHashMap<>(); + row.put("datetime", parsed.getOrDefault("datetime", "")); + row.put("player", player); + row.put("uuid", uuid); + row.put("ip", parsed.getOrDefault("ip", "-")); + out.add(row); + } + } catch (Exception e) { + getLogger().warning("Konnte antibot-security.log nicht lesen: " + e.getMessage()); + } + return out; + } + + private Map parseSecurityLogLine(String line) { + Map map = new LinkedHashMap<>(); + if (line == null) return map; + String[] segments = line.split("\\s*\\|\\s*"); + if (segments.length > 0) map.put("datetime", segments[0].trim()); + for (int i = 1; i < segments.length; i++) { + String seg = segments[i]; + int idx = seg.indexOf('='); + if (idx <= 0) continue; + map.put(seg.substring(0, idx).trim().toLowerCase(Locale.ROOT), seg.substring(idx + 1).trim()); + } + return map; + } + + private boolean isAttackSecurityEvent(String event) { + if (event == null) return false; + String e = event.trim().toLowerCase(Locale.ROOT); + return e.contains("ip_rate") || e.contains("vpn") || e.contains("learning_threshold_block") || e.contains("block"); + } + + // ── PAPI-Token-Erkennung ────────────────────────────────────────────────── + + /** Alle Tokens die StatusAPI selbst auflöst – werden nicht an PAPI weitergegeben */ + private static final Set NATIVE_TOKENS = new HashSet<>(Arrays.asList( + "player", "rank", "money", "server", "compass", "health", "hearts", "ping", + "online", "maxplayers", "tps", "ram", "time", "playtime", "x", "y", "z", + "world", "gamemode", "exp", "food", "foodsym", "speed", "uptime", "servers", + "proxymem", "date", "news", "line", "balance", + "ticket_my_open", "ticket_open", "ticket_claimed", + "ticket_rating_good", "ticket_rating_bad", "ticket_rating_pct" + )); + + /** + * Scannt alle .properties-Dateien im Plugin-Ordner nach %token%-Mustern, + * filtert nativ unterstützte Tokens heraus und veröffentlicht den Rest + * als JSON-Array unter GET /papi/tokens für StatusAPIBridge. + */ + public void scanAndPublishPapiTokens() { + Set tokens = new LinkedHashSet<>(); + File folder = getDataFolder(); + if (folder.exists()) { + File[] files = folder.listFiles((dir, name) -> name.endsWith(".properties")); + if (files != null) { + for (File f : files) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(f), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + extractAllTokensFromText(line, tokens); + } + } catch (IOException ignored) {} + } + } + } + // Ressourcen-Defaults scannen (falls noch keine Dateien im Ordner) + for (String resource : new String[]{"scoreboard.properties"}) { + try (java.io.InputStream is = getResourceAsStream(resource)) { + if (is == null) continue; + BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); + String line; + while ((line = br.readLine()) != null) { + extractAllTokensFromText(line, tokens); + } + } catch (IOException ignored) {} + } + tokens.removeAll(NATIVE_TOKENS); + // JSON-Array bauen + StringBuilder json = new StringBuilder("["); + boolean first = true; + for (String token : tokens) { + if (!first) json.append(","); + json.append("\"").append(token.replace("\\", "\\\\").replace("\"", "\\\"")).append("\""); + first = false; + } + json.append("]"); + papiTokensJson = json.toString(); + if (!tokens.isEmpty()) { + getLogger().info("[StatusAPI] " + tokens.size() + " PAPI-Token(s) erkannt: " + tokens); + } + } + + private static void extractAllTokensFromText(String text, Set result) { + if (text == null || text.startsWith("#") || !text.contains("%")) return; + int eq = text.indexOf('='); + String value = eq >= 0 ? text.substring(eq + 1) : text; + int i = 0; + while (i < value.length()) { + int start = value.indexOf('%', i); + if (start < 0) break; + int end = value.indexOf('%', start + 1); + if (end < 0) break; + String token = value.substring(start + 1, end); + if (!token.isEmpty() && !token.contains(" ") && token.matches("[a-zA-Z0-9_:]+")) { + result.add(token); + } + i = end + 1; + } + } + + // ── Reload ──────────────────────────────────────────────────────────────── + + /** + * Lädt Scoreboard und Tablist neu (Config + Tasks), ohne den HTTP-Server zu berühren. + * Alle anderen Module (Chat, AntiBot, etc.) bleiben unberührt. + */ + public void reloadModules() { + getLogger().info("[StatusAPI] Reload von Scoreboard und Tablist..."); + + net.viper.status.module.Module sbMod = moduleManager.getModule("ScoreboardModule"); + net.viper.status.module.Module tabMod = moduleManager.getModule("TablistModule"); + + if (sbMod != null) sbMod.onDisable(this); + if (tabMod != null) tabMod.onDisable(this); + + // Neue Instanzen erstellen und registrieren + net.viper.status.modules.scoreboard.ScoreboardModule newSb = new net.viper.status.modules.scoreboard.ScoreboardModule(); + net.viper.status.modules.tablist.TablistModule newTab = new net.viper.status.modules.tablist.TablistModule(); + + moduleManager.replaceModule("ScoreboardModule", newSb); + moduleManager.replaceModule("TablistModule", newTab); + + newSb.onEnable(this); + newTab.onEnable(this); + + // TPS-Fallback neu verbinden + try { + net.viper.status.modules.network.NetworkInfoModule nim = + (net.viper.status.modules.network.NetworkInfoModule) moduleManager.getModule("NetworkInfoModule"); + if (nim != null) newSb.setNetworkInfoModule(nim); + } catch (Exception ignored) {} + + scanAndPublishPapiTokens(); + getLogger().info("[StatusAPI] Reload abgeschlossen."); + } + + // ── /statusapi Befehl ───────────────────────────────────────────────────── + + private static class StatusAPICommand extends net.md_5.bungee.api.plugin.Command { + + private final StatusAPI plugin; + + StatusAPICommand(StatusAPI plugin) { + super("statusapi", "statusapi.admin", "sapi"); + this.plugin = plugin; + } + + @Override + public void execute(net.md_5.bungee.api.CommandSender sender, String[] args) { + if (args.length == 0 || args[0].equalsIgnoreCase("help")) { + boolean isAdmin = sender.hasPermission("statusapi.admin") + || !(sender instanceof net.md_5.bungee.api.connection.ProxiedPlayer); + send(sender, "&8&m──────────────────────────────────────────"); + send(sender, "&6&lStatusAPI &7| Befehle"); + if (isAdmin) { + send(sender, "&e/statusapi reload &7– Scoreboard & Tablist neu laden"); + } else { + send(sender, "&7Keine weiteren Unterbefehle verfügbar."); + } + send(sender, "&8&m──────────────────────────────────────────"); + return; + } + + if (!sender.hasPermission("statusapi.admin")) { + send(sender, "&cKeine Berechtigung."); + return; + } + + switch (args[0].toLowerCase()) { + case "reload": + send(sender, "&7Lade &6Scoreboard &7und &6Tablist &7neu..."); + plugin.reloadModules(); + send(sender, "&aScoreboard &7und &aTablist &7wurden neu geladen."); + send(sender, "&7PAPI-Tokens erkannt: &e" + papiTokensJson); + break; + + default: + send(sender, "&cUnbekannter Unterbefehl. Nutze &e/statusapi help&c."); + break; + } + } + + private static void send(net.md_5.bungee.api.CommandSender s, String text) { + s.sendMessage(new net.md_5.bungee.api.chat.TextComponent( + net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', text))); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/UpdateChecker.java b/src/main/java/net/viper/status/UpdateChecker.java new file mode 100644 index 0000000..9827dbc --- /dev/null +++ b/src/main/java/net/viper/status/UpdateChecker.java @@ -0,0 +1,142 @@ +package net.viper.status; + +import net.md_5.bungee.api.plugin.Plugin; + +import java.io.BufferedReader; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.logging.Level; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +public class UpdateChecker { + + private final Plugin plugin; + private final String currentVersion; + private final int intervalHours; + + // Neue Domain und korrekter API-Pfad für Releases + private final String apiUrl = "https://git.viper.ipv64.net/api/v1/repos/M_Viper/StatusAPI/releases"; + + private volatile String latestVersion = ""; + private volatile String latestUrl = ""; + + private static final Pattern ASSET_NAME_PATTERN = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern DOWNLOAD_PATTERN = Pattern.compile("\"browser_download_url\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + private static final Pattern TAG_NAME_PATTERN = Pattern.compile("\"tag_name\"\\s*:\\s*\"([^\"]+)\"", Pattern.CASE_INSENSITIVE); + + public UpdateChecker(Plugin plugin, String currentVersion, int intervalHours) { + this.plugin = plugin; + this.currentVersion = currentVersion != null ? currentVersion : "0.0.0"; + this.intervalHours = Math.max(1, intervalHours); + } + + public void checkNow() { + try { + HttpURLConnection conn = (HttpURLConnection) new URL(apiUrl).openConnection(); + conn.setRequestMethod("GET"); + conn.setRequestProperty("Accept", "application/json"); + conn.setRequestProperty("User-Agent", "StatusAPI-UpdateChecker/2.0"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(5000); + + int code = conn.getResponseCode(); + if (code != 200) { + plugin.getLogger().warning("Gitea/Forgejo API nicht erreichbar (HTTP " + code + ")"); + return; + } + + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) sb.append(line).append("\n"); + } + + String body = sb.toString(); + + // Neu: Da die API ein JSON-Array von Releases zurückgibt, nehmen wir das erste (neueste) Release + // Wir suchen den ersten Block mit tag_name + String foundVersion = null; + Matcher tagM = TAG_NAME_PATTERN.matcher(body); + if (tagM.find()) { + foundVersion = tagM.group(1).trim(); + } + + if (foundVersion == null) { + plugin.getLogger().warning("Keine Version (Tag) im Release gefunden."); + return; + } + if (foundVersion.startsWith("v") || foundVersion.startsWith("V")) { + foundVersion = foundVersion.substring(1); + } + + String foundUrl = null; + + // Wir suchen im gesamten Body nach der JAR-Datei "StatusAPI.jar" + // Da das neueste Release zuerst kommt, brechen wir ab, sobald wir eine passende JAR finden + Matcher nameMatcher = ASSET_NAME_PATTERN.matcher(body); + Matcher downloadMatcher = DOWNLOAD_PATTERN.matcher(body); + + java.util.List names = new java.util.ArrayList<>(); + java.util.List urls = new java.util.ArrayList<>(); + + while (nameMatcher.find()) { + names.add(nameMatcher.group(1)); + } + while (downloadMatcher.find()) { + urls.add(downloadMatcher.group(1)); + } + + int pairs = Math.min(names.size(), urls.size()); + for (int i = 0; i < pairs; i++) { + String name = names.get(i).trim(); + String url = urls.get(i); + if ("StatusAPI.jar".equalsIgnoreCase(name)) { + foundUrl = url; + break; // Erste (also neueste) passende JAR nehmen + } + } + + if (foundUrl == null) { + plugin.getLogger().warning("Keine StatusAPI.jar im neuesten Release gefunden."); + return; + } + latestVersion = foundVersion; + latestUrl = foundUrl; + + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "Fehler beim Update-Check: " + e.getMessage(), e); + } + } + + public String getLatestVersion() { + return latestVersion != null ? latestVersion : ""; + } + + public String getLatestUrl() { + return latestUrl != null ? latestUrl : ""; + } + + public boolean isUpdateAvailable(String currentVer) { + String lv = getLatestVersion(); + if (lv.isEmpty()) return false; + return compareVersions(lv, currentVer) > 0; + } + + private int compareVersions(String a, String b) { + try { + String[] aa = a.split("\\."); + String[] bb = b.split("\\."); + int len = Math.max(aa.length, bb.length); + for (int i = 0; i < len; i++) { + int ai = i < aa.length ? Integer.parseInt(aa[i].replaceAll("\\D", "")) : 0; + int bi = i < bb.length ? Integer.parseInt(bb[i].replaceAll("\\D", "")) : 0; + if (ai != bi) return Integer.compare(ai, bi); + } + return 0; + } catch (Exception ex) { + return a.compareTo(b); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/module/Module.java b/src/main/java/net/viper/status/module/Module.java new file mode 100644 index 0000000..371b1c7 --- /dev/null +++ b/src/main/java/net/viper/status/module/Module.java @@ -0,0 +1,24 @@ +package net.viper.status.module; + +import net.md_5.bungee.api.plugin.Plugin; + +/** + * Interface für alle zukünftigen Erweiterungen. + */ +public interface Module { + + /** + * Wird aufgerufen, wenn die API startet. + */ + void onEnable(Plugin plugin); + + /** + * Wird aufgerufen, wenn die API stoppt. + */ + void onDisable(Plugin plugin); + + /** + * Eindeutiger Name des Moduls (z.B. "StatsModule"). + */ + String getName(); +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/module/ModuleManager.java b/src/main/java/net/viper/status/module/ModuleManager.java new file mode 100644 index 0000000..6d62d7a --- /dev/null +++ b/src/main/java/net/viper/status/module/ModuleManager.java @@ -0,0 +1,67 @@ +package net.viper.status.module; + +import net.md_5.bungee.api.plugin.Plugin; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Verwaltet alle geladenen Module. + * Verwendet LinkedHashMap um die Registrierungsreihenfolge zu erhalten, + * damit Abhängigkeiten (z.B. VanishModule → ChatModule) korrekt aufgelöst werden. + */ +public class ModuleManager { + + private final Map modules = new LinkedHashMap<>(); + + public void registerModule(Module module) { + modules.put(module.getName().toLowerCase(), module); + } + + public void enableAll(Plugin plugin) { + for (Module module : modules.values()) { + try { + module.onEnable(plugin); + } catch (Exception e) { + plugin.getLogger().severe("Fehler beim Aktivieren von Modul " + module.getName() + ": " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public void disableAll(Plugin plugin) { + for (Module module : modules.values()) { + try { + module.onDisable(plugin); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Deaktivieren von Modul " + module.getName()); + } + } + modules.clear(); + } + + /** + * Ermöglicht anderen Komponenten (wie dem WebServer) Zugriff auf spezifische Module. + */ + public Module getModule(String name) { + return modules.get(name.toLowerCase()); + } + + /** + * Ersetzt ein bestehendes Modul durch eine neue Instanz (für Reload). + * Das alte Modul muss bereits deaktiviert worden sein. + */ + public void replaceModule(String name, Module newModule) { + modules.put(name.toLowerCase(), newModule); + } + + @SuppressWarnings("unchecked") + public T getModule(Class clazz) { + for (Module m : modules.values()) { + if (clazz.isInstance(m)) { + return (T) m; + } + } + return null; + } +} diff --git a/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java b/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java new file mode 100644 index 0000000..7c2a3f4 --- /dev/null +++ b/src/main/java/net/viper/status/modules/AutoMessage/AutoMessageModule.java @@ -0,0 +1,163 @@ +package net.viper.status.modules.AutoMessage; + +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.TextComponent; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +/** + * AutoMessageModule + * + * Fix #5: + * - Nachrichten werden bei jedem Zyklus frisch aus der Datei gelesen, + * damit Änderungen an messages.txt sofort wirken ohne Neustart. + * - Neuer Befehl /automessage reload (Permission: statusapi.automessage) + * lädt die Konfiguration neu und setzt den Zähler zurück. + * - TextComponent.fromLegacy() → ChatColor.translateAlternateColorCodes für §-Codes. + */ +public class AutoMessageModule implements Module { + + private int taskId = -1; + private StatusAPI api; + private final AtomicInteger currentIndex = new AtomicInteger(0); + + // Konfiguration (für Reload zugänglich) + private volatile boolean enabled = false; + private volatile int intervalSeconds = 300; + private volatile String fileName = "messages.txt"; + private volatile String prefix = ""; + + @Override + public String getName() { return "AutoMessage"; } + + @Override + public void onEnable(Plugin plugin) { + this.api = (StatusAPI) plugin; + loadSettings(); + ensureMessagesFileExists(); + + if (!enabled) return; + + registerReloadCommand(); + scheduleTask(); + } + + @Override + public void onDisable(Plugin plugin) { + cancelTask(); + } + + private void ensureMessagesFileExists() { + File dataFolder = api.getDataFolder(); + if (!dataFolder.exists()) dataFolder.mkdirs(); + + File target = new File(dataFolder, fileName); + if (target.exists()) return; + + // Datei aus den Plugin-Ressourcen kopieren + try (java.io.InputStream in = api.getResourceAsStream(fileName)) { + if (in != null) { + Files.copy(in, target.toPath()); + api.getLogger().info("[AutoMessage] " + fileName + " wurde aus den Ressourcen erstellt."); + return; + } + } catch (IOException e) { + api.getLogger().warning("[AutoMessage] Konnte " + fileName + " nicht aus Ressourcen kopieren: " + e.getMessage()); + } + + // Fallback: leere Datei mit Hinweis anlegen + try { + Files.write(target.toPath(), + ("# AutoMessage – eine Nachricht pro Zeile\n" + + "# Farben mit & oder §-Codes, z.B. &aGrüner Text\n" + + "# Kommentarzeilen (# ...) und Leerzeilen werden ignoriert\n").getBytes(StandardCharsets.UTF_8)); + api.getLogger().info("[AutoMessage] " + fileName + " wurde als leere Vorlage erstellt."); + } catch (IOException e) { + api.getLogger().severe("[AutoMessage] Konnte " + fileName + " nicht erstellen: " + e.getMessage()); + } + } + + private void loadSettings() { + Properties props = api.getVerifyProperties(); + enabled = Boolean.parseBoolean(props.getProperty("automessage.enabled", "false")); + String rawInterval = props.getProperty("automessage.interval", "300"); + try { intervalSeconds = Integer.parseInt(rawInterval); } + catch (NumberFormatException e) { api.getLogger().warning("Ungültiges Intervall für AutoMessage! Nutze Standard (300s)."); intervalSeconds = 300; } + fileName = props.getProperty("automessage.file", "messages.txt"); + prefix = props.getProperty("automessage.prefix", ""); + } + + private void registerReloadCommand() { + ProxyServer.getInstance().getPluginManager().registerCommand(api, new Command("automessage", "statusapi.automessage") { + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length > 0 && "reload".equalsIgnoreCase(args[0])) { + cancelTask(); + loadSettings(); + currentIndex.set(0); + if (enabled) { + scheduleTask(); + sender.sendMessage(ChatColor.GREEN + "[AutoMessage] Neu geladen. Intervall: " + intervalSeconds + "s"); + } else { + sender.sendMessage(ChatColor.YELLOW + "[AutoMessage] Modul ist deaktiviert (automessage.enabled=false)."); + } + } else { + sender.sendMessage(ChatColor.YELLOW + "/automessage reload"); + } + } + }); + } + + private void scheduleTask() { + taskId = ProxyServer.getInstance().getScheduler().schedule(api, () -> { + File messageFile = new File(api.getDataFolder(), fileName); + if (!messageFile.exists()) { + api.getLogger().warning("[AutoMessage] Datei nicht gefunden: " + messageFile.getAbsolutePath()); + return; + } + + // Fix #5: Datei bei jedem Tick neu einlesen → Änderungen wirken sofort + List messages; + try { + messages = Files.readAllLines(messageFile.toPath(), StandardCharsets.UTF_8); + } catch (IOException e) { + api.getLogger().severe("[AutoMessage] Fehler beim Lesen von '" + fileName + "': " + e.getMessage()); + return; + } + messages.removeIf(line -> line.trim().isEmpty() || line.trim().startsWith("#")); + if (messages.isEmpty()) return; + + // Index wrappen (threadsafe) + int idx = currentIndex.getAndUpdate(i -> (i + 1) % messages.size()); + if (idx >= messages.size()) idx = 0; + + String raw = messages.get(idx); + String prefixPart = prefix.isEmpty() ? "" : ChatColor.translateAlternateColorCodes('&', prefix) + " "; + // Fix: §-Codes direkt übersetzen (messages.txt nutzt §-Codes) + String text = prefixPart + ChatColor.translateAlternateColorCodes('&', + raw.replace("\u00a7", "&").replace("§", "&")); + + ProxyServer.getInstance().broadcast(new TextComponent(text)); + }, intervalSeconds, intervalSeconds, TimeUnit.SECONDS).getId(); + } + + private void cancelTask() { + if (taskId != -1) { + ProxyServer.getInstance().getScheduler().cancel(taskId); + taskId = -1; + } + } +} diff --git a/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java new file mode 100644 index 0000000..1a15bd6 --- /dev/null +++ b/src/main/java/net/viper/status/modules/antibot/AntiBotModule.java @@ -0,0 +1,853 @@ +package net.viper.status.modules.antibot; + +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.connection.PendingConnection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PreLoginEvent; +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.viper.status.StatusAPI; +import net.viper.status.module.Module; +import net.viper.status.modules.network.NetworkInfoModule; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.FileWriter; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.file.Files; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Date; +import java.util.Deque; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicLong; + +/** + * Eigenständiger AntiBot/Attack-Guard. + * + * Fixes: + * - cleanupExpired() nutzt jetzt removeIf() statt Iteration + remove() (Bug #3) + * - applyProfileDefaults() setzt korrekten attackDefaultSource aus Config + */ +public class AntiBotModule implements Module, Listener { + + private static final String CONFIG_FILE_NAME = "network-guard.properties"; + + private StatusAPI plugin; + + private boolean enabled = true; + private String profile = "high-traffic"; + private int maxCps = 120; + private int attackStartCps = 220; + private int attackStopCps = 120; + private int attackCalmSeconds = 20; + private int ipConnectionsPerMinute = 18; + private int ipBlockSeconds = 600; + private String kickMessage = "Zu viele Verbindungen von deiner IP. Bitte warte kurz."; + + private boolean vpnCheckEnabled = false; + private boolean vpnBlockProxy = true; + private boolean vpnBlockHosting = true; + private int vpnCacheMinutes = 30; + private int vpnTimeoutMs = 2500; + private boolean securityLogEnabled = true; + private String securityLogFileName = "antibot-security.log"; + private File securityLogFile; + private final Object securityLogLock = new Object(); + + private boolean learningModeEnabled = true; + private int learningScoreThreshold = 100; + private int learningDecayPerSecond = 2; + private int learningStateWindowSeconds = 120; + private int learningRapidWindowMs = 1500; + private int learningRapidPoints = 12; + private int learningIpRateExceededPoints = 30; + private int learningVpnProxyPoints = 40; + private int learningVpnHostingPoints = 30; + private int learningAttackModePoints = 12; + private int learningHighCpsPoints = 10; + private int learningRecentEventLimit = 30; + + private final AtomicInteger currentSecondConnections = new AtomicInteger(0); + private volatile long currentSecond = System.currentTimeMillis() / 1000L; + private volatile int lastCps = 0; + private final AtomicInteger peakCps = new AtomicInteger(0); + + private volatile boolean attackMode = false; + private volatile long attackCalmSince = 0L; + private final AtomicLong blockedConnectionsTotal = new AtomicLong(0L); + private final AtomicLong blockedConnectionsCurrentAttack = new AtomicLong(0L); + private final Set blockedIpsCurrentAttack = ConcurrentHashMap.newKeySet(); + + private final Map perIpWindows = new ConcurrentHashMap<>(); + private final Map blockedIpsUntil = new ConcurrentHashMap<>(); + private final Map vpnCache = new ConcurrentHashMap<>(); + private final Map recentIdentityByIp = new ConcurrentHashMap<>(); + private final Map learningProfiles = new ConcurrentHashMap<>(); + private final Deque learningRecentEvents = new ArrayDeque<>(); + + @Override + public String getName() { return "AntiBotModule"; } + + @Override + public void onEnable(Plugin plugin) { + if (!(plugin instanceof StatusAPI)) return; + this.plugin = (StatusAPI) plugin; + ensureModuleConfigExists(); + loadConfig(); + ensureSecurityLogFile(); + + if (!enabled) { + StatusAPI.debugLog(this.plugin, "[AntiBotModule] deaktiviert via " + CONFIG_FILE_NAME); + return; + } + + ProxyServer.getInstance().getPluginManager().registerListener(this.plugin, this); + ProxyServer.getInstance().getPluginManager().registerCommand(this.plugin, new AntiBotCommand()); + ProxyServer.getInstance().getScheduler().schedule(this.plugin, this::tick, 1, 1, TimeUnit.SECONDS); + + this.plugin.getLogger().fine("[AntiBotModule] aktiviert. maxCps=" + maxCps + + ", attackStartCps=" + attackStartCps + ", ip/min=" + ipConnectionsPerMinute); + } + + @Override + public void onDisable(Plugin plugin) { + perIpWindows.clear(); + blockedIpsUntil.clear(); + vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { learningRecentEvents.clear(); } + blockedIpsCurrentAttack.clear(); + attackMode = false; + } + + public boolean isEnabled() { return enabled; } + + private void reloadRuntimeState() { + perIpWindows.clear(); + blockedIpsUntil.clear(); + vpnCache.clear(); + learningProfiles.clear(); + synchronized (learningRecentEvents) { learningRecentEvents.clear(); } + blockedIpsCurrentAttack.clear(); + attackMode = false; + attackCalmSince = 0L; + blockedConnectionsCurrentAttack.set(0L); + currentSecondConnections.set(0); + lastCps = 0; + peakCps.set(0); + loadConfig(); + ensureSecurityLogFile(); + } + + public Map buildSnapshot() { + Map out = new LinkedHashMap<>(); + out.put("enabled", enabled); + out.put("profile", profile); + out.put("attack_mode", attackMode); + out.put("protection_enabled", enabled); + out.put("attack_mode_status", attackMode ? "active" : "normal"); + out.put("attack_mode_display", attackMode ? "Angriff erkannt" : "Normalbetrieb"); + out.put("status_message", enabled + ? (attackMode ? "AntiBot aktiv: Angriff erkannt" : "AntiBot aktiv: kein Angriff erkannt") + : "AntiBot deaktiviert"); + out.put("last_cps", lastCps); + out.put("peak_cps", peakCps.get()); + out.put("blocked_ips_active", blockedIpsUntil.size()); + out.put("blocked_connections_total", blockedConnectionsTotal.get()); + out.put("vpn_check_enabled", vpnCheckEnabled); + out.put("learning_mode_enabled", learningModeEnabled); + out.put("learning_profiles", learningProfiles.size()); + out.put("thresholds", buildThresholds()); + return out; + } + + private Map buildThresholds() { + Map m = new LinkedHashMap<>(); + m.put("max_cps", maxCps); + m.put("attack_start_cps", attackStartCps); + m.put("attack_stop_cps", attackStopCps); + m.put("attack_calm_seconds", attackCalmSeconds); + m.put("ip_connections_per_minute", ipConnectionsPerMinute); + m.put("ip_block_seconds", ipBlockSeconds); + m.put("learning_score_threshold", learningScoreThreshold); + m.put("learning_decay_per_second", learningDecayPerSecond); + return m; + } + + @EventHandler + public void onPreLogin(PreLoginEvent event) { + if (!enabled) return; + + String ip = extractIp(event.getConnection()); + if (ip == null || ip.isEmpty()) return; + + cacheRecentIdentity(ip, event.getConnection(), System.currentTimeMillis()); + recordConnection(); + long now = System.currentTimeMillis(); + + // FIX #3: cleanupExpired verwendet removeIf statt Iteration+remove + cleanupExpired(now); + + Long blockedUntil = blockedIpsUntil.get(ip); + if (blockedUntil != null && blockedUntil > now) { + logSecurityEvent("ip_block_active", ip, event.getConnection(), "blocked_until_ms=" + blockedUntil); + blockEvent(event); + return; + } + + if (learningModeEnabled) { + evaluateLearningBaseline(ip, now); + Long learningBlock = blockedIpsUntil.get(ip); + if (learningBlock != null && learningBlock > now) { + blockEvent(event); + return; + } + } + + boolean ipRateExceeded = isIpRateExceeded(ip, now); + if (ipRateExceeded) { + if (learningModeEnabled) { + int score = addLearningScore(ip, now, learningIpRateExceededPoints, "ip-rate-exceeded", true); + logSecurityEvent("ip_rate_exceeded_scored", ip, event.getConnection(), "score=" + score + ", threshold=" + learningScoreThreshold); + if (score >= learningScoreThreshold) { + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=ip-rate-exceeded, score=" + score); + blockEvent(event); + return; + } + } else { + blockIp(ip, now); + logSecurityEvent("ip_rate_limit_block", ip, event.getConnection(), "mode=direct"); + blockEvent(event); + return; + } + } + + if (vpnCheckEnabled) { + VpnCheckResult info = getVpnInfo(ip, now); + if (info != null) { + boolean shouldBlock = (vpnBlockProxy && info.proxy) || (vpnBlockHosting && info.hosting); + if (shouldBlock) { + logSecurityEvent("vpn_detected", ip, event.getConnection(), "proxy=" + info.proxy + ", hosting=" + info.hosting); + if (learningModeEnabled) { + if (vpnBlockProxy && info.proxy) addLearningScore(ip, now, learningVpnProxyPoints, "vpn-proxy", false); + if (vpnBlockHosting && info.hosting) addLearningScore(ip, now, learningVpnHostingPoints, "vpn-hosting", false); + int current = getLearningScore(ip, now); + if (current >= learningScoreThreshold) { + blockIp(ip, now); + logSecurityEvent("learning_threshold_block", ip, event.getConnection(), "reason=vpn, score=" + current); + recordLearningEvent("BLOCK " + ip + " reason=vpn score=" + current); + blockEvent(event); + } + } else { + blockIp(ip, now); + logSecurityEvent("vpn_block", ip, event.getConnection(), "mode=direct, proxy=" + info.proxy + ", hosting=" + info.hosting); + blockEvent(event); + } + } + } + } + } + + @EventHandler + public void onPostLogin(PostLoginEvent event) { + if (!enabled || event == null || event.getPlayer() == null) return; + ProxiedPlayer player = event.getPlayer(); + String ip = extractIpFromPlayer(player); + if (ip == null || ip.isEmpty()) return; + cacheRecentIdentityDirect(ip, player.getName(), player.getUniqueId(), System.currentTimeMillis()); + } + + private void blockEvent(PreLoginEvent event) { + event.setCancelled(true); + } + + private String extractIp(PendingConnection conn) { + if (conn == null || conn.getAddress() == null) return null; + if (conn.getAddress() instanceof InetSocketAddress) { + InetSocketAddress sa = (InetSocketAddress) conn.getAddress(); + return sa.getAddress() != null ? sa.getAddress().getHostAddress() : sa.getHostString(); + } + return String.valueOf(conn.getAddress()); + } + + private String extractIpFromPlayer(ProxiedPlayer player) { + if (player == null || player.getAddress() == null) return null; + if (player.getAddress() instanceof InetSocketAddress) { + InetSocketAddress sa = (InetSocketAddress) player.getAddress(); + return sa.getAddress() != null ? sa.getAddress().getHostAddress() : sa.getHostString(); + } + return String.valueOf(player.getAddress()); + } + + private void recordConnection() { + long sec = System.currentTimeMillis() / 1000L; + if (sec != currentSecond) { + synchronized (this) { + if (sec != currentSecond) { + currentSecond = sec; + currentSecondConnections.set(0); + } + } + } + currentSecondConnections.incrementAndGet(); + } + + private boolean isIpRateExceeded(String ip, long now) { + IpWindow window = perIpWindows.computeIfAbsent(ip, k -> new IpWindow(now)); + synchronized (window) { + long diff = now - window.windowStart; + if (diff > 60_000L) { + window.windowStart = now; + window.count = 0; + } + window.count++; + return window.count > Math.max(1, ipConnectionsPerMinute); + } + } + + /** + * Sperrt eine IP für die konfigurierte Block-Dauer (antibot.ip.block_seconds). + * Kann von anderen Modulen aufgerufen werden (z. B. MultiAccountGuard). + * @param ip Die zu sperrende IP-Adresse + * @param durationSeconds Sperrdauer in Sekunden (0 = antibot-Standard verwenden) + */ + public void blockIpExternal(String ip, int durationSeconds) { + long now = System.currentTimeMillis(); + long duration = durationSeconds > 0 ? durationSeconds : Math.max(1, ipBlockSeconds); + blockedIpsUntil.put(ip, now + duration * 1000L); + blockedConnectionsTotal.incrementAndGet(); + } + + private void blockIp(String ip, long now) { + blockedIpsUntil.put(ip, now + Math.max(1, ipBlockSeconds) * 1000L); + blockedConnectionsTotal.incrementAndGet(); + if (attackMode) { + blockedConnectionsCurrentAttack.incrementAndGet(); + blockedIpsCurrentAttack.add(ip); + } + } + + /** + * FIX #3: Verwendet removeIf() statt for-each + remove() um ConcurrentModificationException zu vermeiden. + */ + private void cleanupExpired(long now) { + blockedIpsUntil.entrySet().removeIf(e -> e.getValue() <= now); + vpnCache.entrySet().removeIf(e -> e.getValue().expiresAt <= now); + recentIdentityByIp.entrySet().removeIf(e -> { + RecentPlayerIdentity id = e.getValue(); + return id == null || (now - id.updatedAtMs) > 600_000L; + }); + if (learningModeEnabled) { + long staleAfter = Math.max(60, learningStateWindowSeconds) * 1000L; + learningProfiles.entrySet().removeIf(e -> { + LearningProfile lp = e.getValue(); + return lp == null || ((now - lp.lastSeenAt) > staleAfter && lp.score <= 0); + }); + } + } + + private void tick() { + if (!enabled) return; + int cps = currentSecondConnections.getAndSet(0); + lastCps = cps; + if (cps > peakCps.get()) peakCps.set(cps); + + long now = System.currentTimeMillis(); + + if (!attackMode && cps >= Math.max(1, attackStartCps)) { + attackMode = true; + attackCalmSince = 0L; + blockedConnectionsCurrentAttack.set(0L); + blockedIpsCurrentAttack.clear(); + sendAttackToWebhook("detected", cps, null, null, "StatusAPI AntiBot"); + plugin.getLogger().warning("[AntiBotModule] Attack erkannt. CPS=" + cps); + return; + } + + if (attackMode) { + if (cps <= Math.max(1, attackStopCps)) { + if (attackCalmSince == 0L) attackCalmSince = now; + long calmFor = now - attackCalmSince; + if (calmFor >= Math.max(1, attackCalmSeconds) * 1000L) { + attackMode = false; + attackCalmSince = 0L; + int blockedIps = blockedIpsCurrentAttack.size(); + long blockedConns = blockedConnectionsCurrentAttack.get(); + sendAttackToWebhook("stopped", cps, blockedIps, blockedConns, "StatusAPI AntiBot"); + plugin.getLogger().warning("[AntiBotModule] Attack beendet. blockedIps=" + blockedIps + ", blockedConnections=" + blockedConns); + } + } else { + attackCalmSince = 0L; + } + } + } + + private void sendAttackToWebhook(String type, Integer cps, Integer blockedIps, Long blockedConnections, String source) { + NetworkInfoModule networkInfoModule = getNetworkInfoModule(); + if (networkInfoModule == null) return; + networkInfoModule.sendAttackNotification(type, cps, blockedIps, blockedConnections, source); + } + + private NetworkInfoModule getNetworkInfoModule() { + if (plugin == null || plugin.getModuleManager() == null) return null; + return (NetworkInfoModule) plugin.getModuleManager().getModule("NetworkInfoModule"); + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (!file.exists()) return; + + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(file)) { + props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + + enabled = parseBoolean(props.getProperty("antibot.enabled"), true); + profile = normalizeProfile(props.getProperty("antibot.profile", "high-traffic")); + applyProfileDefaults(profile); + + maxCps = parseInt(props.getProperty("antibot.max_cps"), maxCps); + attackStartCps = parseInt(props.getProperty("antibot.attack.start_cps"), attackStartCps); + attackStopCps = parseInt(props.getProperty("antibot.attack.stop_cps"), attackStopCps); + attackCalmSeconds = parseInt(props.getProperty("antibot.attack.stop_grace_seconds"), attackCalmSeconds); + ipConnectionsPerMinute = parseInt(props.getProperty("antibot.ip.max_connections_per_minute"), ipConnectionsPerMinute); + ipBlockSeconds = parseInt(props.getProperty("antibot.ip.block_seconds"), ipBlockSeconds); + kickMessage = props.getProperty("antibot.kick_message", kickMessage); + + vpnCheckEnabled = parseBoolean(props.getProperty("antibot.vpn_check.enabled"), vpnCheckEnabled); + vpnBlockProxy = parseBoolean(props.getProperty("antibot.vpn_check.block_proxy"), vpnBlockProxy); + vpnBlockHosting = parseBoolean(props.getProperty("antibot.vpn_check.block_hosting"), vpnBlockHosting); + vpnCacheMinutes = parseInt(props.getProperty("antibot.vpn_check.cache_minutes"), vpnCacheMinutes); + vpnTimeoutMs = parseInt(props.getProperty("antibot.vpn_check.timeout_ms"), vpnTimeoutMs); + securityLogEnabled = parseBoolean(props.getProperty("antibot.security_log.enabled"), securityLogEnabled); + securityLogFileName = props.getProperty("antibot.security_log.file", securityLogFileName).trim(); + if (securityLogFileName.isEmpty()) securityLogFileName = "antibot-security.log"; + + learningModeEnabled = parseBoolean(props.getProperty("antibot.learning.enabled"), learningModeEnabled); + learningScoreThreshold = parseInt(props.getProperty("antibot.learning.score_threshold"), learningScoreThreshold); + learningDecayPerSecond = parseInt(props.getProperty("antibot.learning.decay_per_second"), learningDecayPerSecond); + learningStateWindowSeconds = parseInt(props.getProperty("antibot.learning.state_window_seconds"), learningStateWindowSeconds); + learningRapidWindowMs = parseInt(props.getProperty("antibot.learning.rapid.window_ms"), learningRapidWindowMs); + learningRapidPoints = parseInt(props.getProperty("antibot.learning.rapid.points"), learningRapidPoints); + learningIpRateExceededPoints = parseInt(props.getProperty("antibot.learning.ip_rate_exceeded.points"), learningIpRateExceededPoints); + learningVpnProxyPoints = parseInt(props.getProperty("antibot.learning.vpn_proxy.points"), learningVpnProxyPoints); + learningVpnHostingPoints = parseInt(props.getProperty("antibot.learning.vpn_hosting.points"), learningVpnHostingPoints); + learningAttackModePoints = parseInt(props.getProperty("antibot.learning.attack_mode.points"), learningAttackModePoints); + learningHighCpsPoints = parseInt(props.getProperty("antibot.learning.high_cps.points"), learningHighCpsPoints); + learningRecentEventLimit = parseInt(props.getProperty("antibot.learning.recent_events.limit"), learningRecentEventLimit); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); + } + } + + private String normalizeProfile(String raw) { + if (raw == null) return "high-traffic"; + String v = raw.trim().toLowerCase(Locale.ROOT); + return "strict".equals(v) ? "strict" : "high-traffic"; + } + + private void applyProfileDefaults(String profileName) { + if ("strict".equals(profileName)) { + maxCps = 120; attackStartCps = 220; attackStopCps = 120; attackCalmSeconds = 20; + ipConnectionsPerMinute = 18; ipBlockSeconds = 900; + vpnCheckEnabled = true; vpnBlockProxy = true; vpnBlockHosting = true; + vpnCacheMinutes = 30; vpnTimeoutMs = 2500; + } else { + maxCps = 180; attackStartCps = 300; attackStopCps = 170; attackCalmSeconds = 25; + ipConnectionsPerMinute = 24; ipBlockSeconds = 600; + vpnCheckEnabled = false; vpnBlockProxy = true; vpnBlockHosting = true; + vpnCacheMinutes = 30; vpnTimeoutMs = 2500; + } + } + + private boolean isSupportedProfile(String raw) { + if (raw == null) return false; + String v = raw.trim().toLowerCase(Locale.ROOT); + return "strict".equals(v) || "high-traffic".equals(v); + } + + private boolean applyProfileAndPersist(String requestedProfile) { + if (!isSupportedProfile(requestedProfile)) return false; + String normalized = normalizeProfile(requestedProfile); + profile = normalized; + applyProfileDefaults(normalized); + + Map values = new LinkedHashMap<>(); + values.put("antibot.profile", normalized); + values.put("antibot.max_cps", String.valueOf(maxCps)); + values.put("antibot.attack.start_cps", String.valueOf(attackStartCps)); + values.put("antibot.attack.stop_cps", String.valueOf(attackStopCps)); + values.put("antibot.attack.stop_grace_seconds", String.valueOf(attackCalmSeconds)); + values.put("antibot.ip.max_connections_per_minute", String.valueOf(ipConnectionsPerMinute)); + values.put("antibot.ip.block_seconds", String.valueOf(ipBlockSeconds)); + values.put("antibot.vpn_check.enabled", String.valueOf(vpnCheckEnabled)); + values.put("antibot.vpn_check.block_proxy", String.valueOf(vpnBlockProxy)); + values.put("antibot.vpn_check.block_hosting", String.valueOf(vpnBlockHosting)); + values.put("antibot.vpn_check.cache_minutes", String.valueOf(vpnCacheMinutes)); + values.put("antibot.vpn_check.timeout_ms", String.valueOf(vpnTimeoutMs)); + try { updateConfigValues(values); return true; } + catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Konnte Profil nicht speichern: " + e.getMessage()); return false; } + } + + private synchronized void updateConfigValues(Map keyValues) throws Exception { + File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + List lines = target.exists() + ? Files.readAllLines(target.toPath(), StandardCharsets.UTF_8) + : new ArrayList<>(); + for (Map.Entry entry : keyValues.entrySet()) { + String key = entry.getKey(); + String newLine = key + "=" + entry.getValue(); + boolean replaced = false; + for (int i = 0; i < lines.size(); i++) { + if (lines.get(i).trim().startsWith(key + "=")) { + lines.set(i, newLine); + replaced = true; + break; + } + } + if (!replaced) lines.add(newLine); + } + Files.write(target.toPath(), lines, StandardCharsets.UTF_8); + } + + private void ensureModuleConfigExists() { + File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (target.exists()) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + try (InputStream in = plugin.getResourceAsStream(CONFIG_FILE_NAME); + FileOutputStream out = new FileOutputStream(target)) { + if (in == null) { plugin.getLogger().warning("[AntiBotModule] Standarddatei nicht im JAR."); return; } + byte[] buffer = new byte[4096]; int read; + while ((read = in.read(buffer)) != -1) out.write(buffer, 0, read); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Konnte Config nicht erstellen: " + e.getMessage()); + } + } + + private void evaluateLearningBaseline(String ip, long now) { + LearningProfile lp = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + synchronized (lp) { + decayLearningProfile(lp, now); + long delta = now - lp.lastConnectionAt; + if (lp.lastConnectionAt > 0 && delta <= Math.max(250L, learningRapidWindowMs)) { + lp.rapidStreak++; + int points = learningRapidPoints + Math.min(lp.rapidStreak, 5); + lp.score += Math.max(1, points); + recordLearningEvent("IP=" + ip + " +" + points + " rapid-connect score=" + lp.score); + } else { + lp.rapidStreak = 0; + } + if (attackMode) { lp.score += Math.max(1, learningAttackModePoints); recordLearningEvent("IP=" + ip + " +" + learningAttackModePoints + " attack-mode score=" + lp.score); } + if (lastCps >= Math.max(1, maxCps)) { lp.score += Math.max(1, learningHighCpsPoints); recordLearningEvent("IP=" + ip + " +" + learningHighCpsPoints + " high-cps score=" + lp.score); } + lp.lastConnectionAt = now; + lp.lastSeenAt = now; + if (lp.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=learning-threshold score=" + lp.score); + } + } + } + + private int addLearningScore(String ip, long now, int points, String reason, boolean checkThreshold) { + LearningProfile lp = learningProfiles.computeIfAbsent(ip, k -> new LearningProfile(now)); + synchronized (lp) { + decayLearningProfile(lp, now); + int add = Math.max(1, points); + lp.score += add; + lp.lastSeenAt = now; + recordLearningEvent("IP=" + ip + " +" + add + " " + reason + " score=" + lp.score); + if (checkThreshold && lp.score >= learningScoreThreshold) { + blockIp(ip, now); + recordLearningEvent("BLOCK " + ip + " reason=" + reason + " score=" + lp.score); + } + return lp.score; + } + } + + private int getLearningScore(String ip, long now) { + LearningProfile lp = learningProfiles.get(ip); + if (lp == null) return 0; + synchronized (lp) { decayLearningProfile(lp, now); return lp.score; } + } + + private void decayLearningProfile(LearningProfile lp, long now) { + long elapsedMs = Math.max(0L, now - lp.lastScoreUpdateAt); + if (elapsedMs > 0L) { + long decay = (elapsedMs / 1000L) * Math.max(0, learningDecayPerSecond); + if (decay > 0L) lp.score = (int) Math.max(0L, lp.score - decay); + lp.lastScoreUpdateAt = now; + } + long resetAfter = Math.max(30, learningStateWindowSeconds) * 1000L; + if (lp.lastSeenAt > 0L && now - lp.lastSeenAt > resetAfter) { + lp.score = 0; + lp.rapidStreak = 0; + } + } + + private void recordLearningEvent(String event) { + String line = new SimpleDateFormat("HH:mm:ss").format(new Date()) + " " + event; + synchronized (learningRecentEvents) { + learningRecentEvents.addLast(line); + while (learningRecentEvents.size() > Math.max(5, learningRecentEventLimit)) learningRecentEvents.pollFirst(); + } + } + + private void ensureSecurityLogFile() { + if (plugin == null) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + securityLogFile = new File(plugin.getDataFolder(), securityLogFileName); + try { if (!securityLogFile.exists()) securityLogFile.createNewFile(); } + catch (Exception e) { plugin.getLogger().warning("[AntiBotModule] Konnte Sicherheitslog nicht erstellen: " + e.getMessage()); } + } + + private void logSecurityEvent(String eventType, String ip, PendingConnection conn, String details) { + if (!securityLogEnabled || plugin == null) return; + if (securityLogFile == null) { ensureSecurityLogFile(); if (securityLogFile == null) return; } + + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + if ((name == null || name.isEmpty() || "unknown".equalsIgnoreCase(name)) && ip != null) { + RecentPlayerIdentity cached = recentIdentityByIp.get(ip); + if (cached != null) { + if (cached.playerName != null && !cached.playerName.trim().isEmpty()) name = cached.playerName; + if ((uuid == null || uuid.isEmpty()) && cached.playerUuid != null && !cached.playerUuid.trim().isEmpty()) uuid = cached.playerUuid; + } + } + if (name == null || name.trim().isEmpty()) name = "unknown"; + if (uuid == null || uuid.trim().isEmpty()) uuid = "unknown"; + + String line = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(new Date()) + + " | event=" + safeLog(eventType) + + " | ip=" + safeLog(ip) + + " | player=" + safeLog(name) + + " | uuid=" + safeLog(uuid) + + " | details=" + safeLog(details); + + synchronized (securityLogLock) { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(securityLogFile, true))) { + bw.write(line); bw.newLine(); + } catch (Exception e) { + plugin.getLogger().warning("[AntiBotModule] Sicherheitslog-Schreibfehler: " + e.getMessage()); + } + } + } + + private void cacheRecentIdentity(String ip, PendingConnection conn, long now) { + if (ip == null || ip.isEmpty() || conn == null) return; + String name = extractPlayerName(conn); + String uuid = extractPlayerUuid(conn, name); + if ((name == null || name.isEmpty()) && (uuid == null || uuid.isEmpty())) return; + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (name != null && !name.trim().isEmpty()) identity.playerName = name.trim(); + if (uuid != null && !uuid.trim().isEmpty()) identity.playerUuid = uuid.trim(); + identity.updatedAtMs = now; + } + } + + private void cacheRecentIdentityDirect(String ip, String playerName, UUID playerUuid, long now) { + if (ip == null || ip.isEmpty()) return; + String name = playerName == null ? "" : playerName.trim(); + String uuid = playerUuid == null ? "" : playerUuid.toString(); + if (name.isEmpty() && uuid.isEmpty()) return; + RecentPlayerIdentity identity = recentIdentityByIp.computeIfAbsent(ip, k -> new RecentPlayerIdentity()); + synchronized (identity) { + if (!name.isEmpty()) identity.playerName = name; + if (!uuid.isEmpty()) identity.playerUuid = uuid; + identity.updatedAtMs = now; + } + } + + private String extractPlayerName(PendingConnection conn) { + if (conn == null) return ""; + try { String raw = conn.getName(); return raw == null ? "" : raw.trim(); } catch (Exception ignored) { return ""; } + } + + private String extractPlayerUuid(PendingConnection conn, String playerName) { + if (conn != null) { + try { UUID uuid = conn.getUniqueId(); if (uuid != null) return uuid.toString(); } catch (Exception ignored) {} + } + if (playerName != null && !playerName.trim().isEmpty()) { + return UUID.nameUUIDFromBytes(("OfflinePlayer:" + playerName.trim()).getBytes(StandardCharsets.UTF_8)).toString(); + } + return ""; + } + + private String safeLog(String input) { + if (input == null || input.isEmpty()) return "-"; + return input.replace("\n", " ").replace("\r", " ").trim(); + } + + private List getRecentLearningEvents(int max) { + List out = new ArrayList<>(); + synchronized (learningRecentEvents) { + int skip = Math.max(0, learningRecentEvents.size() - Math.max(1, max)); + int idx = 0; + for (String line : learningRecentEvents) { + if (idx++ < skip) continue; + out.add(line); + } + } + return out; + } + + private boolean parseBoolean(String s, boolean fallback) { + if (s == null) return fallback; + return Boolean.parseBoolean(s.trim()); + } + + private int parseInt(String s, int fallback) { + try { return Integer.parseInt(s == null ? "" : s.trim()); } catch (Exception ignored) { return fallback; } + } + + private VpnCheckResult getVpnInfo(String ip, long now) { + VpnCacheEntry cached = vpnCache.get(ip); + if (cached != null && cached.expiresAt > now) return cached.result; + VpnCheckResult fresh = requestIpApi(ip); + if (fresh != null) { + VpnCacheEntry entry = new VpnCacheEntry(); + entry.result = fresh; + entry.expiresAt = now + Math.max(1, vpnCacheMinutes) * 60_000L; + vpnCache.put(ip, entry); + } + return fresh; + } + + private VpnCheckResult requestIpApi(String ip) { + HttpURLConnection conn = null; + try { + String url = "http://ip-api.com/json/" + ip + "?fields=status,proxy,hosting"; + conn = (HttpURLConnection) new URL(url).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(vpnTimeoutMs); + conn.setReadTimeout(vpnTimeoutMs); + conn.setRequestProperty("User-Agent", "StatusAPI-AntiBot/1.0"); + if (conn.getResponseCode() < 200 || conn.getResponseCode() >= 300) return null; + StringBuilder sb = new StringBuilder(); + try (BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8))) { + String line; while ((line = br.readLine()) != null) sb.append(line); + } + String json = sb.toString(); + if (json.isEmpty() || !json.contains("\"status\":\"success\"")) return null; + VpnCheckResult result = new VpnCheckResult(); + result.proxy = json.contains("\"proxy\":true"); + result.hosting = json.contains("\"hosting\":true"); + return result; + } catch (Exception ignored) { return null; } + finally { if (conn != null) conn.disconnect(); } + } + + // --- Interne Klassen --- + + private static class IpWindow { + long windowStart; int count; + IpWindow(long now) { this.windowStart = now; this.count = 0; } + } + + private static class VpnCacheEntry { VpnCheckResult result; long expiresAt; } + private static class VpnCheckResult { boolean proxy; boolean hosting; } + + private static class LearningProfile { + long lastConnectionAt, lastScoreUpdateAt, lastSeenAt; + int rapidStreak, score; + LearningProfile(long now) { lastConnectionAt = lastScoreUpdateAt = lastSeenAt = now; } + } + + private static class RecentPlayerIdentity { String playerName; String playerUuid; long updatedAtMs; } + + // --- Command --- + + private class AntiBotCommand extends Command { + AntiBotCommand() { super("antibot", "statusapi.antibot"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!enabled) { sender.sendMessage(ChatColor.RED + "AntiBotModule ist deaktiviert."); return; } + + if (args.length == 0 || "status".equalsIgnoreCase(args[0])) { + sender.sendMessage(ChatColor.GOLD + "----- AntiBot Status -----"); + sender.sendMessage(ChatColor.YELLOW + "Schutz aktiv: " + ChatColor.WHITE + enabled); + sender.sendMessage(ChatColor.YELLOW + "Profil: " + ChatColor.WHITE + profile); + if (attackMode) sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.RED + "AKTIV"); + else sender.sendMessage(ChatColor.YELLOW + "Attack Mode: " + ChatColor.GREEN + "Normal"); + sender.sendMessage(ChatColor.YELLOW + "CPS: " + ChatColor.WHITE + lastCps + ChatColor.GRAY + " (Peak " + peakCps.get() + ")"); + sender.sendMessage(ChatColor.YELLOW + "Schwellen: " + ChatColor.WHITE + "start " + attackStartCps + ChatColor.GRAY + " / " + ChatColor.WHITE + "stop " + attackStopCps + ChatColor.GRAY + " CPS"); + sender.sendMessage(ChatColor.YELLOW + "Active IP Blocks: " + ChatColor.WHITE + blockedIpsUntil.size()); + sender.sendMessage(ChatColor.YELLOW + "Total blocked connections: " + ChatColor.WHITE + blockedConnectionsTotal.get()); + sender.sendMessage(ChatColor.YELLOW + "VPN Check: " + ChatColor.WHITE + vpnCheckEnabled); + sender.sendMessage(ChatColor.YELLOW + "Learning Mode: " + ChatColor.WHITE + learningModeEnabled + + ChatColor.GRAY + " (threshold=" + learningScoreThreshold + ")"); + List recent = getRecentLearningEvents(3); + if (!recent.isEmpty()) { + sender.sendMessage(ChatColor.YELLOW + "Learning Events:"); + for (String line : recent) sender.sendMessage(ChatColor.GRAY + "- " + line); + } + return; + } + + if ("clearblocks".equalsIgnoreCase(args[0])) { + blockedIpsUntil.clear(); + sender.sendMessage(ChatColor.GREEN + "Alle IP-Blocks wurden entfernt."); + return; + } + + if ("unblock".equalsIgnoreCase(args[0]) && args.length >= 2) { + String ip = args[1].trim(); + if (blockedIpsUntil.remove(ip) != null) sender.sendMessage(ChatColor.GREEN + "IP entblockt: " + ip); + else sender.sendMessage(ChatColor.RED + "IP war nicht geblockt: " + ip); + return; + } + + if ("profile".equalsIgnoreCase(args[0])) { + if (args.length < 2) { + sender.sendMessage(ChatColor.YELLOW + "Aktuelles Profil: " + ChatColor.WHITE + profile); + sender.sendMessage(ChatColor.YELLOW + "Benutzung: /antibot profile "); + return; + } + String requested = args[1].trim().toLowerCase(Locale.ROOT); + if (!isSupportedProfile(requested)) { sender.sendMessage(ChatColor.RED + "Unbekanntes Profil. Erlaubt: strict, high-traffic"); return; } + boolean ok = applyProfileAndPersist(requested); + if (!ok) { sender.sendMessage(ChatColor.RED + "Profil konnte nicht gespeichert werden."); return; } + sender.sendMessage(ChatColor.GREEN + "AntiBot-Profil umgestellt auf: " + requested); + sender.sendMessage(ChatColor.GRAY + "Werte wurden in " + CONFIG_FILE_NAME + " gespeichert."); + return; + } + + if ("reload".equalsIgnoreCase(args[0])) { + reloadRuntimeState(); + sender.sendMessage(ChatColor.GREEN + "AntiBot-Konfiguration neu geladen."); + sender.sendMessage(ChatColor.GRAY + "Aktives Profil: " + profile); + return; + } + + sender.sendMessage(ChatColor.YELLOW + "/antibot status"); + sender.sendMessage(ChatColor.YELLOW + "/antibot clearblocks"); + sender.sendMessage(ChatColor.YELLOW + "/antibot unblock "); + sender.sendMessage(ChatColor.YELLOW + "/antibot profile "); + sender.sendMessage(ChatColor.YELLOW + "/antibot reload"); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java b/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java new file mode 100644 index 0000000..1ff0112 --- /dev/null +++ b/src/main/java/net/viper/status/modules/broadcast/BroadcastModule.java @@ -0,0 +1,386 @@ +package net.viper.status.modules.broadcast; + +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ChatColor; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.BaseComponent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ClickEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.TextComponent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Listener; +import net.viper.status.module.Module; + +import java.io.*; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +/** + * BroadcastModule + * + * Fixes: + * - loadSchedules(): ID-Split nutzt jetzt indexOf/lastIndexOf statt split("\\.") mit length==2-Check. + * Damit werden auch clientScheduleIds die Punkte enthalten korrekt geladen. (Bug #2) + * - handleBroadcast(): &-Farbcodes werden jetzt auch in der Nachricht selbst übersetzt. (Bug #3) + * - handleBroadcast(): Literal \n in der Nachricht wird als echter Zeilenumbruch gerendert. (Bug #4) + * - handleBroadcast(): URLs (http/https) werden als anklickbare TextComponents eingebettet. (Bug #5) + */ +public class BroadcastModule implements Module, Listener { + + private Plugin plugin; + private boolean enabled = true; + private String requiredApiKey = ""; + private String format = "%prefix% %message%"; + private String fallbackPrefix = "[Broadcast]"; + private String fallbackPrefixColor = "&c"; + private String fallbackBracketColor = "&8"; + private String fallbackMessageColor = "&f"; + + private final Map scheduledByClientId = new ConcurrentHashMap<>(); + private File schedulesFile; + private final SimpleDateFormat dateFormat; + + public BroadcastModule() { + dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss z"); + dateFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + } + + @Override + public String getName() { return "BroadcastModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + schedulesFile = new File(plugin.getDataFolder(), "broadcasts.schedules"); + loadConfig(); + if (!enabled) return; + try { plugin.getProxy().getPluginManager().registerListener(plugin, this); } catch (Throwable ignored) {} + plugin.getLogger().fine("[BroadcastModule] aktiviert. Format: " + format); + loadSchedules(); + plugin.getProxy().getScheduler().schedule(plugin, this::processScheduled, 1, 1, TimeUnit.SECONDS); + } + + @Override + public void onDisable(Plugin plugin) { + saveSchedules(); + scheduledByClientId.clear(); + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), "verify.properties"); + if (!file.exists()) return; + try (InputStream in = new FileInputStream(file)) { + Properties props = new Properties(); + props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + enabled = Boolean.parseBoolean(props.getProperty("broadcast.enabled", "true")); + requiredApiKey = props.getProperty("broadcast.api_key", "").trim(); + format = props.getProperty("broadcast.format", format).trim(); + if (format.isEmpty()) format = "%prefix% %message%"; + fallbackPrefix = props.getProperty("broadcast.prefix", fallbackPrefix).trim(); + fallbackPrefixColor = props.getProperty("broadcast.prefix-color", fallbackPrefixColor).trim(); + fallbackBracketColor = props.getProperty("broadcast.bracket-color", fallbackBracketColor).trim(); + fallbackMessageColor = props.getProperty("broadcast.message-color", fallbackMessageColor).trim(); + } catch (IOException e) { + plugin.getLogger().warning("[BroadcastModule] Fehler beim Laden von verify.properties: " + e.getMessage()); + } + } + + public boolean handleBroadcast(String sourceName, String message, String type, String apiKeyHeader, + String prefix, String prefixColor, String bracketColor, String messageColor) { + loadConfig(); + if (!enabled) return false; + if (requiredApiKey != null && !requiredApiKey.isEmpty()) { + if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + plugin.getLogger().warning("[BroadcastModule] Broadcast abgelehnt: API-Key fehlt oder inkorrekt."); + return false; + } + } + + if (message == null) message = ""; + if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; + if (type == null) type = "global"; + + String usedPrefix = (prefix != null && !prefix.trim().isEmpty()) ? prefix.trim() : fallbackPrefix; + String usedPrefixColor = (prefixColor != null && !prefixColor.trim().isEmpty()) ? prefixColor.trim() : fallbackPrefixColor; + String usedBracketColor = (bracketColor != null && !bracketColor.trim().isEmpty()) ? bracketColor.trim() : fallbackBracketColor; + String usedMessageColor = (messageColor != null && !messageColor.trim().isEmpty()) ? messageColor.trim() : fallbackMessageColor; + + String prefixColorCode = normalizeColorCode(usedPrefixColor); + String bracketColorCode = normalizeColorCode(usedBracketColor); + String messageColorCode = normalizeColorCode(usedMessageColor); + + String finalPrefix; + if (!bracketColorCode.isEmpty()) { + String textContent = usedPrefix; + if (textContent.startsWith("[")) textContent = textContent.substring(1); + if (textContent.endsWith("]")) textContent = textContent.substring(0, textContent.length() - 1); + finalPrefix = bracketColorCode + "[" + prefixColorCode + textContent + bracketColorCode + "]" + ChatColor.RESET; + } else { + finalPrefix = prefixColorCode + usedPrefix + ChatColor.RESET; + } + + // FIX #1: &-Farbcodes auch in der Nachricht selbst übersetzen + String translatedMessage = ChatColor.translateAlternateColorCodes('&', message); + String coloredMessage = (messageColorCode.isEmpty() ? "" : messageColorCode) + translatedMessage; + + String out = format + .replace("%name%", sourceName) + .replace("%prefix%", finalPrefix) + .replace("%prefixColored%", finalPrefix) + .replace("%message%", translatedMessage) + .replace("%messageColored%",coloredMessage) + .replace("%type%", type); + + // FIX #2: \r entfernen (Windows CRLF -> nur LF), Literal \\n als Fallback + out = out.replace("\r\n", "\n").replace("\r", "").replace("\\n", "\n"); + + // FIX #3: Nachricht mit anklickbaren URLs aufbauen + BaseComponent[] components = buildClickableComponents(out); + int sent = 0; + for (ProxiedPlayer p : plugin.getProxy().getPlayers()) { + try { p.sendMessage(components); sent++; } catch (Throwable ignored) {} + } + StatusAPI.debugLog(plugin, "[BroadcastModule] Broadcast gesendet (Empfänger=" + sent + "): " + message); + return true; + } + + /** + * Baut ein BaseComponent-Array aus einem formatierten String. + * URLs (http/https) werden als anklickbare TextComponents eingebettet. + * Unterstützt auch echte Newlines (\n) als Zeilenumbruch. + */ + private BaseComponent[] buildClickableComponents(String text) { + // Regex für URLs + java.util.regex.Pattern urlPattern = java.util.regex.Pattern.compile( + "(https?://[^\\s\\n]+)", java.util.regex.Pattern.CASE_INSENSITIVE); + + ComponentBuilder builder = new ComponentBuilder(""); + + // Zeilenweise aufteilen (echte \n) + String[] lines = text.split("\n", -1); + for (int li = 0; li < lines.length; li++) { + if (li > 0) { + // Zeilenumbruch als eigene Komponente + builder.append(TextComponent.fromLegacyText("\n")); + } + + String line = lines[li]; + java.util.regex.Matcher matcher = urlPattern.matcher(line); + int lastEnd = 0; + + while (matcher.find()) { + // Text vor der URL (mit Minecraft-Farbcodes) + if (matcher.start() > lastEnd) { + String before = line.substring(lastEnd, matcher.start()); + builder.append(TextComponent.fromLegacyText(before)); + } + + // URL selbst: anklickbar + unterstrichen + String url = matcher.group(1); + TextComponent urlComponent = new TextComponent(url); + urlComponent.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, url)); + // Farbe der URL auf Cyan setzen damit sie sich abhebt + urlComponent.setColor(ChatColor.AQUA); + urlComponent.setUnderlined(true); + builder.append(urlComponent, ComponentBuilder.FormatRetention.NONE); + + lastEnd = matcher.end(); + } + + // Restlicher Text nach der letzten URL + if (lastEnd < line.length()) { + builder.append(TextComponent.fromLegacyText(line.substring(lastEnd))); + } + } + + return builder.create(); + } + + private String normalizeColorCode(String code) { + if (code == null) return ""; + code = code.trim(); + if (code.isEmpty()) return ""; + return code.contains("&") ? ChatColor.translateAlternateColorCodes('&', code) : code; + } + + private void saveSchedules() { + Properties props = new Properties(); + for (Map.Entry entry : scheduledByClientId.entrySet()) { + String id = entry.getKey(); + ScheduledBroadcast sb = entry.getValue(); + // Wir escapen den ID-Wert damit Punkte in der ID nicht den Parser verwirren + props.setProperty(id + ".nextRunMillis", String.valueOf(sb.nextRunMillis)); + props.setProperty(id + ".sourceName", sb.sourceName); + props.setProperty(id + ".message", sb.message); + props.setProperty(id + ".type", sb.type); + props.setProperty(id + ".prefix", sb.prefix); + props.setProperty(id + ".prefixColor", sb.prefixColor); + props.setProperty(id + ".bracketColor", sb.bracketColor); + props.setProperty(id + ".messageColor", sb.messageColor); + props.setProperty(id + ".recur", sb.recur); + } + try (OutputStream out = new FileOutputStream(schedulesFile)) { + props.store(out, "PulseCast Scheduled Broadcasts"); + } catch (IOException e) { + plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht speichern: " + e.getMessage()); + } + } + + /** + * FIX #2: Robusteres Parsen der Property-Keys. + * Statt split("\\.") mit length==2-Check nutzen wir lastIndexOf('.') um den letzten + * Punkt als Trenner zu verwenden. Damit funktionieren auch IDs die Punkte enthalten. + * + * Bekannte Felder: nextRunMillis, sourceName, message, type, prefix, prefixColor, + * bracketColor, messageColor, recur → alle ohne Punkte im Namen. + */ + private void loadSchedules() { + if (!schedulesFile.exists()) return; + Properties props = new Properties(); + try (InputStream in = new FileInputStream(schedulesFile)) { + props.load(in); + } catch (IOException e) { + plugin.getLogger().severe("[BroadcastModule] Konnte Schedules nicht laden: " + e.getMessage()); + return; + } + + // Bekannte Feld-Suffixe + Set knownFields = new HashSet<>(Arrays.asList( + "nextRunMillis", "sourceName", "message", "type", + "prefix", "prefixColor", "bracketColor", "messageColor", "recur" + )); + + Map loaded = new LinkedHashMap<>(); + for (String key : props.stringPropertyNames()) { + // Finde das letzte '.' das einen bekannten Feldnamen abtrennt + int lastDot = key.lastIndexOf('.'); + if (lastDot < 0) continue; + String field = key.substring(lastDot + 1); + if (!knownFields.contains(field)) continue; + String id = key.substring(0, lastDot); + if (id.isEmpty()) continue; + String value = props.getProperty(key); + + ScheduledBroadcast sb = loaded.computeIfAbsent(id, + k -> new ScheduledBroadcast(k, 0, "", "", "", "", "", "", "", "")); + + switch (field) { + case "nextRunMillis": try { sb.nextRunMillis = Long.parseLong(value); } catch (NumberFormatException ignored) {} break; + case "sourceName": sb.sourceName = value; break; + case "message": sb.message = value; break; + case "type": sb.type = value; break; + case "prefix": sb.prefix = value; break; + case "prefixColor": sb.prefixColor = value; break; + case "bracketColor": sb.bracketColor = value; break; + case "messageColor": sb.messageColor = value; break; + case "recur": sb.recur = value; break; + } + } + scheduledByClientId.putAll(loaded); + plugin.getLogger().fine("[BroadcastModule] geplante Broadcasts wiederhergestellt."); + } + + public boolean scheduleBroadcast(long timestampMillis, String sourceName, String message, String type, + String apiKeyHeader, String prefix, String prefixColor, String bracketColor, + String messageColor, String recur, String clientScheduleId) { + loadConfig(); + if (!enabled) return false; + if (requiredApiKey != null && !requiredApiKey.isEmpty()) { + if (apiKeyHeader == null || !requiredApiKey.equals(apiKeyHeader)) { + plugin.getLogger().warning("[BroadcastModule] schedule abgelehnt: API-Key fehlt oder inkorrekt."); + return false; + } + } + if (message == null) message = ""; + if (sourceName == null || sourceName.isEmpty()) sourceName = "System"; + if (type == null) type = "global"; + if (recur == null) recur = "none"; + + String id = (clientScheduleId != null && !clientScheduleId.trim().isEmpty()) + ? clientScheduleId.trim() : UUID.randomUUID().toString(); + + long now = System.currentTimeMillis(); + if (timestampMillis <= now) { + plugin.getLogger().warning("[BroadcastModule] Geplante Zeit in der Vergangenheit → sende sofort!"); + return handleBroadcast(sourceName, message, type, apiKeyHeader, prefix, prefixColor, bracketColor, messageColor); + } + + ScheduledBroadcast sb = new ScheduledBroadcast(id, timestampMillis, sourceName, message, type, + prefix, prefixColor, bracketColor, messageColor, recur); + scheduledByClientId.put(id, sb); + saveSchedules(); + StatusAPI.debugLog(plugin, "[BroadcastModule] Neue geplante Nachricht registriert: " + id + + " @ " + dateFormat.format(new Date(timestampMillis))); + return true; + } + + public boolean cancelScheduled(String clientScheduleId) { + if (clientScheduleId == null || clientScheduleId.trim().isEmpty()) return false; + ScheduledBroadcast removed = scheduledByClientId.remove(clientScheduleId); + if (removed != null) { StatusAPI.debugLog(plugin, "[BroadcastModule] Schedule abgebrochen: " + clientScheduleId); saveSchedules(); return true; } + return false; + } + + private void processScheduled() { + if (scheduledByClientId.isEmpty()) return; + long now = System.currentTimeMillis(); + List toRemove = new ArrayList<>(); + boolean changed = false; + + for (Map.Entry entry : scheduledByClientId.entrySet()) { + ScheduledBroadcast sb = entry.getValue(); + if (sb.nextRunMillis <= now) { + StatusAPI.debugLog(plugin, "[BroadcastModule] ⏰ Sende geplante Nachricht (ID: " + entry.getKey() + ")"); + handleBroadcast(sb.sourceName, sb.message, sb.type, "", sb.prefix, sb.prefixColor, sb.bracketColor, sb.messageColor); + if (!"none".equalsIgnoreCase(sb.recur)) { + long next = computeNextMillis(sb.nextRunMillis, sb.recur); + if (next > 0) { sb.nextRunMillis = next; changed = true; } + else { toRemove.add(entry.getKey()); changed = true; } + } else { toRemove.add(entry.getKey()); changed = true; } + } + } + if (changed || !toRemove.isEmpty()) { + for (String k : toRemove) { scheduledByClientId.remove(k); } + saveSchedules(); + } + } + + private long computeNextMillis(long currentMillis, String recur) { + switch (recur.toLowerCase(Locale.ROOT)) { + case "hourly": return currentMillis + TimeUnit.HOURS.toMillis(1); + case "daily": return currentMillis + TimeUnit.DAYS.toMillis(1); + case "weekly": return currentMillis + TimeUnit.DAYS.toMillis(7); + default: return -1L; + } + } + + private static class ScheduledBroadcast { + final String clientId; + long nextRunMillis; + String sourceName, message, type, prefix, prefixColor, bracketColor, messageColor, recur; + + ScheduledBroadcast(String clientId, long nextRunMillis, String sourceName, String message, String type, + String prefix, String prefixColor, String bracketColor, String messageColor, String recur) { + this.clientId = clientId; + this.nextRunMillis = nextRunMillis; + this.sourceName = sourceName; + this.message = message; + this.type = type; + this.prefix = prefix; + this.prefixColor = prefixColor; + this.bracketColor = bracketColor; + this.messageColor = messageColor; + this.recur = recur == null ? "none" : recur; + } + } +} \ No newline at end of file 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..7b92fc7 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/AccountLinkManager.java @@ -0,0 +1,318 @@ +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()); + } + } + + 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..5639480 --- /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..0893c68 --- /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..aec5131 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatConfig.java @@ -0,0 +1,496 @@ +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. + * + * Fix #8: Rate-Limit-Werte aus anti-spam werden nicht mehr durch nachfolgende + * Berechnungen überschrieben. Der rate-limit.chat-Block hat jetzt Vorrang. + * Die Reihenfolge ist: erst rate-limit.chat einlesen, dann ggf. durch anti-spam + * als Fallback ergänzen, nicht umgekehrt. + */ +public class ChatConfig { + + private final Plugin plugin; + private Configuration config; + + private final Map channels = new LinkedHashMap<>(); + private String defaultChannel; + + private String helpopFormat, helpopPermission, helpopConfirm, helpopDiscordWebhook, helpopTelegramChatId; + private int helpopCooldown; + + private String broadcastFormat, broadcastPermission; + + private boolean pmEnabled; + private String pmFormatSender, pmFormatReceiver, pmFormatSpy, pmSpyPermission, pmRateLimitMessage; + private boolean pmRateLimitEnabled; + private long pmRateLimitWindowMs; + private int pmRateLimitMaxActions; + private long pmRateLimitBlockMs; + + private int defaultMuteDuration; + private String mutedMessage; + + private boolean emojiEnabled, emojiBedrockSupport; + private final Map emojiMappings = new LinkedHashMap<>(); + + private boolean discordEnabled; + private String discordBotToken, discordGuildId, discordFromFormat, discordAdminChannelId, discordEmbedColor; + private int discordPollInterval; + + private boolean telegramEnabled; + private String telegramBotToken, telegramFromFormat, telegramAdminChatId; + private int telegramPollInterval, telegramChatTopicId, telegramAdminTopicId; + + private boolean linkingEnabled; + private String linkDiscordMessage, linkTelegramMessage, linkSuccessDiscord, linkSuccessTelegram; + private String linkBotSuccessDiscord, linkBotSuccessTelegram, linkedDiscordFormat, linkedTelegramFormat; + private int telegramAdminThreadId; + + private String adminBypassPermission, adminNotifyPermission; + + private boolean chatlogEnabled; + private int chatlogRetentionDays; + + private final Map serverColors = new LinkedHashMap<>(); + private final Map serverDisplayNames = new LinkedHashMap<>(); + private String serverColorDefault; + + private boolean reportsEnabled, reportWebhookEnabled; + private String reportConfirm, reportPermission, reportClosePermission, reportViewPermission; + private String reportDiscordWebhook, reportTelegramChatId; + private int reportCooldown; + + private ChatFilter.ChatFilterConfig filterConfig = new ChatFilter.ChatFilterConfig(); + + private boolean mentionsEnabled, mentionsAllowToggle; + private String mentionsHighlightColor, mentionsSound, mentionsNotifyPrefix; + + private int historyMaxLines, historyDefaultLines; + + private boolean joinLeaveEnabled, vanishShowToAdmins; + private String joinFormat, leaveFormat, vanishJoinFormat, vanishLeaveFormat; + private String joinLeaveDiscordWebhook, joinLeaveTelegramChatId; + private int joinLeaveTelegramThreadId; + + 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, 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().fine("[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) + )); + } + } + 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", ""); + } else { + helpopFormat = "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}"; + helpopPermission = "chat.helpop.receive"; helpopCooldown = 30; + helpopConfirm = "&aHilferuf gesendet!"; helpopDiscordWebhook = ""; helpopTelegramChatId = ""; + } + + // --- Broadcast --- + Configuration bc = config.getSection("broadcast"); + broadcastFormat = bc != null ? bc.getString("format", "&c[&6Broadcast&c] &e{message}") : "&c[&6Broadcast&c] &e{message}"; + broadcastPermission = bc != null ? bc.getString("permission", "chat.broadcast") : "chat.broadcast"; + + // --- Private Messages --- + Configuration pm = config.getSection("private-messages"); + pmEnabled = pm == null || pm.getBoolean("enabled", true); + pmFormatSender = pm != null ? pm.getString("format-sender", "&8[&7Du &8→ &b{player}&8] &f{message}") : "&8[&7Du &8→ &b{player}&8] &f{message}"; + pmFormatReceiver = pm != null ? pm.getString("format-receiver", "&8[&b{player} &8→ &7Dir&8] &f{message}") : "&8[&b{player} &8→ &7Dir&8] &f{message}"; + pmFormatSpy = pm != null ? pm.getString("format-social-spy","&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}") : "&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}"; + pmSpyPermission = pm != null ? pm.getString("social-spy-permission", "chat.socialspy") : "chat.socialspy"; + + // --- Mute --- + Configuration mu = config.getSection("mute"); + defaultMuteDuration = mu != null ? mu.getInt("default-duration-minutes", 60) : 60; + mutedMessage = mu != null ? mu.getString("muted-message", "&cDu bist stummgeschaltet. Noch: &f{time}") : "&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)); } + } else { emojiEnabled = true; emojiBedrockSupport = true; } + + // --- 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"); + } else { discordEnabled = false; discordBotToken = ""; discordGuildId = ""; discordPollInterval = 3; discordFromFormat = "&9[Discord] &b{user}&8: &f{message}"; discordAdminChannelId = ""; discordEmbedColor = "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); + } else { telegramEnabled = false; telegramBotToken = ""; telegramPollInterval = 3; telegramFromFormat = "&3[Telegram] &b{user}&8: &f{message}"; telegramAdminChatId = ""; telegramChatTopicId = 0; telegramAdminTopicId = 0; } + + // --- Account-Linking --- + Configuration al = config.getSection("account-linking"); + linkingEnabled = al == null || al.getBoolean("enabled", true); + linkDiscordMessage = al != null ? al.getString("discord-link-message", "&aCode: &f{token}") : "&aCode: &f{token}"; + linkTelegramMessage = al != null ? al.getString("telegram-link-message", "&aCode: &f{token}") : "&aCode: &f{token}"; + linkSuccessDiscord = al != null ? al.getString("success-discord", "&aDiscord verknüpft!") : "&aDiscord verknüpft!"; + linkSuccessTelegram = al != null ? al.getString("success-telegram", "&aTelegram verknüpft!") : "&aTelegram verknüpft!"; + linkBotSuccessDiscord = al != null ? al.getString("bot-success-discord", "✅ Verknüpft: {player}") : "✅ Verknüpft: {player}"; + linkBotSuccessTelegram = al != null ? al.getString("bot-success-telegram", "✅ Verknüpft: {player}") : "✅ Verknüpft: {player}"; + linkedDiscordFormat = al != null ? al.getString("linked-discord-format", "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}") : "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}"; + linkedTelegramFormat = al != null ? al.getString("linked-telegram-format", "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}") : "&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!"); + // FIX #8: Fallback-Werte aus anti-spam werden NUR gesetzt wenn rate-limit.chat nicht + // konfiguriert ist. Wir setzen die Werte hier als Vorbelegung und überschreiben sie + // unten mit dem rate-limit.chat-Block wenn vorhanden. + filterConfig.globalRateLimitWindowMs = Math.max(500L, filterConfig.spamCooldownMs); + filterConfig.globalRateLimitMaxActions = Math.max(1, filterConfig.spamMaxMessages); + filterConfig.globalRateLimitBlockMs = Math.max(2000L, filterConfig.spamCooldownMs * 4L); + } + 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(); + loadFilterWords(filterConfig.blacklistWords); + try { + java.util.List wordList = bl.getList("words"); + if (wordList != null) { + for (Object o : wordList) { + if (o != null && !o.toString().trim().isEmpty()) { + String w = o.toString().trim(); + if (!filterConfig.blacklistWords.contains(w)) filterConfig.blacklistWords.add(w); + } + } + } + } 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); + } + Configuration antiAd = cf.getSection("anti-ad"); + if (antiAd != null) { + filterConfig.antiAdEnabled = antiAd.getBoolean("enabled", true); + filterConfig.antiAdMessage = antiAd.getString("message", "&cWerbung ist nicht erlaubt!"); + java.util.List wl = antiAd.getList("whitelist"); + if (wl != null) { filterConfig.antiAdWhitelist.clear(); for (Object o : wl) if (o != null) filterConfig.antiAdWhitelist.add(o.toString()); } + java.util.List tlds = antiAd.getList("blocked-tlds"); + if (tlds != null) { filterConfig.antiAdBlockedTlds.clear(); for (Object o : tlds) if (o != null) filterConfig.antiAdBlockedTlds.add(o.toString()); } + } + } + + // --- Rate-Limit (FIX #8: dieser Block setzt die endgültigen Werte, hat Vorrang) --- + pmRateLimitEnabled = true; + pmRateLimitWindowMs = 5000L; + pmRateLimitMaxActions = 4; + pmRateLimitBlockMs = 10000L; + pmRateLimitMessage = "&cDu sendest zu viele private Nachrichten. Bitte warte kurz."; + + Configuration rl = config.getSection("rate-limit"); + if (rl != null) { + Configuration rlChat = rl.getSection("chat"); + if (rlChat != null) { + // FIX #8: rate-limit.chat überschreibt die anti-spam-Fallbacks vollständig + filterConfig.globalRateLimitEnabled = rlChat.getBoolean("enabled", true); + filterConfig.globalRateLimitWindowMs = rlChat.getLong("window-ms", filterConfig.globalRateLimitWindowMs); + filterConfig.globalRateLimitMaxActions = rlChat.getInt("max-actions", filterConfig.globalRateLimitMaxActions); + filterConfig.globalRateLimitBlockMs = rlChat.getLong("block-ms", filterConfig.globalRateLimitBlockMs); + filterConfig.spamMessage = rlChat.getString("message", filterConfig.spamMessage); + } + Configuration rlPm = rl.getSection("private-messages"); + if (rlPm != null) { + pmRateLimitEnabled = rlPm.getBoolean("enabled", pmRateLimitEnabled); + pmRateLimitWindowMs = rlPm.getLong("window-ms", pmRateLimitWindowMs); + pmRateLimitMaxActions = rlPm.getInt("max-actions", pmRateLimitMaxActions); + pmRateLimitBlockMs = rlPm.getLong("block-ms", pmRateLimitBlockMs); + pmRateLimitMessage = rlPm.getString("message", pmRateLimitMessage); + } + } + + // --- Mentions --- + Configuration mn = config.getSection("mentions"); + mentionsEnabled = mn == null || mn.getBoolean("enabled", true); + mentionsHighlightColor = mn != null ? mn.getString("highlight-color", "&e&l") : "&e&l"; + mentionsSound = mn != null ? mn.getString("sound", "ENTITY_EXPERIENCE_ORB_PICKUP") : "ENTITY_EXPERIENCE_ORB_PICKUP"; + mentionsAllowToggle = mn == null || mn.getBoolean("allow-toggle", true); + mentionsNotifyPrefix = mn != null ? mn.getString("notify-prefix", "&e&l[Mention] &r") : "&e&l[Mention] &r"; + + // --- Chat-History --- + Configuration ch = config.getSection("chat-history"); + historyMaxLines = ch != null ? ch.getInt("max-lines", 50) : 50; + historyDefaultLines = ch != null ? ch.getInt("default-lines", 10) : 10; + + // --- Admin --- + Configuration adm = config.getSection("admin"); + adminBypassPermission = adm != null ? adm.getString("bypass-permission", "chat.admin.bypass") : "chat.admin.bypass"; + adminNotifyPermission = adm != null ? adm.getString("notify-permission", "chat.admin.notify") : "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; + 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 { + serverColors.put(key.toLowerCase(), sc.getString(key, "&7")); + } + } + } else { serverColorDefault = "&7"; } + + // --- Chatlog --- + Configuration cl = config.getSection("chatlog"); + chatlogEnabled = cl == null || cl.getBoolean("enabled", true); + int raw = cl != null ? cl.getInt("retention-days", 7) : 7; + chatlogRetentionDays = (raw == 14) ? 14 : 7; + + // --- Reports --- + 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({id}) &awurde eingereicht. Danke!"); + reportPermission = rp.getString("report-permission", ""); + 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 = ""; + } + + // --- Join / Leave --- + Configuration jl = config.getSection("join-leave"); + if (jl != null) { + joinLeaveEnabled = jl.getBoolean("enabled", true); + joinFormat = jl.getString("join-format", "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten."); + leaveFormat = jl.getString("leave-format", "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen."); + vanishShowToAdmins = jl.getBoolean("vanish-show-to-admins", true); + vanishJoinFormat = jl.getString("vanish-join-format", "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)"); + vanishLeaveFormat = jl.getString("vanish-leave-format", "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)"); + joinLeaveDiscordWebhook = jl.getString("discord-webhook", ""); + joinLeaveTelegramChatId = jl.getString("telegram-chat-id", ""); + joinLeaveTelegramThreadId = jl.getInt("telegram-thread-id", 0); + } else { + joinLeaveEnabled = true; + joinFormat = "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten."; + leaveFormat = "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen."; + vanishShowToAdmins = true; + vanishJoinFormat = "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)"; + vanishLeaveFormat = "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)"; + joinLeaveDiscordWebhook = ""; joinLeaveTelegramChatId = ""; joinLeaveTelegramThreadId = 0; + } + } + + private void loadFilterWords(java.util.List target) { + File filterFile = new File(plugin.getDataFolder(), "filter.yml"); + if (!filterFile.exists()) { + try { + plugin.getDataFolder().mkdirs(); + try (java.io.FileWriter fw = new java.io.FileWriter(filterFile)) { + fw.write("# StatusAPI - Wort-Blacklist\n# words:\n# - beispielwort\nwords:\n"); + } + plugin.getLogger().fine("[ChatModule] filter.yml erstellt."); + } catch (IOException e) { plugin.getLogger().warning("[ChatModule] Konnte filter.yml nicht erstellen: " + e.getMessage()); } + return; + } + try { + Configuration fc = ConfigurationProvider.getProvider(YamlConfiguration.class).load(filterFile); + java.util.List words = fc.getList("words"); + if (words != null) { + for (Object o : words) { + if (o != null && !o.toString().trim().isEmpty()) target.add(o.toString().trim().toLowerCase()); + } + } + } catch (Exception e) { plugin.getLogger().warning("[ChatModule] Fehler beim Laden der filter.yml: " + e.getMessage()); } + } + + // ===== Getter ===== + + 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 boolean isPmRateLimitEnabled() { return pmRateLimitEnabled; } + public long getPmRateLimitWindowMs() { return pmRateLimitWindowMs; } + public int getPmRateLimitMaxActions() { return pmRateLimitMaxActions; } + public long getPmRateLimitBlockMs() { return pmRateLimitBlockMs; } + public String getPmRateLimitMessage() { return pmRateLimitMessage; } + 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; } + 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; } + public String getServerColor(String serverName) { if (serverName == null) return serverColorDefault; String c = serverColors.get(serverName.toLowerCase()); return c != null ? c : serverColorDefault; } + public Map getServerColors() { return Collections.unmodifiableMap(serverColors); } + public String getServerColorDefault() { return serverColorDefault; } + public String getServerDisplay(String serverName) { if (serverName == null) return ""; String d = serverDisplayNames.get(serverName.toLowerCase()); return d != null ? d : serverName; } + public boolean isChatlogEnabled() { return chatlogEnabled; } + public int getChatlogRetentionDays() { return chatlogRetentionDays; } + 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; } + public ChatFilter.ChatFilterConfig getFilterConfig() { return filterConfig; } + 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; } + public int getHistoryMaxLines() { return historyMaxLines; } + public int getHistoryDefaultLines() { return historyDefaultLines; } + public boolean isJoinLeaveEnabled() { return joinLeaveEnabled; } + public String getJoinFormat() { return joinFormat; } + public String getLeaveFormat() { return leaveFormat; } + public boolean isVanishShowToAdmins() { return vanishShowToAdmins; } + public String getVanishJoinFormat() { return vanishJoinFormat; } + public String getVanishLeaveFormat() { return vanishLeaveFormat; } + public String getJoinLeaveDiscordWebhook() { return joinLeaveDiscordWebhook; } + public String getJoinLeaveTelegramChatId() { return joinLeaveTelegramChatId; } + public int getJoinLeaveTelegramThreadId() { return joinLeaveTelegramThreadId; } +} 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..b110f0a --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatFilter.java @@ -0,0 +1,331 @@ +package net.viper.status.modules.chat; + +import net.viper.status.ratelimit.GlobalRateLimitFramework; + +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; + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); + + // UUID → letzte Nachricht (für Duplikat-Check) + private final Map lastMessageText = 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) { + if (cfg.globalRateLimitEnabled) { + GlobalRateLimitFramework.Result rl = rateLimiter.check( + "chat.message", + uuid.toString(), + new GlobalRateLimitFramework.Rule( + true, + cfg.globalRateLimitWindowMs, + cfg.globalRateLimitMaxActions, + cfg.globalRateLimitBlockMs + ) + ); + if (rl.isBlocked()) { + return new FilterResponse(FilterResult.BLOCKED, message, cfg.spamMessage); + } + } + } + + // ── 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; + } + } + + // ── 6. Anti-Werbung ── + if (cfg.antiAdEnabled && !isAdmin) { + if (containsAdvertisement(result)) { + return new FilterResponse(FilterResult.BLOCKED, result, cfg.antiAdMessage); + } + } + + 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 (i zeigt auf &, +1 = #, +2..+7 = RRGGBB) + if (isHex && i + 7 <= message.length()) { i += 7; continue; } + } + sb.append(c); + } + return sb.toString(); + } + + // ===== Anti-Werbung ===== + + // Vorkompilierte Patterns (einmalig beim Classload) + private static final Pattern PATTERN_IP = + Pattern.compile("\\b(\\d{1,3}[.,]){3}\\d{1,3}(:\\d{1,5})?\\b"); + + private static final Pattern PATTERN_DOMAIN_GENERIC = + Pattern.compile("(?i)\\b[a-z0-9-]{2,63}\\.[a-z]{2,10}(?:[/:\\d]\\S*)?\\b"); + + private static final Pattern PATTERN_URL_PREFIX = + Pattern.compile("(?i)(https?://|www\\.)\\S+"); + + /** + * Prüft ob die Nachricht Werbung enthält (IP, URL, fremde Domain). + * Domains auf der Whitelist werden ignoriert. + * + * Erkennt: + * - http:// / https:// / www. Prefixe + * - IPv4-Adressen (auch mit Port) + * - Domain-Namen mit konfigurierten TLDs (z.B. .net, .de, .com) + * - Verschleierungsversuche mit Leerzeichen um Punkte ("play . server . net") + */ + private boolean containsAdvertisement(String message) { + // Normalisierung: "play . server . net" → "play.server.net" + String normalized = message.replaceAll("\\s*\\.\\s*", "."); + + // 1. Explizite URL-Prefixe + if (PATTERN_URL_PREFIX.matcher(normalized).find()) { + return !allMatchesWhitelisted(normalized, PATTERN_URL_PREFIX); + } + + // 2. IP-Adressen (werden nie whitelisted) + if (PATTERN_IP.matcher(normalized).find()) { + return true; + } + + // 3. Domains mit bekannten TLDs + if (!cfg.antiAdBlockedTlds.isEmpty()) { + java.util.regex.Matcher m = PATTERN_DOMAIN_GENERIC.matcher(normalized); + while (m.find()) { + String match = m.group(); + String tld = extractTld(match); + if (cfg.antiAdBlockedTlds.contains(tld.toLowerCase())) { + if (!isOnWhitelist(match)) return true; + } + } + } + + return false; + } + + /** true wenn ALLE Treffer des Patterns auf der Whitelist stehen. */ + private boolean allMatchesWhitelisted(String message, Pattern pattern) { + java.util.regex.Matcher m = pattern.matcher(message); + while (m.find()) { + if (!isOnWhitelist(m.group())) return false; + } + return true; + } + + private boolean isOnWhitelist(String match) { + String lower = match.toLowerCase(); + for (String entry : cfg.antiAdWhitelist) { + if (lower.contains(entry.toLowerCase())) return true; + } + return false; + } + + private static String extractTld(String domain) { + String clean = domain.split("[/:]")[0]; + int dot = clean.lastIndexOf('.'); + return dot >= 0 ? clean.substring(dot + 1) : ""; + } + + 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) { + lastMessageText.remove(uuid); + rateLimiter.clearActor(uuid.toString()); + } + + // ===== Konfigurationsklasse ===== + + public static class ChatFilterConfig { + // Anti-Spam + public boolean antiSpamEnabled = true; + public long spamCooldownMs = 1500; // Legacy-Feld fuer Kompatibilitaet + public int spamMaxMessages = 3; // Legacy-Feld fuer Kompatibilitaet + public String spamMessage = "&cBitte nicht so schnell schreiben!"; + + // Globales Rate-Limit-Framework + public boolean globalRateLimitEnabled = true; + public long globalRateLimitWindowMs = 2500; + public int globalRateLimitMaxActions = 3; + public long globalRateLimitBlockMs = 6000; + + // 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 + + // Anti-Werbung + public boolean antiAdEnabled = true; + public String antiAdMessage = "&cWerbung ist in diesem Chat nicht erlaubt!"; + // Domains/Substrings die NICHT geblockt werden (z.B. eigene Serveradresse) + public List antiAdWhitelist = new ArrayList<>(); + // TLDs die als Werbung gewertet werden (leer = alle TLDs prüfen) + public List antiAdBlockedTlds = new ArrayList<>(Arrays.asList( + "net", "com", "de", "org", "gg", "io", "eu", "tv", "xyz", + "info", "me", "cc", "co", "app", "online", "site", "fun" + )); + } +} \ 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..8d679e7 --- /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..e89ab71 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/ChatModule.java @@ -0,0 +1,1400 @@ +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) + * ✅ Join / Leave Nachrichten (mit Vanish-Support) + * ✅ BungeeCord-Vanish-Integration via VanishProvider + */ +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<>(); + + // Letzte Chatnachricht pro Spieler (für Report-Kontext): name.toLowerCase() → message + private final Map lastChatMessages = new ConcurrentHashMap<>(); + + // UUIDs die gerade auf Plugin-Chat-Eingabe warten + private final Set awaitingInput = Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // 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()); + + // ChatLogger + if (config.isChatlogEnabled()) { + chatLogger = new ChatLogger(plugin.getDataFolder(), logger, config.getChatlogRetentionDays()); + logger.fine("[ChatModule] Chat-Log aktiviert (" + config.getChatlogRetentionDays() + " Tage Aufbewahrung)."); + } + + // 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.fine("[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(); + } + + // ========================================================= + // 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(); + + 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. + */ + 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; + + 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()); + 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) { + recipient.sendMessage(color(config.getMentionsNotifyPrefix() + + "&7" + finalSenderName + " &7hat dich erwähnt!")); + 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); + }); + } + + // ========================================================= + // LOGIN-EVENT: Kanal setzen + Join-Nachricht + Report-Info + // ========================================================= + + @EventHandler + public void onLogin(PostLoginEvent e) { + ProxiedPlayer player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + + // Standard-Kanal setzen + playerChannels.put(uuid, config.getDefaultChannelId()); + + // Join-Nachricht und Report-Benachrichtigung mit kurzem Delay senden, + // damit alle anderen Proxy-Initialisierungen (inkl. VanishModule) abgeschlossen sind. + // 2s statt 1s: VanishModule markiert den Spieler oft erst beim ServerConnectedEvent. + plugin.getProxy().getScheduler().schedule(plugin, () -> { + if (!player.isConnected()) return; + + // ── Join-Nachricht ── + if (config.isJoinLeaveEnabled()) { + broadcastJoinLeave(player, true); + } + + // ── Offene Reports für Admins anzeigen ── + if (reportManager != null + && (player.hasPermission(config.getAdminNotifyPermission()) + || player.hasPermission(config.getAdminBypassPermission()))) { + int count = reportManager.getOpenCount(); + if (count > 0) { + 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); + } + + // ========================================================= + // DISCONNECT-EVENT: Cleanup + Leave-Nachricht + // ========================================================= + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + ProxiedPlayer player = e.getPlayer(); + UUID uuid = player.getUniqueId(); + + // Leave-Nachricht + if (config.isJoinLeaveEnabled()) { + broadcastJoinLeave(player, false); + } + + // Cleanup + chatFilter.cleanup(uuid); + playerChannels.remove(uuid); + mentionsDisabled.remove(uuid); + awaitingInput.remove(uuid); + VanishProvider.cleanup(uuid); // Vanish-Status bereinigen + } + + // ========================================================= + // JOIN / LEAVE NACHRICHTEN (mit Vanish-Support) + // ========================================================= + + /** + * Sendet eine Join- oder Leave-Nachricht an alle Spieler. + * + * Vanish-Logik: + * - Unsichtbare Spieler: kein Broadcast an normale Spieler. + * - Ist vanish-show-to-admins=true, erhalten Admins (bypass-permission) + * eine dezente Vanish-Benachrichtigung. + * + * @param player Der betroffene Spieler + * @param isJoin true = Join, false = Leave + */ + private void broadcastJoinLeave(ProxiedPlayer player, boolean isJoin) { + boolean isVanished = VanishProvider.isVanished(player); + + String prefix = getLuckPermsPrefix(player); + String suffix = getLuckPermsSuffix(player); + String server = (player.getServer() != null) + ? config.getServerDisplay(player.getServer().getInfo().getName()) + : "Netzwerk"; + + String normalFormat = isJoin ? config.getJoinFormat() : config.getLeaveFormat(); + String vanishFormat = isJoin ? config.getVanishJoinFormat() : config.getVanishLeaveFormat(); + + // Platzhalter ersetzen + String normalMsg = normalFormat + .replace("{player}", player.getName()) + .replace("{prefix}", prefix != null ? prefix : "") + .replace("{suffix}", suffix != null ? suffix : "") + .replace("{server}", server); + + String vanishMsg = vanishFormat + .replace("{player}", player.getName()) + .replace("{prefix}", prefix != null ? prefix : "") + .replace("{suffix}", suffix != null ? suffix : "") + .replace("{server}", server); + + for (ProxiedPlayer recipient : ProxyServer.getInstance().getPlayers()) { + // Spieler sieht seine eigene Join-/Leave-Meldung nie + if (recipient.getUniqueId().equals(player.getUniqueId())) continue; + + // Admin = bypass-permission ODER notify-permission + boolean recipientIsAdmin = recipient.hasPermission(config.getAdminBypassPermission()) + || recipient.hasPermission(config.getAdminNotifyPermission()); + + if (isVanished) { + // Vanished: Nur Admins sehen eine dezente Meldung (wenn konfiguriert) + if (recipientIsAdmin && config.isVanishShowToAdmins()) { + recipient.sendMessage(color(vanishMsg)); + } + } else { + // Normaler Spieler: alle erhalten die Nachricht + // Vanished Admins sehen Join/Leave-Events anderer Spieler normal + recipient.sendMessage(color(normalMsg)); + } + } + + // Konsole immer informieren + String logMsg = isVanished + ? "[ChatModule] " + (isJoin ? "JOIN(V)" : "LEAVE(V)") + " " + player.getName() + : "[ChatModule] " + (isJoin ? "JOIN" : "LEAVE") + " " + player.getName(); + ProxyServer.getInstance().getConsole().sendMessage(color( + isVanished ? "&8" + logMsg : "&7" + logMsg)); + + // Brücken (nur für nicht-vanished Spieler) + if (!isVanished) { + String cleanMsg = ChatColor.stripColor(ChatColor.translateAlternateColorCodes('&', normalMsg)); + if (discordBridge != null && !config.getJoinLeaveDiscordWebhook().isEmpty()) { + discordBridge.sendToDiscord(config.getJoinLeaveDiscordWebhook(), + player.getName(), cleanMsg, null); + } + if (telegramBridge != null && !config.getJoinLeaveTelegramChatId().isEmpty()) { + telegramBridge.sendToTelegram(config.getJoinLeaveTelegramChatId(), + config.getJoinLeaveTelegramThreadId(), cleanMsg); + } + } + } + + // ========================================================= + // 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 + // Vanish: Vanished Spieler sind für normale Spieler nicht erreichbar. + // Admins können vanished Spieler per PM kontaktieren. + 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; + boolean fromIsAdmin = from.hasPermission(config.getAdminBypassPermission()); + + // Ziel suchen – vanished Spieler sind für Nicht-Admins "nicht gefunden" + ProxiedPlayer to = findVisiblePlayer(args[0], fromIsAdmin); + 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"; + + String channelId = playerChannels.getOrDefault(tUUID, config.getDefaultChannelId()); + ChatChannel ch = config.getChannel(channelId); + String channelName = ch != null ? ch.getFormattedTag() + " &f" + ch.getName() : "&f" + channelId; + + String muteStatus = muteManager.isMuted(tUUID) + ? "&cJa &8(noch: &f" + muteManager.getRemainingTime(tUUID) + "&8)" + : "&aKein"; + + Set blocked = blockManager.getBlockedBy(tUUID); + + 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"; + + String vanishStatus = VanishProvider.isVanished(target) ? "&eJa" : "&aKein"; + + 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("&7Vanish: " + vanishStatus)); + 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) { + 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 + 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); + + // /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; + + 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; + } + + 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"; + String msgContext = lastChatMessages.getOrDefault(reportedName.toLowerCase(), "(keine Chat-Nachricht bekannt)"); + + String reportId = reportManager.createReport( + p.getName(), p.getUniqueId(), reportedName, server, msgContext, reason); + + if (chatLogger != null) { + String logMsg = "[REPORT] Reporter: " + p.getName() + ", Gemeldet: " + reportedName + + ", Grund: " + reason + " | Letzte Nachricht: " + msgContext + + " | Report-ID: " + reportId; + chatLogger.log(reportId, server, "report", p.getName(), logMsg); + } + + String reportWebhook = config.getReportDiscordWebhook(); + 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()); + } + + 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); + + String confirm = config.getReportConfirm().replace("{id}", reportId); + p.sendMessage(color(confirm)); + + notifyAdminsReport(reportId, p.getName(), reportedName, server, reason, msgContext); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportCmd); + + // /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) { + ComponentBuilder line = new ComponentBuilder(""); + + line.append(ChatColor.translateAlternateColorCodes('&', statusColor + " ")) + .event((ClickEvent) null) + .event((HoverEvent) null); + + 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())); + + 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 { + 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); + + // /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.")); + + 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.")); + } + + notifyAdmins("&8[&aReport geschlossen&8] &f" + adminName + " &7hat Report &f" + id + " &7geschlossen."); + } + }; + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, reportCloseCmd); + } + + // ========================================================= + // PLUGIN-INPUT BYPASS + // ========================================================= + + /** + * Ö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); + } + + // ========================================================= + // VANISH-HILFSMETHODEN + // ========================================================= + + /** + * Sucht einen Spieler nach Name und berücksichtigt den Vanish-Status. + * + * @param name Spielername (case-insensitiv) + * @param callerIsAdmin true → Vanished Spieler werden ebenfalls gefunden + * @return ProxiedPlayer oder null wenn nicht gefunden / vanished (für Nicht-Admins) + */ + private ProxiedPlayer findVisiblePlayer(String name, boolean callerIsAdmin) { + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(name); + if (target == null) return null; + if (!callerIsAdmin && VanishProvider.isVanished(target)) return null; + return target; + } + + // ========================================================= + // 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); + // Nur den Servernamen-Teil vorübersetzen damit &#RRGGBB im Display-Namen + // korrekt sitzt; der Rest wird am Ausgabepunkt via translateColors() übersetzt. + String coloredServer = 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. + */ + private String translateColors(String text) { + if (text == null) return ""; + + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + 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++; + } + + 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. + */ + private void notifyAdminsReport(String reportId, String reporter, String reported, + String server, String reason, String msgContext) { + 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; + + 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)); + + 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▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬▬")); + } + + 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 mit klickbarem [⚑] Melden-Button. + */ + private BaseComponent[] buildClickableMessage(String msgId, String formatted, String senderName) { + ComponentBuilder builder = new ComponentBuilder(""); + + builder.append(translateColors(formatted), + ComponentBuilder.FormatRetention.NONE) + .event((ClickEvent) null) + .event((HoverEvent) null); + + 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-Hinweis via Actionbar (Mention-Feedback). + */ + private void sendMentionSound(ProxiedPlayer player, String soundName) { + if (soundName == null || soundName.isEmpty()) return; + try { + 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..2dd94c3 --- /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..5a0a523 --- /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..8db961d --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/PrivateMsgManager.java @@ -0,0 +1,163 @@ +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 net.viper.status.ratelimit.GlobalRateLimitFramework; + +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; + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); + + // 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; + } + + if (config.isPmRateLimitEnabled()) { + GlobalRateLimitFramework.Result result = rateLimiter.check( + "chat.pm", + sender.getUniqueId().toString(), + new GlobalRateLimitFramework.Rule( + true, + config.getPmRateLimitWindowMs(), + config.getPmRateLimitMaxActions(), + config.getPmRateLimitBlockMs() + ) + ); + + if (result.isBlocked()) { + sender.sendMessage(color(config.getPmRateLimitMessage())); + 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..accc4fa --- /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); + + } + + // ===== 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 diff --git a/src/main/java/net/viper/status/modules/chat/VanishProvider.java b/src/main/java/net/viper/status/modules/chat/VanishProvider.java new file mode 100644 index 0000000..73599ef --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/VanishProvider.java @@ -0,0 +1,71 @@ +package net.viper.status.modules.chat; + +import net.md_5.bungee.api.connection.ProxiedPlayer; + +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Zentrale Schnittstelle zwischen dem VanishModule und dem ChatModule. + * + * Das VanishModule (oder jedes andere Modul) ruft {@link #setVanished} auf + * um Spieler als unsichtbar zu markieren. Das ChatModule prüft via + * {@link #isVanished} bevor es Join-/Leave-Nachrichten sendet oder + * Privat-Nachrichten zulässt. + * + * Verwendung im VanishModule: + * VanishProvider.setVanished(player.getUniqueId(), true); // beim Verschwinden + * VanishProvider.setVanished(player.getUniqueId(), false); // beim Erscheinen / Disconnect + */ +public final class VanishProvider { + + private VanishProvider() {} + + /** Intern verwaltete Menge aller aktuell unsichtbaren Spieler-UUIDs. */ + private static final Set vanishedPlayers = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + // ===== Schreib-API (wird vom VanishModule aufgerufen) ===== + + /** + * Markiert einen Spieler als sichtbar oder unsichtbar. + * + * @param uuid UUID des Spielers + * @param vanished true = unsichtbar, false = sichtbar + */ + public static void setVanished(UUID uuid, boolean vanished) { + if (vanished) { + vanishedPlayers.add(uuid); + } else { + vanishedPlayers.remove(uuid); + } + } + + /** + * Entfernt einen Spieler beim Disconnect aus der Vanish-Liste. + * Sollte vom ChatModule (onDisconnect) aufgerufen werden, damit + * kein toter Eintrag verbleibt. + */ + public static void cleanup(UUID uuid) { + vanishedPlayers.remove(uuid); + } + + // ===== Lese-API (wird vom ChatModule aufgerufen) ===== + + /** @return true wenn der Spieler aktuell als unsichtbar markiert ist. */ + public static boolean isVanished(ProxiedPlayer player) { + return player != null && vanishedPlayers.contains(player.getUniqueId()); + } + + /** @return true wenn der Spieler mit der angegebenen UUID unsichtbar ist. */ + public static boolean isVanished(UUID uuid) { + return uuid != null && vanishedPlayers.contains(uuid); + } + + /** Snapshot der aktuell unsichtbaren Spieler (für Debugging / Logs). */ + public static Set getVanishedPlayers() { + return Collections.unmodifiableSet(vanishedPlayers); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java new file mode 100644 index 0000000..6d42903 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/bridge/DiscordBridge.java @@ -0,0 +1,323 @@ +package net.viper.status.modules.chat.bridge; + +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.plugin.Plugin; +import net.viper.status.modules.chat.AccountLinkManager; +import net.viper.status.modules.chat.ChatConfig; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Discord-Brücke für bidirektionale Kommunikation. + * + * Fix #12: extractJsonString() behandelt Escape-Sequenzen jetzt korrekt. + * Statt Zeichenvergleich mit dem Vorgänger-Char wird ein expliziter Escape-Flag verwendet. + */ +public class DiscordBridge { + + private final Plugin plugin; + private final ChatConfig config; + private final Logger logger; + private AccountLinkManager linkManager; + + private final java.util.Map lastMessageIds = new java.util.concurrent.ConcurrentHashMap<>(); + private volatile boolean running = false; + + public DiscordBridge(Plugin plugin, ChatConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + public void setLinkManager(AccountLinkManager linkManager) { this.linkManager = linkManager; } + + public void start() { + if (!config.isDiscordEnabled() + || config.getDiscordBotToken().isEmpty() + || config.getDiscordBotToken().equals("YOUR_BOT_TOKEN_HERE")) { + logger.warning("[ChatModule-Discord] Bot-Token nicht konfiguriert. Discord-Empfang deaktiviert."); + return; + } + running = true; + int interval = Math.max(2, config.getDiscordPollInterval()); + plugin.getProxy().getScheduler().schedule(plugin, this::pollAllChannels, interval, interval, TimeUnit.SECONDS); + logger.info("[ChatModule-Discord] Brücke gestartet (Poll-Intervall: " + interval + "s)."); + } + + public void stop() { running = false; } + + // ===== Minecraft → Discord ===== + + public void sendToDiscord(String webhookUrl, String username, String message, String avatarUrl) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String payload = "{\"username\":\"" + escapeJson(username) + "\"" + + (avatarUrl != null && !avatarUrl.isEmpty() ? ",\"avatar_url\":\"" + avatarUrl + "\"" : "") + + ",\"content\":\"" + escapeJson(message) + "\"}"; + postJson(webhookUrl, payload, null); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Webhook-Fehler: " + e.getMessage()); + } + }); + } + + public void sendEmbedToDiscord(String webhookUrl, String title, String description, String colorHex) { + if (webhookUrl == null || webhookUrl.isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + int color = 0x5865F2; + try { color = Integer.parseInt(colorHex.replace("#", ""), 16); } catch (Exception ignored) {} + String payload = "{\"embeds\":[{\"title\":\"" + escapeJson(title) + "\"" + + ",\"description\":\"" + escapeJson(description) + "\"" + + ",\"color\":" + color + "}]}"; + postJson(webhookUrl, payload, null); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Embed-Fehler: " + e.getMessage()); + } + }); + } + + public void sendToChannel(String channelId, String message) { + if (channelId == null || channelId.isEmpty()) return; + if (config.getDiscordBotToken().isEmpty()) return; + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages"; + postJson(url, "{\"content\":\"" + escapeJson(message) + "\"}", "Bot " + config.getDiscordBotToken()); + } catch (Exception e) { + logger.warning("[ChatModule-Discord] Send-to-Channel-Fehler: " + e.getMessage()); + } + }); + } + + // ===== Discord → Minecraft (Polling) ===== + + private void pollAllChannels() { + if (!running) return; + java.util.Set channelIds = new java.util.LinkedHashSet<>(); + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (!ch.getDiscordChannelId().isEmpty()) channelIds.add(ch.getDiscordChannelId()); + } + if (!config.getDiscordAdminChannelId().isEmpty()) channelIds.add(config.getDiscordAdminChannelId()); + for (String channelId : channelIds) pollChannel(channelId); + } + + private void pollChannel(String channelId) { + try { + AtomicLong lastId = lastMessageIds.computeIfAbsent(channelId, k -> new AtomicLong(0L)); + if (lastId.get() == 0L) { + String initResp = getJson("https://discord.com/api/v10/channels/" + channelId + "/messages?limit=1", + "Bot " + config.getDiscordBotToken()); + if (initResp != null && !initResp.equals("[]") && !initResp.isEmpty()) { + java.util.List initMsgs = parseMessages(initResp); + if (!initMsgs.isEmpty()) lastId.set(initMsgs.get(0).id); + } + return; + } + + String url = "https://discord.com/api/v10/channels/" + channelId + "/messages?after=" + lastId.get() + "&limit=10"; + String response = getJson(url, "Bot " + config.getDiscordBotToken()); + if (response == null || response.equals("[]") || response.isEmpty()) return; + + java.util.List messages = parseMessages(response); + messages.sort(java.util.Comparator.comparingLong(m -> m.id)); + + for (DiscordMessage msg : messages) { + if (msg.id <= lastId.get()) continue; + if (msg.isBot) continue; + if (msg.content.isEmpty()) continue; + lastId.set(msg.id); + + if (msg.content.startsWith("!link ")) { + String token = msg.content.substring(6).trim().toUpperCase(); + if (linkManager != null) { + AccountLinkManager.LinkedAccount acc = linkManager.redeemDiscord(token, msg.authorId, msg.authorName); + if (acc != null) sendToChannel(channelId, "✅ Verknüpfung erfolgreich! Minecraft-Account: **" + acc.minecraftName + "**"); + else sendToChannel(channelId, "❌ Ungültiger oder abgelaufener Token. Bitte `/discordlink` im Spiel erneut ausführen."); + } + continue; + } + + String displayName = (linkManager != null) + ? linkManager.resolveDiscordName(msg.authorId, msg.authorName) : msg.authorName; + String mcFormat = resolveFormat(channelId); + if (mcFormat == null) continue; + + String formatted = ChatColor.translateAlternateColorCodes('&', + mcFormat.replace("{user}", displayName).replace("{message}", msg.content)); + ProxyServer.getInstance().getScheduler().runAsync(plugin, + () -> ProxyServer.getInstance().broadcast(new TextComponent(formatted))); + } + } catch (Exception e) { + logger.fine("[ChatModule-Discord] Poll-Fehler für Kanal " + channelId + ": " + e.getMessage()); + } + } + + private String resolveFormat(String channelId) { + if (channelId.equals(config.getDiscordAdminChannelId())) return config.getDiscordFromFormat(); + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (channelId.equals(ch.getDiscordChannelId())) return config.getDiscordFromFormat(); + } + return null; + } + + // ===== HTTP ===== + + private void postJson(String urlStr, String payload, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "POST", authorization); + byte[] data = payload.getBytes(StandardCharsets.UTF_8); + conn.setRequestProperty("Content-Length", String.valueOf(data.length)); + conn.setDoOutput(true); + try (OutputStream os = conn.getOutputStream()) { os.write(data); } + int code = conn.getResponseCode(); + if (code >= 400) logger.warning("[ChatModule-Discord] HTTP " + code + ": " + readStream(conn.getErrorStream())); + conn.disconnect(); + } + + private String getJson(String urlStr, String authorization) throws Exception { + HttpURLConnection conn = openConnection(urlStr, "GET", authorization); + int code = conn.getResponseCode(); + if (code != 200) { conn.disconnect(); return null; } + String result = readStream(conn.getInputStream()); + conn.disconnect(); + return result; + } + + private HttpURLConnection openConnection(String urlStr, String method, String authorization) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod(method); + conn.setConnectTimeout(5000); + conn.setReadTimeout(8000); + conn.setRequestProperty("Content-Type", "application/json"); + conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); + if (authorization != null && !authorization.isEmpty()) conn.setRequestProperty("Authorization", authorization); + return conn; + } + + private String readStream(InputStream in) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + // ===== JSON Mini-Parser ===== + + private static class DiscordMessage { + long id; + String authorId = "", authorName = "", content = ""; + boolean isBot = false; + } + + private java.util.List parseMessages(String json) { + java.util.List result = new java.util.ArrayList<>(); + int depth = 0, start = -1; + for (int i = 0; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { + if (--depth == 0 && start != -1) { + DiscordMessage msg = parseMessage(json.substring(start, i + 1)); + if (msg != null) result.add(msg); + start = -1; + } + } + } + return result; + } + + private DiscordMessage parseMessage(String obj) { + try { + DiscordMessage msg = new DiscordMessage(); + msg.id = Long.parseLong(extractJsonString(obj, "id")); + msg.content = unescapeJson(extractJsonString(obj, "content")); + + // Webhook-Nachrichten als Bot markieren (Echo-Loop verhindern) + if (!extractJsonString(obj, "webhook_id").isEmpty()) { + msg.isBot = true; + return msg; + } + + int authStart = obj.indexOf("\"author\""); + if (authStart >= 0) { + String authBlock = extractJsonObject(obj, authStart); + msg.authorId = extractJsonString(authBlock, "id"); + msg.authorName = unescapeJson(extractJsonString(authBlock, "username")); + msg.isBot = "true".equals(extractJsonString(authBlock, "bot")); + } + return msg; + } catch (Exception e) { + return null; + } + } + + /** + * FIX #12: Escape-Sequenzen werden korrekt mit einem Escape-Flag behandelt + * statt den Vorgänger-Char zu vergleichen (der bei '\\' + '"' versagt). + * Gibt immer einen leeren String zurück wenn der Key nicht gefunden wird (nie null). + */ + private String extractJsonString(String json, String key) { + if (json == null || key == null) return ""; + String fullKey = "\"" + key + "\""; + int keyIdx = json.indexOf(fullKey); + if (keyIdx < 0) return ""; + int colon = json.indexOf(':', keyIdx + fullKey.length()); + if (colon < 0) return ""; + int valStart = colon + 1; + while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; + if (valStart >= json.length()) return ""; + char first = json.charAt(valStart); + if (first == '"') { + // FIX: Expliziter Escape-Flag statt Vorgänger-Char-Vergleich + int end = valStart + 1; + boolean escaped = false; + while (end < json.length()) { + char ch = json.charAt(end); + if (escaped) { + escaped = false; + } else if (ch == '\\') { + escaped = true; + } else if (ch == '"') { + break; + } + end++; + } + return json.substring(valStart + 1, end); + } else { + int end = valStart; + while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; + return json.substring(valStart, end).trim(); + } + } + + private String extractJsonObject(String json, int fromIndex) { + int depth = 0, start = -1; + for (int i = fromIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } + } + return ""; + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r").replace("\t", "\\t"); + } + + private static String unescapeJson(String s) { + if (s == null) return ""; + return s.replace("\\\"", "\"").replace("\\n", "\n").replace("\\r", "\r").replace("\\\\", "\\"); + } +} diff --git a/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java b/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java new file mode 100644 index 0000000..856ef95 --- /dev/null +++ b/src/main/java/net/viper/status/modules/chat/bridge/TelegramBridge.java @@ -0,0 +1,399 @@ +package net.viper.status.modules.chat.bridge; + +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.plugin.Plugin; +import net.viper.status.modules.chat.AccountLinkManager; +import net.viper.status.modules.chat.ChatConfig; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; +import java.util.logging.Logger; + +/** + * Telegram-Brücke für bidirektionale Kommunikation. + * + * Minecraft → Telegram: Via Bot API (sendMessage) + * Telegram → Minecraft: Via Long-Polling (getUpdates) + * + * Voraussetzungen: + * - Telegram Bot via @BotFather erstellen + * - Bot-Token in chat.yml eintragen + * - Bot in die gewünschten Gruppen/Kanäle einladen + * - Bot zu Admin machen (für Gruppen-Nachrichten empfangen) + */ +public class TelegramBridge { + + private static final String API_BASE = "https://api.telegram.org/bot"; + + private final Plugin plugin; + private final ChatConfig config; + private final Logger logger; + private AccountLinkManager linkManager; // wird nach dem Start gesetzt + + // Letztes verarbeitetes Update-ID (für getUpdates Offset) + private final AtomicLong lastUpdateId = new AtomicLong(0L); + + private volatile boolean running = false; + + public TelegramBridge(Plugin plugin, ChatConfig config) { + this.plugin = plugin; + this.config = config; + this.logger = plugin.getLogger(); + } + + /** Setzt den AccountLinkManager – muss vor start() aufgerufen werden. */ + public void setLinkManager(AccountLinkManager linkManager) { + this.linkManager = linkManager; + } + + public void start() { + if (!config.isTelegramEnabled() + || config.getTelegramBotToken().isEmpty() + || config.getTelegramBotToken().equals("YOUR_TELEGRAM_BOT_TOKEN")) { + logger.warning("[ChatModule-Telegram] Bot-Token nicht konfiguriert. Telegram-Empfang deaktiviert."); + return; + } + + running = true; + int interval = Math.max(2, config.getTelegramPollInterval()); + + plugin.getProxy().getScheduler().schedule(plugin, this::pollUpdates, + interval, interval, TimeUnit.SECONDS); + + logger.info("[ChatModule-Telegram] Brücke gestartet (Poll-Intervall: " + interval + "s)."); + } + + public void stop() { + running = false; + } + + // ===== Minecraft → Telegram ===== + + /** + * Sendet eine Nachricht an eine Telegram-Chat-ID. + * Unterstützt Themen-Gruppen via message_thread_id. + */ + public void sendToTelegram(String chatId, String message) { + sendToTelegram(chatId, 0, message); + } + + public void sendToTelegram(String chatId, int threadId, String message) { + if (chatId == null || chatId.isEmpty()) return; + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String cleanMessage = ChatColor.stripColor( + ChatColor.translateAlternateColorCodes('&', message)); + + String url = API_BASE + config.getTelegramBotToken() + + "/sendMessage?chat_id=" + URLEncoder.encode(chatId, "UTF-8") + + "&text=" + URLEncoder.encode(cleanMessage, "UTF-8") + + "&parse_mode=HTML" + + (threadId > 0 ? "&message_thread_id=" + threadId : ""); + + getJson(url); + } catch (Exception e) { + logger.warning("[ChatModule-Telegram] Sende-Fehler: " + e.getMessage()); + } + }); + } + + /** + * Sendet eine formatierte HelpOp/Broadcast-Nachricht an Telegram. + * Unterstützt Themen-Gruppen via message_thread_id. + */ + public void sendFormattedToTelegram(String chatId, String header, String content) { + sendFormattedToTelegram(chatId, 0, header, content); + } + + public void sendFormattedToTelegram(String chatId, int threadId, String header, String content) { + if (chatId == null || chatId.isEmpty()) return; + String text = "" + escapeHtml(header) + "\n" + escapeHtml(content); + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String url = API_BASE + config.getTelegramBotToken() + + "/sendMessage?chat_id=" + URLEncoder.encode(chatId, "UTF-8") + + "&text=" + URLEncoder.encode(text, "UTF-8") + + "&parse_mode=HTML" + + (threadId > 0 ? "&message_thread_id=" + threadId : ""); + getJson(url); + } catch (Exception e) { + logger.warning("[ChatModule-Telegram] Format-Sende-Fehler: " + e.getMessage()); + } + }); + } + + // ===== Telegram → Minecraft (Polling) ===== + + private void pollUpdates() { + if (!running) return; + try { + // Beim ersten Poll: nur den aktuellen Offset holen, keine alten Updates verarbeiten + if (lastUpdateId.get() == 0L) { + String initUrl = API_BASE + config.getTelegramBotToken() + + "/getUpdates?limit=1&offset=-1"; + String initResp = getJson(initUrl); + if (initResp != null && initResp.contains("\"ok\":true")) { + java.util.List initUpdates = parseUpdates(initResp); + if (!initUpdates.isEmpty()) { + lastUpdateId.set(initUpdates.get(initUpdates.size() - 1).updateId); + } + } + return; // Erster Poll nur zum Initialisieren + } + + long offset = lastUpdateId.get() + 1; + String url = API_BASE + config.getTelegramBotToken() + + "/getUpdates?timeout=2&limit=10" + + (offset > 0 ? "&offset=" + offset : ""); + + String response = getJson(url); + if (response == null || !response.contains("\"ok\":true")) return; + + java.util.List updates = parseUpdates(response); + + for (TelegramUpdate update : updates) { + if (update.updateId > lastUpdateId.get()) { + lastUpdateId.set(update.updateId); + } + if (update.text == null || update.text.isEmpty()) continue; + if (update.isBot) continue; + + // ── Token-Einlösung: /link ── + if (update.text.startsWith("/link ") || update.text.startsWith("/link@")) { + String[] parts = update.text.split("\\s+", 2); + if (parts.length == 2 && linkManager != null) { + String token = parts[1].trim().toUpperCase(); + AccountLinkManager.LinkedAccount acc = + linkManager.redeemTelegram(token, update.fromId, update.fromName); + if (acc != null) { + sendToTelegram(update.chatId, update.threadId, + "✅ Verknüpfung erfolgreich! Minecraft-Account: " + + escapeHtml(acc.minecraftName) + ""); + } else { + sendToTelegram(update.chatId, update.threadId, + "❌ Ungültiger oder abgelaufener Token. Bitte /telegramlink im Spiel erneut ausführen."); + } + } + continue; // Nicht als Chat-Nachricht weiterleiten + } + + // Bot-Befehle ignorieren + if (update.text.startsWith("/")) continue; + + // ── Account-Name auflösen ── + String displayName = (linkManager != null) + ? linkManager.resolveTelegramName(update.fromId, update.fromName) + : update.fromName; + + // Welchem Minecraft-Kanal gehört diese Telegram-Chat-ID + Thread? + final boolean isAdminChat = update.chatId.equals(config.getTelegramAdminChatId()) + && (config.getTelegramAdminTopicId() == 0 + || config.getTelegramAdminTopicId() == update.threadId); + + // Prüfen ob die Nachricht zu einem konfigurierten Kanal-Thema gehört + final boolean matchesChannel = isAdminChat || matchesTelegramChannel(update); + + if (!matchesChannel && !isAdminChat) continue; + + final String format = config.getTelegramFromFormat(); + final String finalDisplay = displayName; + final String formatted = ChatColor.translateAlternateColorCodes('&', + format.replace("{user}", finalDisplay) + .replace("{message}", update.text)); + + ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> { + if (isAdminChat) { + for (net.md_5.bungee.api.connection.ProxiedPlayer p : + ProxyServer.getInstance().getPlayers()) { + if (p.hasPermission("chat.admin.bypass")) { + p.sendMessage(new TextComponent(formatted)); + } + } + } else { + ProxyServer.getInstance().broadcast(new TextComponent(formatted)); + } + }); + } + } catch (Exception e) { + logger.fine("[ChatModule-Telegram] Poll-Fehler: " + e.getMessage()); + } + } + + // ===== HTTP-Hilfsmethoden ===== + + private String getJson(String urlStr) throws Exception { + HttpURLConnection conn = (HttpURLConnection) new URL(urlStr).openConnection(); + conn.setRequestMethod("GET"); + conn.setConnectTimeout(6000); + conn.setReadTimeout(10000); + conn.setRequestProperty("User-Agent", "StatusAPI-ChatModule/1.0"); + int code = conn.getResponseCode(); + String result = readStream(code == 200 ? conn.getInputStream() : conn.getErrorStream()); + conn.disconnect(); + return result; + } + + private String readStream(InputStream in) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + // ===== JSON Mini-Parser ===== + + private static class TelegramUpdate { + long updateId; + String chatId = ""; + String fromId = ""; // Telegram User-ID (für Account-Link) + String fromName = ""; + String text = ""; + boolean isBot = false; + int threadId = 0; // message_thread_id für Themen-Gruppen (0 = kein Thema) + } + + private java.util.List parseUpdates(String json) { + java.util.List result = new java.util.ArrayList<>(); + // Suche nach "result":[...] + int resultStart = json.indexOf("\"result\":["); + if (resultStart < 0) return result; + + // Extrahiere alle Update-Objekte + int depth = 0, start = -1; + boolean inResult = false; + for (int i = resultStart + 10; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '[' && !inResult) { inResult = true; continue; } + if (!inResult) continue; + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { + if (--depth == 0 && start >= 0) { + TelegramUpdate upd = parseUpdate(json.substring(start, i + 1)); + if (upd != null) result.add(upd); + start = -1; + } + } else if (c == ']' && depth == 0) break; + } + return result; + } + + /** Prüft ob ein Update zu einem konfigurierten Kanal-Thema gehört. */ + private boolean matchesTelegramChannel(TelegramUpdate update) { + for (net.viper.status.modules.chat.ChatChannel ch : config.getChannels().values()) { + if (!ch.getTelegramChatId().equals(update.chatId)) continue; + // Thema konfiguriert? → Thread-ID muss übereinstimmen + if (ch.getTelegramThreadId() > 0 && ch.getTelegramThreadId() != update.threadId) continue; + return true; + } + return false; + } + + private TelegramUpdate parseUpdate(String obj) { + try { + TelegramUpdate upd = new TelegramUpdate(); + upd.updateId = Long.parseLong(extractValue(obj, "update_id")); + + // message-Block + int msgIdx = obj.indexOf("\"message\""); + if (msgIdx < 0) return null; + String msgBlock = extractObject(obj, msgIdx); + + upd.text = unescapeJson(extractString(msgBlock, "text")); + + // message_thread_id (Themen-Gruppen) + String threadIdStr = extractValue(msgBlock, "message_thread_id"); + if (!threadIdStr.isEmpty()) { + try { upd.threadId = Integer.parseInt(threadIdStr); } catch (Exception ignored) {} + } + + // from-Block (Absender) + int fromIdx = msgBlock.indexOf("\"from\""); + if (fromIdx >= 0) { + String fromBlock = extractObject(msgBlock, fromIdx); + String firstName = unescapeJson(extractString(fromBlock, "first_name")); + String lastName = unescapeJson(extractString(fromBlock, "last_name")); + String username = unescapeJson(extractString(fromBlock, "username")); + upd.fromId = extractValue(fromBlock, "id"); + upd.fromName = !username.isEmpty() ? "@" + username + : (firstName + (lastName.isEmpty() ? "" : " " + lastName)).trim(); + String botFlag = extractValue(fromBlock, "is_bot"); + upd.isBot = "true".equals(botFlag); + } + + // chat-Block (Chat-ID) + int chatIdx = msgBlock.indexOf("\"chat\""); + if (chatIdx >= 0) { + String chatBlock = extractObject(msgBlock, chatIdx); + upd.chatId = extractValue(chatBlock, "id"); + } + + return upd; + } catch (Exception e) { + return null; + } + } + + private String extractValue(String json, String key) { + String fullKey = "\"" + key + "\""; + int idx = json.indexOf(fullKey); + if (idx < 0) return ""; + int colon = json.indexOf(':', idx + fullKey.length()); + if (colon < 0) return ""; + int valStart = colon + 1; + while (valStart < json.length() && json.charAt(valStart) == ' ') valStart++; + if (valStart >= json.length()) return ""; + char first = json.charAt(valStart); + if (first == '"') { + return extractString(json.substring(valStart - 1 - key.length()), key); + } + int end = valStart; + while (end < json.length() && ",}\n".indexOf(json.charAt(end)) < 0) end++; + return json.substring(valStart, end).trim(); + } + + private String extractString(String json, String key) { + String fullKey = "\"" + key + "\":\""; + int idx = json.indexOf(fullKey); + if (idx < 0) return ""; + int start = idx + fullKey.length(); + int end = start; + while (end < json.length()) { + if (json.charAt(end) == '"' && json.charAt(end - 1) != '\\') break; + end++; + } + return json.substring(start, end); + } + + private String extractObject(String json, int fromIndex) { + int depth = 0, start = -1; + for (int i = fromIndex; i < json.length(); i++) { + char c = json.charAt(i); + if (c == '{') { if (depth++ == 0) start = i; } + else if (c == '}') { if (--depth == 0 && start >= 0) return json.substring(start, i + 1); } + } + return ""; + } + + private static String unescapeJson(String s) { + if (s == null) return ""; + return s.replace("\\\"", "\"").replace("\\n", "\n") + .replace("\\r", "\r").replace("\\\\", "\\"); + } + + private static String escapeHtml(String s) { + if (s == null) return ""; + return s.replace("&", "&").replace("<", "<").replace(">", ">"); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java b/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java new file mode 100644 index 0000000..00bf3fe --- /dev/null +++ b/src/main/java/net/viper/status/modules/commandblocker/CommandBlockerModule.java @@ -0,0 +1,245 @@ +package net.viper.status.modules.commandblocker; + +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.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.ChatEvent; +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.viper.status.StatusAPI; +import net.viper.status.module.Module; +import net.viper.status.ratelimit.GlobalRateLimitFramework; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileWriter; +import java.io.IOException; +import java.util.*; +import org.yaml.snakeyaml.Yaml; + +@SuppressWarnings({"unchecked", "rawtypes"}) +public class CommandBlockerModule implements Module, Listener { + + private StatusAPI plugin; + private boolean enabled = true; // Standardmäßig aktiv + private String bypassPermission = "commandblocker.bypass"; // Standard Permission + + private File file; + private Set blocked = new HashSet<>(); + private final GlobalRateLimitFramework rateLimiter = GlobalRateLimitFramework.getInstance(); + + private boolean commandRateLimitEnabled = true; + private long commandRateLimitWindowMs = 3000L; + private int commandRateLimitMaxActions = 8; + private long commandRateLimitBlockMs = 6000L; + private String commandRateLimitMessage = "&cZu viele Befehle in kurzer Zeit. Bitte warte kurz."; + + @Override + public String getName() { + return "CommandBlockerModule"; + } + + @Override + public void onEnable(Plugin plugin) { + if (!(plugin instanceof StatusAPI)) return; // Sicherheit + this.plugin = (StatusAPI) plugin; + + // Datei laden + file = new File(this.plugin.getDataFolder(), "blocked-commands.yml"); + loadFile(); + + // Listener registrieren + ProxyServer.getInstance().getPluginManager().registerListener(this.plugin, this); + + // /cb Befehl registrieren + ProxyServer.getInstance().getPluginManager().registerCommand(this.plugin, + new net.md_5.bungee.api.plugin.Command("cb", "commandblocker.admin") { + @Override + public void execute(CommandSender sender, String[] args) { + handleCommand(sender, args); + } + }); + + this.plugin.getLogger().fine("[CommandBlocker] aktiviert (" + blocked.size() + " Commands)."); + } + + + @Override + public void onDisable(Plugin plugin) { + blocked.clear(); + } + + @EventHandler + public void onCommand(ChatEvent event) { + if (!enabled || !event.isCommand()) return; + + if (!(event.getSender() instanceof ProxiedPlayer)) return; + ProxiedPlayer player = (ProxiedPlayer) event.getSender(); + + if (player.hasPermission(bypassPermission)) return; + + String msg = event.getMessage(); + if (msg == null || msg.length() <= 1) return; + + if (commandRateLimitEnabled) { + GlobalRateLimitFramework.Result result = rateLimiter.check( + "chat.command", + player.getUniqueId().toString(), + new GlobalRateLimitFramework.Rule( + true, + commandRateLimitWindowMs, + commandRateLimitMaxActions, + commandRateLimitBlockMs + ) + ); + + if (result.isBlocked()) { + event.setCancelled(true); + player.sendMessage(ChatColor.translateAlternateColorCodes('&', commandRateLimitMessage)); + return; + } + } + + String cmd = msg.substring(1).toLowerCase(Locale.ROOT); + String base = cmd.split(" ")[0]; + + if (blocked.contains(base)) { + event.setCancelled(true); + player.sendMessage(ChatColor.RED + "Dieser Befehl ist auf diesem Netzwerk blockiert."); + } + } + + private void handleCommand(CommandSender sender, String[] args) { + if (args == null || args.length == 0) { + sender.sendMessage(ChatColor.YELLOW + "/cb add "); + sender.sendMessage(ChatColor.YELLOW + "/cb remove "); + sender.sendMessage(ChatColor.YELLOW + "/cb list"); + sender.sendMessage(ChatColor.YELLOW + "/cb reload"); + return; + } + + String action = args[0].toLowerCase(); + + switch (action) { + case "add": + if (args.length < 2) break; + blocked.add(args[1].toLowerCase()); + saveFile(); + sender.sendMessage(ChatColor.GREEN + "Command blockiert: " + args[1]); + break; + case "remove": + if (args.length < 2) break; + blocked.remove(args[1].toLowerCase()); + saveFile(); + sender.sendMessage(ChatColor.GREEN + "Command freigegeben: " + args[1]); + break; + case "list": + sender.sendMessage(ChatColor.GOLD + "Blockierte Commands:"); + for (String c : blocked) { + sender.sendMessage(ChatColor.RED + "- " + c); + } + break; + case "reload": + loadFile(); + sender.sendMessage(ChatColor.GREEN + "CommandBlocker neu geladen."); + break; + default: + sender.sendMessage(ChatColor.RED + "Unbekannter Unterbefehl."); + break; + } + } + + private void loadFile() { + try { + if (!file.exists()) { + File parent = file.getParentFile(); + if (parent != null && !parent.exists()) parent.mkdirs(); + file.createNewFile(); + saveFile(); + } + + Yaml yaml = new Yaml(); + Map data = null; + FileInputStream fis = null; + try { + fis = new FileInputStream(file); + data = yaml.loadAs(fis, Map.class); + } finally { + if (fis != null) try { fis.close(); } catch (IOException ignored) {} + } + + blocked.clear(); + if (data != null && data.containsKey("blocked")) { + Object obj = data.get("blocked"); + if (obj instanceof List) { + List list = (List) obj; + for (Object o : list) { + if (o != null) blocked.add(String.valueOf(o).toLowerCase()); + } + } + } + + if (data != null && data.containsKey("rate-limit")) { + Object rlObj = data.get("rate-limit"); + if (rlObj instanceof Map) { + Map rl = (Map) rlObj; + commandRateLimitEnabled = parseBoolean(rl.get("enabled"), commandRateLimitEnabled); + commandRateLimitWindowMs = parseLong(rl.get("window-ms"), commandRateLimitWindowMs); + commandRateLimitMaxActions = (int) parseLong(rl.get("max-actions"), commandRateLimitMaxActions); + commandRateLimitBlockMs = parseLong(rl.get("block-ms"), commandRateLimitBlockMs); + Object msgObj = rl.get("message"); + if (msgObj != null) { + commandRateLimitMessage = String.valueOf(msgObj); + } + } + } + + } catch (Exception e) { + if (plugin != null) plugin.getLogger().severe("[CommandBlocker] Fehler beim Laden: " + e.getMessage()); + else System.err.println("[CommandBlocker] Fehler beim Laden: " + e.getMessage()); + } + } + + private void saveFile() { + try { + Yaml yaml = new Yaml(); + Map out = new LinkedHashMap<>(); + out.put("blocked", new ArrayList<>(blocked)); + + Map rl = new LinkedHashMap<>(); + rl.put("enabled", commandRateLimitEnabled); + rl.put("window-ms", commandRateLimitWindowMs); + rl.put("max-actions", commandRateLimitMaxActions); + rl.put("block-ms", commandRateLimitBlockMs); + rl.put("message", commandRateLimitMessage); + out.put("rate-limit", rl); + + FileWriter fw = null; + try { + fw = new FileWriter(file); + yaml.dump(out, fw); + } finally { + if (fw != null) try { fw.close(); } catch (IOException ignored) {} + } + } catch (IOException e) { + if (plugin != null) plugin.getLogger().severe("[CommandBlocker] Fehler beim Speichern: " + e.getMessage()); + else System.err.println("[CommandBlocker] Fehler beim Speichern: " + e.getMessage()); + } + } + + private boolean parseBoolean(Object obj, boolean fallback) { + if (obj == null) return fallback; + return Boolean.parseBoolean(String.valueOf(obj)); + } + + private long parseLong(Object obj, long fallback) { + if (obj == null) return fallback; + try { + return Long.parseLong(String.valueOf(obj)); + } catch (Exception ignored) { + return fallback; + } + } +} diff --git a/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java b/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java new file mode 100644 index 0000000..083a16a --- /dev/null +++ b/src/main/java/net/viper/status/modules/customcommands/CustomCommandModule.java @@ -0,0 +1,242 @@ +package net.viper.status.modules.customcommands; + +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.CopyOption; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Random; +import java.util.concurrent.TimeUnit; + +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.TextComponent; +import net.md_5.bungee.api.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.ChatEvent; +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 für das Interface Argument +import net.md_5.bungee.config.Configuration; +import net.md_5.bungee.config.ConfigurationProvider; +import net.md_5.bungee.config.YamlConfiguration; +import net.md_5.bungee.event.EventHandler; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +public class CustomCommandModule implements Module, Listener { + + private StatusAPI plugin; + private Configuration config; + private Command chatCommand; + + public CustomCommandModule() { + // Leerer Konstruktor + } + + @Override + public String getName() { + return "CustomCommandModule"; + } + + @Override + public void onEnable(Plugin plugin) { + // Hier casten wir 'Plugin' zu 'StatusAPI', da wir wissen, dass es das ist + this.plugin = (StatusAPI) plugin; + + this.plugin.getLogger().fine("Lade CustomCommandModule..."); + reloadConfig(); + if (this.config == null) { + this.config = new Configuration(); + this.plugin.getLogger().warning("customcommands.yml konnte nicht geladen werden. Verwende leere Konfiguration."); + } + + // /bcmds Reload Befehl registrieren + this.plugin.getProxy().getPluginManager().registerCommand(this.plugin, new Command("bcmds") { + @Override + public void execute(CommandSender sender, String[] args) { + if (!sender.hasPermission("statusapi.bcmds")) { + sender.sendMessage(new TextComponent(ChatColor.RED + "You don't have permission.")); + } else { + reloadConfig(); + sender.sendMessage(new TextComponent(ChatColor.GREEN + "Config reloaded.")); + } + } + }); + + // /chat Befehl registrieren (falls aktiviert) + if (config.getBoolean("chat-command", true)) { + chatCommand = new Command("chat") { + @Override + public void execute(CommandSender sender, String[] args) { + if (sender instanceof ProxiedPlayer) { + ProxiedPlayer player = (ProxiedPlayer) sender; + if (player.getServer() == null) { + player.sendMessage(new TextComponent(ChatColor.RED + "Konnte deinen Server nicht ermitteln. Bitte versuche es erneut.")); + return; + } + String msg = String.join(" ", args); + if (msg.trim().isEmpty()) { + player.sendMessage(new TextComponent(ChatColor.RED + "Bitte gib eine Nachricht an.")); + return; + } + ChatEvent e = new ChatEvent(player, player.getServer(), msg); + ProxyServer.getInstance().getPluginManager().callEvent(e); + if (!e.isCancelled()) { + if (!e.isCommand() || !ProxyServer.getInstance().getPluginManager().dispatchCommand(sender, msg.substring(1))) { + player.chat(msg); + } + } + } else { + String msg = String.join(" ", args); + if(msg.startsWith("/")) { + ProxyServer.getInstance().getPluginManager().dispatchCommand(sender, msg.substring(1)); + } else { + sender.sendMessage(new TextComponent("Console cannot send chat messages via /chat usually.")); + } + } + } + }; + this.plugin.getProxy().getPluginManager().registerCommand(this.plugin, chatCommand); + } + + this.plugin.getProxy().getPluginManager().registerListener(this.plugin, this); + } + + @Override + public void onDisable(Plugin plugin) { + // Optional: Cleanup logic, falls nötig. + // Wir nutzen hier das übergebene 'plugin' Argument (oder this.plugin, ist egal) + // Listener und Commands werden automatisch entfernt, wenn das Plugin stoppt. + } + + public void reloadConfig() { + try { + if (!this.plugin.getDataFolder().exists()) { + this.plugin.getDataFolder().mkdirs(); + } + File file = new File(this.plugin.getDataFolder(), "customcommands.yml"); + if (!file.exists()) { + // Kopieren aus Resources + InputStream in = this.plugin.getResourceAsStream("customcommands.yml"); + if (in == null) { + this.plugin.getLogger().warning("customcommands.yml nicht in JAR gefunden. Erstelle leere Datei."); + file.createNewFile(); + } else { + Files.copy(in, file.toPath(), new CopyOption[0]); + in.close(); + } + } + this.config = ConfigurationProvider.getProvider(YamlConfiguration.class).load(file); + } catch (IOException e) { + e.printStackTrace(); + this.plugin.getLogger().severe("Konnte customcommands.yml nicht laden!"); + } + } + + @EventHandler(priority = 64) + public void onCommand(ChatEvent e) { + if (!e.isCommand()) return; + if (!(e.getSender() instanceof ProxiedPlayer)) return; + + final ProxiedPlayer player = (ProxiedPlayer) e.getSender(); + String[] split = e.getMessage().split(" "); + String label = split[0].substring(1); + + final List args = new ArrayList<>(Arrays.asList(split)); + args.remove(0); + + Configuration cmds = config.getSection("commands"); + if (cmds == null) return; + + Configuration section = null; + String foundKey = null; + + for (String key : cmds.getKeys()) { + Configuration cmdSection = cmds.getSection(key); + if (key.equalsIgnoreCase(label)) { + section = cmdSection; + foundKey = key; + break; + } + for (String alias : cmdSection.getStringList("aliases")) { + if (alias.equalsIgnoreCase(label)) { + section = cmdSection; + foundKey = key; + break; + } + } + if (section != null) break; + } + + if (section == null) return; + + String type = section.getString("type", "line"); + String sendertype = section.getString("sender", "default"); + String permission = section.getString("permission", ""); + final List commands = section.getStringList("commands"); + + if (!permission.isEmpty() && !player.hasPermission(permission)) { + player.sendMessage(new TextComponent(ChatColor.RED + "You don't have permission.")); + e.setCancelled(true); + return; + } + + e.setCancelled(true); + + final CommandSender target; + if (sendertype.equals("default")) { + target = player; + } else if (sendertype.equals("admin")) { + target = new ForwardSender(player, true); + } else if (sendertype.equals("console")) { + target = ProxyServer.getInstance().getConsole(); + } else { + ProxiedPlayer targetPlayer = ProxyServer.getInstance().getPlayer(sendertype); + if (targetPlayer == null || !targetPlayer.isConnected()) { + player.sendMessage(new TextComponent(ChatColor.RED + "Player " + sendertype + " is not online.")); + return; + } + target = targetPlayer; + } + + String argsString = args.size() >= 1 ? String.join(" ", args) : ""; + final String finalArgs = argsString; + final String senderName = player.getName(); + + if (type.equals("random")) { + int randomIndex = new Random().nextInt(commands.size()); + String rawCommand = commands.get(randomIndex); + executeCommand(target, rawCommand, finalArgs, senderName); + } else if (type.equals("line")) { + ProxyServer.getInstance().getScheduler().runAsync(this.plugin, new Runnable() { + @Override + public void run() { + for (String rawCommand : commands) { + executeCommand(target, rawCommand, finalArgs, senderName); + try { + Thread.sleep(100L); + } catch (InterruptedException ex) { + ex.printStackTrace(); + } + } + } + }); + } else { + this.plugin.getLogger().warning("Unknown type '" + type + "' for command " + foundKey); + } + } + + private void executeCommand(CommandSender sender, String rawCommand, String args, String playerName) { + String parsed = rawCommand + .replace("%args%", args) + .replace("%sender%", playerName); + + String commandToDispatch = parsed.startsWith("/") ? parsed.substring(1) : parsed; + ProxyServer.getInstance().getPluginManager().dispatchCommand(sender, commandToDispatch); + } +} diff --git a/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java b/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java new file mode 100644 index 0000000..b1598bc --- /dev/null +++ b/src/main/java/net/viper/status/modules/customcommands/ForwardSender.java @@ -0,0 +1,118 @@ +package net.viper.status.modules.customcommands; + +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Collection; +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.chat.BaseComponent; +import net.md_5.bungee.api.connection.Connection; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.connection.Connection.Unsafe; + +public class ForwardSender implements CommandSender, Connection { + private ProxiedPlayer target; + private Boolean admin; + + public ForwardSender(ProxiedPlayer sender, Boolean admin) { + this.target = sender; + this.admin = admin; + } + + public ProxiedPlayer target() { + return this.target; + } + + @Override + public String getName() { + return this.target.getName(); + } + + @Override + public void sendMessage(String message) { + this.target.sendMessage(message); + } + + @Override + public void sendMessages(String... messages) { + this.target.sendMessages(messages); + } + + @Override + public void sendMessage(BaseComponent... message) { + this.target.sendMessage(message); + } + + @Override + public void sendMessage(BaseComponent message) { + this.target.sendMessage(message); + } + + @Override + public Collection getGroups() { + return this.target.getGroups(); + } + + @Override + public void addGroups(String... groups) { + this.target.addGroups(groups); + } + + @Override + public void removeGroups(String... groups) { + this.target.removeGroups(groups); + } + + @Override + public boolean hasPermission(String permission) { + return this.admin ? true : this.target.hasPermission(permission); + } + + @Override + public void setPermission(String permission, boolean value) { + this.target.setPermission(permission, value); + } + + @Override + public Collection getPermissions() { + Collection perms = new java.util.ArrayList<>(this.target.getPermissions()); + if (this.admin) { + perms.add("*"); + } + return perms; + } + + @Override + public InetSocketAddress getAddress() { + return this.target.getAddress(); + } + + @Override + public SocketAddress getSocketAddress() { + return this.target.getSocketAddress(); + } + + @Override + public void disconnect(String reason) { + this.target.disconnect(reason); + } + + @Override + public void disconnect(BaseComponent... reason) { + this.target.disconnect(reason); + } + + @Override + public void disconnect(BaseComponent reason) { + this.target.disconnect(reason); + } + + @Override + public boolean isConnected() { + return this.target.isConnected(); + } + + @Override + public Unsafe unsafe() { + return this.target.unsafe(); + } +} diff --git a/src/main/java/net/viper/status/modules/economy/EcoAdminCommand.java b/src/main/java/net/viper/status/modules/economy/EcoAdminCommand.java new file mode 100644 index 0000000..158629f --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/EcoAdminCommand.java @@ -0,0 +1,19 @@ +package net.viper.status.modules.economy; + +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; + +/** + * /ecoadmin – wird NICHT mehr auf BungeeCord registriert. + * NexEco /eco auf dem Spigot-Server übernimmt Admin-Befehle. + */ +public class EcoAdminCommand extends Command { + + public EcoAdminCommand(Plugin plugin, EconomyManager manager) { + super("ecoadmin_disabled_nexeco", "economy.admin"); + } + + @Override + public void execute(CommandSender sender, String[] args) {} +} diff --git a/src/main/java/net/viper/status/modules/economy/EconomyDatabase.java b/src/main/java/net/viper/status/modules/economy/EconomyDatabase.java new file mode 100644 index 0000000..6ce2ada --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/EconomyDatabase.java @@ -0,0 +1,232 @@ +package net.viper.status.modules.economy; + +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Plugin; + +import java.sql.*; +import java.util.UUID; +import java.util.logging.Logger; + +/** + * Verwaltet die MySQL-Verbindung (HikariCP) und die Tabelle bc_accounts. + * + * Fixes: + * - balance-Spalte als DOUBLE(30,2) statt VARCHAR → kompatibel mit NexEco & SurvivalPlus + * - atomare Transaktion für withdraw+deposit → kein Geldverlust bei Absturz + * - FOR UPDATE Lock → kein Race-Condition-Bug bei gleichzeitigen Überweisungen + */ +public class EconomyDatabase { + + private static final String TABLE = "bc_accounts"; + private static final String TABLE_NAMES = "bc_player_names"; + + private final Logger log; + private HikariDataSource dataSource; + + public EconomyDatabase(Plugin plugin, String host, int port, String database, String user, String password) { + this.log = plugin.getLogger(); + + HikariConfig cfg = new HikariConfig(); + java.util.logging.Logger.getLogger("com.zaxxer.hikari").setLevel(java.util.logging.Level.WARNING); + cfg.setJdbcUrl("jdbc:mysql://" + host + ":" + port + "/" + database + + "?useSSL=false&autoReconnect=true&characterEncoding=UTF-8&useUnicode=true" + + "&allowPublicKeyRetrieval=true"); + cfg.setUsername(user); + cfg.setPassword(password); + cfg.setMaximumPoolSize(5); + cfg.setMinimumIdle(1); + cfg.setConnectionTimeout(10_000); + cfg.setIdleTimeout(600_000); + cfg.setMaxLifetime(1_800_000); + cfg.setPoolName("StatusAPI-Economy"); + cfg.addDataSourceProperty("cachePrepStmts", "true"); + cfg.addDataSourceProperty("prepStmtCacheSize", "250"); + cfg.addDataSourceProperty("prepStmtCacheSqlLimit", "2048"); + + try { + dataSource = new HikariDataSource(cfg); + } catch (Exception e) { + log.severe("[Economy] MySQL-Verbindung fehlgeschlagen: " + e.getMessage()); + return; + } + + // ── bc_accounts: balance als DOUBLE – kompatibel mit NexEco & SurvivalPlus ── + try (Connection con = dataSource.getConnection(); Statement st = con.createStatement()) { + st.executeUpdate( + "CREATE TABLE IF NOT EXISTS `" + TABLE + "` (" + + " `player_name` VARCHAR(36) NOT NULL," + + " `balance` DOUBLE(30,2) NOT NULL DEFAULT 0.00," + + " PRIMARY KEY (`player_name`)" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" + ); + // Falls Tabelle existiert aber balance noch VARCHAR ist → konvertieren + st.executeUpdate( + "ALTER TABLE `" + TABLE + "` " + + "MODIFY COLUMN `balance` DOUBLE(30,2) NOT NULL DEFAULT 0.00" + ); + } catch (SQLException e) { + // ALTER schlägt fehl wenn Typ bereits korrekt ist – kein Problem + if (!e.getMessage().contains("Duplicate") && !e.getMessage().contains("doesn't exist")) { + log.warning("[Economy] Tabellen-Setup bc_accounts: " + e.getMessage()); + } + } + + // ── bc_player_names ──────────────────────────────────────────────────── + try (Connection con = dataSource.getConnection(); Statement st = con.createStatement()) { + st.executeUpdate( + "CREATE TABLE IF NOT EXISTS `" + TABLE_NAMES + "` (" + + " `uuid` VARCHAR(36) NOT NULL PRIMARY KEY," + + " `name` VARCHAR(16) NOT NULL," + + " `updated` BIGINT NOT NULL" + + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;" + ); + if (StatusAPI.DEBUG) log.info("[Economy] MySQL verbunden – Tabellen bereit."); + } catch (SQLException e) { + log.severe("[Economy] Tabellen-Setup bc_player_names fehlgeschlagen: " + e.getMessage()); + } + } + + public boolean isConnected() { + return dataSource != null && !dataSource.isClosed(); + } + + public void close() { + if (dataSource != null && !dataSource.isClosed()) dataSource.close(); + } + + // ── Kontostand ──────────────────────────────────────────────────────────── + + /** Lädt den Kontostand direkt aus der DB. Gibt -1 zurück wenn kein Eintrag. */ + public double load(UUID uuid) { + if (!isConnected()) return -1; + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "SELECT `balance` FROM `" + TABLE + "` WHERE `player_name` = ?")) { + ps.setString(1, uuid.toString()); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return rs.getDouble("balance"); + } + } catch (SQLException e) { + log.warning("[Economy] Load fehlgeschlagen für " + uuid + ": " + e.getMessage()); + } + return -1; + } + + /** Schreibt einen Kontostand in die DB (INSERT oder UPDATE). */ + public void save(UUID uuid, double balance) { + if (!isConnected()) return; + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO `" + TABLE + "` (`player_name`, `balance`) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE `balance` = VALUES(`balance`)")) { + ps.setString(1, uuid.toString()); + ps.setDouble(2, balance); + ps.executeUpdate(); + } catch (SQLException e) { + log.warning("[Economy] Save fehlgeschlagen für " + uuid + ": " + e.getMessage()); + } + } + + /** + * Atomare Überweisung von → to. + * Nutzt eine SQL-Transaktion mit FOR UPDATE Lock – race-condition-sicher. + * Gibt false zurück wenn Sender nicht genug Guthaben hat. + */ + public boolean transfer(UUID from, UUID to, double amount, double startBalance) { + if (!isConnected()) return false; + Connection con = null; + try { + con = dataSource.getConnection(); + con.setAutoCommit(false); + + // Sender sperren und Balance lesen + double fromBalance; + try (PreparedStatement ps = con.prepareStatement( + "SELECT `balance` FROM `" + TABLE + "` WHERE `player_name` = ? FOR UPDATE")) { + ps.setString(1, from.toString()); + try (ResultSet rs = ps.executeQuery()) { + fromBalance = rs.next() ? rs.getDouble("balance") : startBalance; + } + } + + if (fromBalance < amount) { con.rollback(); return false; } + + // Sender abziehen + try (PreparedStatement ps = con.prepareStatement( + "INSERT INTO `" + TABLE + "` (`player_name`, `balance`) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE `balance` = VALUES(`balance`)")) { + ps.setString(1, from.toString()); + ps.setDouble(2, fromBalance - amount); + ps.executeUpdate(); + } + + // Empfänger gutschreiben (Konto anlegen falls nötig) + try (PreparedStatement ps = con.prepareStatement( + "INSERT INTO `" + TABLE + "` (`player_name`, `balance`) VALUES (?, ?) " + + "ON DUPLICATE KEY UPDATE `balance` = `balance` + ?")) { + ps.setString(1, to.toString()); + ps.setDouble(2, startBalance + amount); + ps.setDouble(3, amount); + ps.executeUpdate(); + } + + con.commit(); + return true; + + } catch (SQLException e) { + log.warning("[Economy] Transfer fehlgeschlagen: " + e.getMessage()); + try { if (con != null) con.rollback(); } catch (SQLException ex) { /* ignore */ } + return false; + } finally { + try { if (con != null) con.close(); } catch (SQLException ex) { /* ignore */ } + } + } + + // ── Name-Lookup ─────────────────────────────────────────────────────────── + + public void saveNameMapping(UUID uuid, String name) { + if (!isConnected()) return; + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "INSERT INTO `" + TABLE_NAMES + "` (`uuid`, `name`, `updated`) VALUES (?, ?, ?) " + + "ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `updated` = VALUES(`updated`)")) { + ps.setString(1, uuid.toString()); + ps.setString(2, name); + ps.setLong(3, System.currentTimeMillis()); + ps.executeUpdate(); + } catch (SQLException e) { + log.warning("[Economy] Name-Mapping fehlgeschlagen: " + e.getMessage()); + } + } + + public UUID findUUIDByNameOwn(String name) { + if (!isConnected()) return null; + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "SELECT `uuid` FROM `" + TABLE_NAMES + "` WHERE `name` = ? LIMIT 1")) { + ps.setString(1, name); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) return UUID.fromString(rs.getString("uuid")); + } + } catch (SQLException | IllegalArgumentException e) { /* ignorieren */ } + return null; + } + + public UUID findUUIDByName(String name) { + if (!isConnected()) return null; + try (Connection con = dataSource.getConnection(); + PreparedStatement ps = con.prepareStatement( + "SELECT `player_uuid` FROM `CMI_users` WHERE `username` = ? LIMIT 1")) { + ps.setString(1, name); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + String s = rs.getString("player_uuid"); + if (s != null && !s.isEmpty()) return UUID.fromString(s); + } + } + } catch (SQLException | IllegalArgumentException e) { /* CMI nicht vorhanden – kein Problem */ } + return null; + } +} diff --git a/src/main/java/net/viper/status/modules/economy/EconomyListener.java b/src/main/java/net/viper/status/modules/economy/EconomyListener.java new file mode 100644 index 0000000..0672fd5 --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/EconomyListener.java @@ -0,0 +1,37 @@ +package net.viper.status.modules.economy; + +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +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.viper.status.StatusAPI; + +/** + * EconomyListener – nur noch Aufräumen der playerBalances Map. + * + * Das Befüllen der Map geschieht ausschließlich durch die StatusAPIBridge + * (Spigot) die über Vault/NexEco den Kontostand per HTTP an die StatusAPI sendet. + */ +public class EconomyListener implements Listener { + + public EconomyListener(Plugin plugin, EconomyManager manager) { + // EconomyManager wird nicht mehr benötigt + } + + @EventHandler + public void onLogin(PostLoginEvent event) { + // Wird von StatusAPIBridge befüllt – nichts zu tun beim Login + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent event) { + // Beim Logout aus der Map entfernen + StatusAPI.playerBalances.remove(event.getPlayer().getUniqueId()); + } + + public void cancelTasks() { + // Kein periodischer Task mehr nötig + } +} diff --git a/src/main/java/net/viper/status/modules/economy/EconomyManager.java b/src/main/java/net/viper/status/modules/economy/EconomyManager.java new file mode 100644 index 0000000..bf565de --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/EconomyManager.java @@ -0,0 +1,31 @@ +package net.viper.status.modules.economy; + +import net.md_5.bungee.api.plugin.Plugin; +import java.util.UUID; + +/** + * EconomyManager – Stub, nicht mehr aktiv. + * Economy wird ausschließlich über NexEco (Spigot) verwaltet. + */ +public class EconomyManager { + + public EconomyManager(Plugin plugin, EconomyDatabase db, double startBalance) {} + + public void saveNameMapping(UUID uuid, String name) {} + + public UUID resolveUUID(String name) { return null; } + + public double getBalance(UUID uuid) { return 0.0; } + + public void setBalance(UUID uuid, double amount) {} + + public boolean deposit(UUID uuid, double amount) { return false; } + + public boolean withdraw(UUID uuid, double amount) { return false; } + + public boolean transfer(UUID from, UUID to, double amount) { return false; } + + public boolean hasAccount(UUID uuid) { return false; } + + public double getStartBalance() { return 0.0; } +} diff --git a/src/main/java/net/viper/status/modules/economy/EconomyModule.java b/src/main/java/net/viper/status/modules/economy/EconomyModule.java new file mode 100644 index 0000000..0f3cf35 --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/EconomyModule.java @@ -0,0 +1,32 @@ +package net.viper.status.modules.economy; + +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.module.Module; + +/** + * EconomyModule – DEAKTIVIERT. + * + * Die Economy wird ausschließlich über NexEco (Spigot) verwaltet. + * Die StatusAPIBridge (Spigot-Plugin) liest den Kontostand über Vault/NexEco + * und pushed ihn per HTTP an die StatusAPI → playerBalances Map. + * + * Damit gibt es nur EINE Datenquelle für Kontostände: NexEco / money_accounts. + * Das alte EconomyModule schrieb in bc_accounts – das führte zu doppelten, + * inkonsistenten Kontoständen. + */ +public class EconomyModule implements Module { + + @Override + public String getName() { return "EconomyModule"; } + + @Override + public void onEnable(Plugin plugin) { + plugin.getLogger().info("[Economy] EconomyModule ist deaktiviert – NexEco ist zuständig."); + plugin.getLogger().info("[Economy] Kontostände kommen via StatusAPIBridge (Vault → NexEco → HTTP)."); + } + + @Override + public void onDisable(Plugin plugin) {} + + public EconomyManager getManager() { return null; } +} diff --git a/src/main/java/net/viper/status/modules/economy/PayCommand.java b/src/main/java/net/viper/status/modules/economy/PayCommand.java new file mode 100644 index 0000000..9c48eb7 --- /dev/null +++ b/src/main/java/net/viper/status/modules/economy/PayCommand.java @@ -0,0 +1,20 @@ +package net.viper.status.modules.economy; + +import net.md_5.bungee.api.CommandSender; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; + +/** + * /pay – wird NICHT mehr auf BungeeCord registriert. + * NexEco auf dem Spigot-Server übernimmt /pay direkt. + * Diese Klasse existiert nur noch für Kompilier-Kompatibilität. + */ +public class PayCommand extends Command { + + public PayCommand(Plugin plugin, EconomyManager manager) { + super("pay_disabled_nexeco", null); + } + + @Override + public void execute(CommandSender sender, String[] args) {} +} diff --git a/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java b/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java new file mode 100644 index 0000000..47aa8b1 --- /dev/null +++ b/src/main/java/net/viper/status/modules/forum/ForumBridgeModule.java @@ -0,0 +1,349 @@ +package net.viper.status.modules.forum; + +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ChatColor; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.CommandSender; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ProxyServer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ClickEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.HoverEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.TextComponent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Command; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Listener; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.md_5.bungee.event.EventHandler; +import net.viper.status.module.Module; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.List; +import java.util.Properties; +import java.util.concurrent.TimeUnit; + +/** + * ForumBridgeModule: Verbindet das WP Business Forum mit dem Minecraft-Server. + * + * Fix #13: extractJsonString() gibt jetzt immer einen leeren String statt null zurück. + * Alle Aufrufer müssen nicht mehr auf null prüfen, was NullPointerExceptions verhindert. + */ +public class ForumBridgeModule implements Module, Listener { + + private Plugin plugin; + private ForumNotifStorage storage; + + private boolean enabled = true; + private String wpBaseUrl = ""; + private String apiSecret = ""; + private int loginDelaySeconds = 3; + + @Override + public String getName() { return "ForumBridgeModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + loadConfig(plugin); + if (!enabled) { StatusAPI.debugLog(plugin, "ForumBridgeModule ist deaktiviert."); return; } + + storage = new ForumNotifStorage(plugin.getDataFolder(), plugin.getLogger()); + storage.load(); + + plugin.getProxy().getPluginManager().registerListener(plugin, this); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ForumLinkCommand()); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ForumCommand()); + + plugin.getProxy().getScheduler().schedule(plugin, () -> { + try { storage.save(); } catch (Exception e) { plugin.getLogger().warning("ForumBridge Auto-Save Fehler: " + e.getMessage()); } + }, 10, 10, TimeUnit.MINUTES); + + plugin.getProxy().getScheduler().schedule(plugin, () -> storage.purgeOld(30), 1, 24, TimeUnit.HOURS); + plugin.getLogger().fine("ForumBridgeModule aktiviert."); + } + + @Override + public void onDisable(Plugin plugin) { + if (storage != null) { storage.save(); StatusAPI.debugLog(plugin, "Forum-Benachrichtigungen gespeichert."); } + } + + private void loadConfig(Plugin plugin) { + try { + Properties props = new Properties(); + File configFile = new File(plugin.getDataFolder(), "verify.properties"); + if (configFile.exists()) { + try (FileInputStream fis = new FileInputStream(configFile)) { props.load(fis); } + } + this.enabled = !"false".equalsIgnoreCase(props.getProperty("forum.enabled", "true")); + this.wpBaseUrl = props.getProperty("forum.wp_url", props.getProperty("wp_verify_url", "")); + this.apiSecret = props.getProperty("forum.api_secret", ""); + this.loginDelaySeconds = parseInt(props.getProperty("forum.login_delay_seconds", "3"), 3); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Laden der ForumBridge-Config: " + e.getMessage()); + } + } + + private int parseInt(String s, int def) { try { return Integer.parseInt(s); } catch (Exception e) { return def; } } + + // ===== HTTP HANDLER ===== + + public String handleNotify(String body, String apiKeyHeader) { + if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) { + return "{\"success\":false,\"error\":\"unauthorized\"}"; + } + + // FIX #13: extractJsonString gibt "" statt null → kein NullPointerException möglich + String playerUuid = extractJsonString(body, "player_uuid"); + String type = extractJsonString(body, "type"); + String title = extractJsonString(body, "title"); + String author = extractJsonString(body, "author"); + String url = extractJsonString(body, "url"); + + if (playerUuid.isEmpty()) return "{\"success\":false,\"error\":\"missing_player_uuid\"}"; + + java.util.UUID uuid; + try { uuid = java.util.UUID.fromString(playerUuid); } + catch (Exception e) { return "{\"success\":false,\"error\":\"invalid_uuid\"}"; } + + if ("thread".equalsIgnoreCase(type) && title.toLowerCase().contains("umfrage")) type = "poll"; + if (type.isEmpty()) type = "reply"; + + // Alle Werte sind garantiert nicht null (extractJsonString gibt "" zurück) + ForumNotification notification = new ForumNotification(uuid, type, title, author, url); + + ProxiedPlayer online = ProxyServer.getInstance().getPlayer(uuid); + if (online != null && online.isConnected()) { + deliverNotification(online, notification); + notification.setDelivered(true); + return "{\"success\":true,\"delivered\":true}"; + } + + storage.add(notification); + return "{\"success\":true,\"delivered\":false}"; + } + + public String handleUnlink(String body, String apiKeyHeader) { + if (!apiSecret.isEmpty() && !apiSecret.equals(apiKeyHeader)) return "{\"success\":false,\"error\":\"unauthorized\"}"; + return "{\"success\":true}"; + } + + public String handleStatus() { + String version = "unknown"; + try { if (plugin.getDescription() != null) version = plugin.getDescription().getVersion(); } catch (Exception ignored) {} + return "{\"success\":true,\"module\":\"ForumBridgeModule\",\"version\":\"" + version + "\"}"; + } + + // ===== NOTIFICATION ===== + + private void deliverNotification(ProxiedPlayer player, ForumNotification notif) { + String color = notif.getTypeColor(); + String label = notif.getTypeLabel(); + player.sendMessage(new TextComponent("§8§m ")); + player.sendMessage(new TextComponent("§6§l✉ Forum §8» " + color + label)); + if (!notif.getTitle().isEmpty()) player.sendMessage(new TextComponent("§7 " + notif.getTitle())); + if (!notif.getAuthor().isEmpty()) player.sendMessage(new TextComponent("§7 von §f" + notif.getAuthor())); + if (!notif.getUrl().isEmpty()) { + TextComponent link = new TextComponent("§a ➜ Im Forum ansehen"); + link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, notif.getUrl())); + link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder("§7Klicke um den Beitrag im Forum zu öffnen").create())); + player.sendMessage(link); + } + player.sendMessage(new TextComponent("§8§m ")); + } + + private void deliverPending(ProxiedPlayer player) { + List pending = storage.getPending(player.getUniqueId()); + if (pending.isEmpty()) return; + int count = pending.size(); + if (count > 3) { + player.sendMessage(new TextComponent("§8§m ")); + player.sendMessage(new TextComponent("§6§l✉ Forum §8» §fDu hast §e" + count + " §fneue Benachrichtigungen!")); + player.sendMessage(new TextComponent("§7 Tippe §e/forum §7um sie anzuzeigen.")); + player.sendMessage(new TextComponent("§8§m ")); + } else { + for (ForumNotification n : pending) deliverNotification(player, n); + } + storage.markAllDelivered(player.getUniqueId()); + storage.clearDelivered(player.getUniqueId()); + } + + @EventHandler + public void onJoin(PostLoginEvent e) { + ProxiedPlayer player = e.getPlayer(); + plugin.getProxy().getScheduler().schedule(plugin, () -> { + if (player.isConnected()) deliverPending(player); + }, loginDelaySeconds, TimeUnit.SECONDS); + } + + // ===== COMMANDS ===== + + private class ForumLinkCommand extends Command { + public ForumLinkCommand() { super("forumlink", null, "fl"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (args.length != 1) { + p.sendMessage(new TextComponent("§eBenutzung: §f/forumlink ")); + p.sendMessage(new TextComponent("§7Den Token erhältst du in deinem Forum-Profil unter §fMinecraft-Verknüpfung§7.")); + return; + } + String token = args[0].trim().toUpperCase(); + if (wpBaseUrl.isEmpty()) { p.sendMessage(new TextComponent("§cForum-Verknüpfung ist nicht konfiguriert.")); return; } + p.sendMessage(new TextComponent("§7Überprüfe Token...")); + + plugin.getProxy().getScheduler().runAsync(plugin, () -> { + try { + String endpoint = wpBaseUrl + "/wp-json/mc-bridge/v1/verify-link"; + String payload = "{\"token\":\"" + escapeJson(token) + "\"," + + "\"mc_uuid\":\"" + p.getUniqueId() + "\"," + + "\"mc_name\":\"" + escapeJson(p.getName()) + "\"}"; + + HttpURLConnection conn = (HttpURLConnection) new URL(endpoint).openConnection(); + conn.setRequestMethod("POST"); + conn.setDoOutput(true); + conn.setConnectTimeout(5000); + conn.setReadTimeout(7000); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + if (!apiSecret.isEmpty()) conn.setRequestProperty("X-Api-Key", apiSecret); + + Charset utf8 = Charset.forName("UTF-8"); + try (OutputStream os = conn.getOutputStream()) { os.write(payload.getBytes(utf8)); } + + int code = conn.getResponseCode(); + String resp = code >= 200 && code < 300 + ? streamToString(conn.getInputStream(), utf8) + : streamToString(conn.getErrorStream(), utf8); + + if (resp != null && resp.contains("\"success\":true")) { + String displayName = extractJsonString(resp, "display_name"); + String username = extractJsonString(resp, "username"); + String show = !displayName.isEmpty() ? displayName : username; + p.sendMessage(new TextComponent("§8§m ")); + p.sendMessage(new TextComponent("§a§l✓ §fForum-Account erfolgreich verknüpft!")); + if (!show.isEmpty()) p.sendMessage(new TextComponent("§7 Forum-User: §f" + show)); + p.sendMessage(new TextComponent("§7 Du erhältst jetzt Ingame-Benachrichtigungen.")); + p.sendMessage(new TextComponent("§8§m ")); + } else { + String error = extractJsonString(resp, "error"); + String message = extractJsonString(resp, "message"); + if ("token_expired".equals(error)) p.sendMessage(new TextComponent("§c✗ Der Token ist abgelaufen.")); + else if ("uuid_already_linked".equals(error)) p.sendMessage(new TextComponent("§c✗ " + (!message.isEmpty() ? message : "Diese UUID ist bereits verknüpft."))); + else if ("invalid_token".equals(error)) p.sendMessage(new TextComponent("§c✗ Ungültiger Token.")); + else p.sendMessage(new TextComponent("§c✗ Verknüpfung fehlgeschlagen: " + (!error.isEmpty() ? error : "Unbekannter Fehler"))); + } + } catch (Exception ex) { + p.sendMessage(new TextComponent("§c✗ Fehler bei der Verbindung zum Forum.")); + plugin.getLogger().warning("ForumLink Fehler: " + ex.getMessage()); + } + }); + } + } + + private class ForumCommand extends Command { + public ForumCommand() { super("forum"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(new TextComponent("§cNur Spieler können diesen Befehl benutzen.")); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + List pending = storage.getPending(p.getUniqueId()); + + if (pending.isEmpty()) { + p.sendMessage(new TextComponent("§7Keine neuen Forum-Benachrichtigungen.")); + if (!wpBaseUrl.isEmpty()) { + TextComponent link = new TextComponent("§a➜ Forum öffnen"); + link.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, wpBaseUrl)); + link.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("§7Klicke um das Forum zu öffnen").create())); + p.sendMessage(link); + } + return; + } + + p.sendMessage(new TextComponent("§8§m ")); + p.sendMessage(new TextComponent("§6§l✉ Forum-Benachrichtigungen §8(§f" + pending.size() + "§8)")); + p.sendMessage(new TextComponent("")); + int shown = 0; + for (ForumNotification n : pending) { + if (shown >= 10) { p.sendMessage(new TextComponent("§7 ... und " + (pending.size() - 10) + " weitere")); break; } + String color = n.getTypeColor(); + TextComponent line = new TextComponent(color + " • " + n.getTypeLabel() + "§7: "); + TextComponent detail = new TextComponent(!n.getTitle().isEmpty() ? "§f" + n.getTitle() : "§fvon " + n.getAuthor()); + if (!n.getUrl().isEmpty()) { + detail.setClickEvent(new ClickEvent(ClickEvent.Action.OPEN_URL, n.getUrl())); + detail.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, new ComponentBuilder("§7Klicke zum Öffnen").create())); + } + line.addExtra(detail); + p.sendMessage(line); + shown++; + } + p.sendMessage(new TextComponent("")); + p.sendMessage(new TextComponent("§8§m ")); + storage.markAllDelivered(p.getUniqueId()); + storage.clearDelivered(p.getUniqueId()); + } + } + + // ===== HELPER ===== + + public ForumNotifStorage getStorage() { return storage; } + + /** + * FIX #13: Gibt immer einen leeren String zurück, niemals null. + * Verhindert NullPointerExceptions in allen Aufrufern. + */ + private static String extractJsonString(String json, String key) { + if (json == null || key == null) return ""; + String search = "\"" + key + "\""; + int idx = json.indexOf(search); + if (idx < 0) return ""; + int colon = json.indexOf(':', idx + search.length()); + if (colon < 0) return ""; + int i = colon + 1; + while (i < json.length() && Character.isWhitespace(json.charAt(i))) i++; + if (i >= json.length()) return ""; + char c = json.charAt(i); + if (c == '"') { + i++; + StringBuilder sb = new StringBuilder(); + boolean escape = false; + while (i < json.length()) { + char ch = json.charAt(i++); + if (escape) { sb.append(ch); escape = false; } + else { if (ch == '\\') escape = true; else if (ch == '"') break; else sb.append(ch); } + } + return sb.toString(); + } + return ""; + } + + private static String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } + + private static String streamToString(InputStream in, Charset charset) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, charset))) { + StringBuilder sb = new StringBuilder(); String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } +} diff --git a/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java b/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java new file mode 100644 index 0000000..8a283ca --- /dev/null +++ b/src/main/java/net/viper/status/modules/forum/ForumNotifStorage.java @@ -0,0 +1,127 @@ +package net.viper.status.modules.forum; + +import java.io.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.logging.Logger; + +/** + * Speichert ausstehende Forum-Benachrichtigungen (Datei-basiert). + * Benachrichtigungen die nicht sofort zugestellt werden konnten (Spieler offline) + * werden hier gespeichert und beim nächsten Login zugestellt. + */ +public class ForumNotifStorage { + + private final File file; + private final Logger logger; + + // UUID -> Liste ausstehender Notifications + private final ConcurrentHashMap> pending = new ConcurrentHashMap<>(); + + public ForumNotifStorage(File pluginFolder, Logger logger) { + if (!pluginFolder.exists()) pluginFolder.mkdirs(); + this.file = new File(pluginFolder, "forum_notifications.dat"); + this.logger = logger; + } + + /** + * Fügt eine Benachrichtigung hinzu. + */ + public void add(ForumNotification notification) { + pending.computeIfAbsent(notification.getPlayerUuid(), k -> new CopyOnWriteArrayList<>()) + .add(notification); + } + + /** + * Gibt alle ausstehenden (nicht zugestellten) Benachrichtigungen eines Spielers zurück. + */ + public List getPending(UUID playerUuid) { + CopyOnWriteArrayList list = pending.get(playerUuid); + if (list == null) return Collections.emptyList(); + List result = new ArrayList<>(); + for (ForumNotification n : list) { + if (!n.isDelivered()) result.add(n); + } + return result; + } + + /** + * Anzahl ausstehender Benachrichtigungen. + */ + public int getPendingCount(UUID playerUuid) { + CopyOnWriteArrayList list = pending.get(playerUuid); + if (list == null) return 0; + int count = 0; + for (ForumNotification n : list) { + if (!n.isDelivered()) count++; + } + return count; + } + + /** + * Markiert alle Benachrichtigungen eines Spielers als zugestellt und entfernt sie. + */ + public void clearDelivered(UUID playerUuid) { + CopyOnWriteArrayList list = pending.get(playerUuid); + if (list == null) return; + list.removeIf(ForumNotification::isDelivered); + if (list.isEmpty()) pending.remove(playerUuid); + } + + /** + * Markiert alle als zugestellt. + */ + public void markAllDelivered(UUID playerUuid) { + CopyOnWriteArrayList list = pending.get(playerUuid); + if (list == null) return; + for (ForumNotification n : list) { + n.setDelivered(true); + } + } + + /** + * Entfernt Benachrichtigungen die älter als maxDays Tage sind. + */ + public void purgeOld(int maxDays) { + long cutoff = System.currentTimeMillis() - ((long) maxDays * 24 * 60 * 60 * 1000); + for (Map.Entry> entry : pending.entrySet()) { + entry.getValue().removeIf(n -> n.getTimestamp() < cutoff); + if (entry.getValue().isEmpty()) pending.remove(entry.getKey()); + } + } + + // ===== Datei-Operationen ===== + + public void save() { + try (BufferedWriter bw = new BufferedWriter(new OutputStreamWriter(new FileOutputStream(file), "UTF-8"))) { + for (CopyOnWriteArrayList list : pending.values()) { + for (ForumNotification n : list) { + if (!n.isDelivered()) { + bw.write(n.toLine()); + bw.newLine(); + } + } + } + bw.flush(); + } catch (IOException e) { + logger.warning("Fehler beim Speichern der Forum-Benachrichtigungen: " + e.getMessage()); + } + } + + public void load() { + if (!file.exists()) return; + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + ForumNotification n = ForumNotification.fromLine(line); + if (n != null && !n.isDelivered()) { + pending.computeIfAbsent(n.getPlayerUuid(), k -> new CopyOnWriteArrayList<>()) + .add(n); + } + } + } catch (IOException e) { + logger.warning("Fehler beim Laden der Forum-Benachrichtigungen: " + e.getMessage()); + } + } +} diff --git a/src/main/java/net/viper/status/modules/forum/ForumNotification.java b/src/main/java/net/viper/status/modules/forum/ForumNotification.java new file mode 100644 index 0000000..9a9d2fa --- /dev/null +++ b/src/main/java/net/viper/status/modules/forum/ForumNotification.java @@ -0,0 +1,114 @@ +package net.viper.status.modules.forum; + +import java.util.UUID; + +/** + * Eine einzelne Forum-Benachrichtigung. + */ +public class ForumNotification { + + private final UUID playerUuid; + private final String type; // reply, mention, message + private final String title; + private final String author; + private final String url; + private final long timestamp; + private boolean delivered; + + public ForumNotification(UUID playerUuid, String type, String title, String author, String url) { + this.playerUuid = playerUuid; + this.type = type != null ? type : "reply"; + this.title = title != null ? title : ""; + this.author = author != null ? author : "Unbekannt"; + this.url = url != null ? url : ""; + this.timestamp = System.currentTimeMillis(); + this.delivered = false; + } + + /** Interner Konstruktor für Deserialisierung */ + ForumNotification(UUID playerUuid, String type, String title, String author, String url, long timestamp, boolean delivered) { + this.playerUuid = playerUuid; + this.type = type; + this.title = title; + this.author = author; + this.url = url; + this.timestamp = timestamp; + this.delivered = delivered; + } + + // --- Getter --- + + public UUID getPlayerUuid() { return playerUuid; } + public String getType() { return type; } + public String getTitle() { return title; } + public String getAuthor() { return author; } + public String getUrl() { return url; } + public long getTimestamp() { return timestamp; } + public boolean isDelivered() { return delivered; } + public void setDelivered(boolean d) { this.delivered = d; } + + /** + * Deutsches Label für den Benachrichtigungstyp. + */ + public String getTypeLabel() { + switch (type) { + case "reply": return "Neue Antwort"; + case "mention": return "Erwähnung"; + case "message": return "Neue PN"; + case "thread": return "Neuer Thread"; + case "poll": return "Neue Umfrage"; + case "answer": return "Antwort auf deinen Thread"; + default: return "Benachrichtigung"; + } + } + + /** + * Farbcode (Minecraft) je nach Typ. + */ + public String getTypeColor() { + switch (type) { + case "reply": return "§b"; // Aqua + case "mention": return "§e"; // Gelb + case "message": return "§d"; // Rosa + case "thread": return "§a"; // Grün + case "poll": return "§3"; // Dunkel-Aqua + case "answer": return "§2"; // Dunkel-Grün + default: return "§f"; // Weiß + } + } + + /** + * Serialisierung für Datei-Speicherung. + * Format: uuid|type|title|author|url|timestamp|delivered + */ + public String toLine() { + return playerUuid.toString() + "|" + + type + "|" + + title.replace("|", "_") + "|" + + author.replace("|", "_") + "|" + + url.replace("|", "_") + "|" + + timestamp + "|" + + (delivered ? "1" : "0"); + } + + /** + * Deserialisierung aus einer Zeile. + */ + public static ForumNotification fromLine(String line) { + if (line == null || line.trim().isEmpty()) return null; + String[] parts = line.split("\\|", -1); + if (parts.length < 7) return null; + try { + UUID uuid = UUID.fromString(parts[0]); + String type = parts[1]; + String title = parts[2]; + String author = parts[3]; + String url = parts[4]; + long timestamp = Long.parseLong(parts[5]); + boolean delivered = "1".equals(parts[6]); + return new ForumNotification(uuid, type, title, author, url, timestamp, delivered); + } catch (Exception e) { + return null; + } + } +} diff --git a/src/main/java/net/viper/status/modules/help/HelpModule.java b/src/main/java/net/viper/status/modules/help/HelpModule.java new file mode 100644 index 0000000..0edb0ea --- /dev/null +++ b/src/main/java/net/viper/status/modules/help/HelpModule.java @@ -0,0 +1,252 @@ +package net.viper.status.modules.help; + +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.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +/** + * HelpModule – seitenbasierte Ingame-Hilfe. + * + * verify.properties: + * statusapi.help=vn → /vn help [seite] + * statusapi.help.permission=statusapi.admin + */ +public class HelpModule implements Module { + + private String commandName = "help"; + private String adminPermission = "statusapi.admin"; + + @Override + public String getName() { return "HelpModule"; } + + @Override + public void onEnable(Plugin plugin) { + Properties props = ((StatusAPI) plugin).getVerifyProperties(); + if (props != null) { + String cn = props.getProperty("statusapi.help", "help").trim(); + if (!cn.isEmpty()) commandName = cn; + String ap = props.getProperty("statusapi.help.permission", "statusapi.admin").trim(); + if (!ap.isEmpty()) adminPermission = ap; + } + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, + new HelpCommand(commandName, adminPermission)); + plugin.getLogger().info("[HelpModule] /" + commandName + " help registriert (Admin-Permission: " + adminPermission + ")"); + } + + @Override + public void onDisable(Plugin plugin) {} + + // ───────────────────────────────────────────────────────────────────────── + + private static class HelpCommand extends Command { + + private final String adminPerm; + + // Jede Seite ist eine Liste von Zeilen + // Seiten werden zur Laufzeit je nach Berechtigung zusammengebaut + HelpCommand(String name, String adminPerm) { + super(name, null); + this.adminPerm = adminPerm; + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (args.length == 0) { + send(sender, "&7Nutze &e/" + getName() + " help &7für eine Befehlsübersicht."); + return; + } + if (!args[0].equalsIgnoreCase("help")) { + send(sender, "&cUnbekannter Unterbefehl. Nutze &e/" + getName() + " help&c."); + return; + } + + boolean isAdmin = !(sender instanceof ProxiedPlayer) + || sender.hasPermission(adminPerm) + || sender.hasPermission("statusapi.admin"); + + // Seiten aufbauen + List> pages = buildPages(isAdmin); + + int totalPages = pages.size(); + int page = 1; + + if (args.length >= 2) { + try { + page = Integer.parseInt(args[1].trim()); + } catch (NumberFormatException e) { + send(sender, "&cUngültige Seitenzahl. Nutze &e/" + getName() + " help <1-" + totalPages + ">&c."); + return; + } + } + + if (page < 1 || page > totalPages) { + send(sender, "&cSeite &e" + page + " &cexistiert nicht. Verfügbar: &e1&c-&e" + totalPages + "&c."); + return; + } + + // Header + send(sender, ""); + send(sender, "&8&m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + send(sender, " &6&lStatusAPI &8| &7Hilfe &8– &7Seite &e" + page + "&8/&e" + totalPages); + send(sender, "&8&m━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + send(sender, ""); + + // Seiteninhalte + for (String line : pages.get(page - 1)) { + send(sender, line); + } + + send(sender, ""); + + // Navigation + sendNavigation(sender, getName(), page, totalPages); + send(sender, ""); + } + + /** Baut alle Seiten zusammen. Admins bekommen zusätzliche Seiten. */ + private List> buildPages(boolean isAdmin) { + List> pages = new ArrayList<>(); + + // ── Seite 1: Allgemein & Chat ───────────────────────────────────── + List p1 = new ArrayList<>(); + p1.add(" &e&lAllgemein"); + p1.add(" &a/verify &8– &7Account verifizieren"); + p1.add(" &a/forumlink &8(&7/fl&8) &8– &7Forum-Account verknüpfen"); + p1.add(" &a/forum &8– &7Forum-Benachrichtigungen"); + p1.add(" &a/go [server] &8(&7/wechsel, /switch&8) &8– &7Serverwechsel"); + p1.add(" &a/scoreboard &8(&7/sb&8) [hide|show] &8– &7Scoreboard umschalten"); + p1.add(""); + p1.add(" &e&lChat"); + p1.add(" &a/msg &8(&7/w, /tell&8) &8– &7Private Nachricht"); + p1.add(" &a/r &8(&7/reply, /antwort&8) &8– &7Auf PN antworten"); + p1.add(" &a/ignore &8(&7/block&8) &8– &7Spieler ignorieren"); + p1.add(" &a/unignore &8(&7/unblock&8) &8– &7Ignorierung aufheben"); + p1.add(" &a/channel [kanal] &8(&7/ch, /kanal&8) &8– &7Kanal wechseln"); + p1.add(" &a/chataus &8(&7/togglechat&8) &8– &7Chat-Empfang umschalten"); + pages.add(p1); + + // ── Seite 2: Chat (weiter) & Account-Verknüpfungen ─────────────── + List p2 = new ArrayList<>(); + p2.add(" &e&lChat (Fortsetzung)"); + p2.add(" &a/emoji &8(&7/emojis&8) &8– &7Alle Emojis anzeigen"); + p2.add(" &a/mentions &8(&7/mention&8) &8– &7Mention-Benachrichtigungen"); + p2.add(" &a/helpop &8– &7Team um Hilfe bitten"); + p2.add(" &a/report &8– &7Spieler melden"); + p2.add(" &a/chatbypass &8(&7/cbp&8) &8– &7ChatModule überspringen"); + p2.add(""); + p2.add(" &e&lAccount-Verknüpfungen"); + p2.add(" &a/discordlink &8(&7/dlink&8) &8– &7Discord verknüpfen"); + p2.add(" &a/telegramlink &8(&7/tlink&8) &8– &7Telegram verknüpfen"); + p2.add(" &a/unlink &8– &7Verknüpfung aufheben"); + pages.add(p2); + + // ── Admin-Seiten nur für Berechtigte ────────────────────────────── + if (isAdmin) { + // ── Seite 3: StatusAPI, AntiBot, Vanish ─────────────────────── + List p3 = new ArrayList<>(); + p3.add(" &c&lAdmin &8– &eStatusAPI & AntiBot"); + p3.add(" &c/statusapi reload &8(&7/sapi reload&8) &8– &7Scoreboard & Tablist neu laden"); + p3.add(" &c/netinfo &8– &7Proxy- & Systeminfos"); + p3.add(""); + p3.add(" &c/antibot status &8– &7AntiBot-Status anzeigen"); + p3.add(" &c/antibot clearblocks &8– &7IP-Blockliste leeren"); + p3.add(" &c/antibot unblock &8– &7IP entsperren"); + p3.add(" &c/antibot profile &8– &7Schutzprofil wechseln"); + p3.add(" &c/antibot reload &8– &7AntiBot neu laden"); + p3.add(""); + p3.add(" &c&lAdmin &8– &eVanish"); + p3.add(" &c/vanish [Spieler] &8(&7/v&8) &8– &7Unsichtbar schalten"); + p3.add(" &c/vanishlist &8(&7/vlist&8) &8– &7Unsichtbare Spieler anzeigen"); + pages.add(p3); + + // ── Seite 4: Chat-Admin, Reports, sonstige ──────────────────── + List p4 = new ArrayList<>(); + p4.add(" &c&lAdmin &8– &eChat-Administration"); + p4.add(" &c/broadcast &8(&7/bc, /alert&8) &8– &7Broadcast an alle"); + p4.add(" &c/chatmute [Min.] &8(&7/gmute&8) &8– &7Spieler muten"); + p4.add(" &c/chatunmute &8(&7/gunmute&8) &8– &7Mute aufheben"); + p4.add(" &c/socialspy &8(&7/spy&8) &8– &7Private Nachrichten mitlesen"); + p4.add(" &c/chatinfo &8– &7Chat-Info eines Spielers"); + p4.add(" &c/chathist [Spieler] [n] &8– &7Chat-Verlauf anzeigen"); + p4.add(" &c/chatreload &8– &7Chat-Konfiguration neu laden"); + p4.add(""); + p4.add(" &c&lAdmin &8– &eReports, Tools"); + p4.add(" &c/reports [all] &8– &7Offene Reports anzeigen"); + p4.add(" &c/reportclose &8– &7Report schließen"); + p4.add(" &c/automessage reload &8– &7AutoMessage neu laden"); + p4.add(" &c/bcmds reload &8– &7Custom-Commands neu laden"); + p4.add(" &c/cb &8– &7Command-Blocker verwalten"); + p4.add(" &c/scoreboard admin|player &8– &7Admin/Spieler-Ansicht wechseln"); + pages.add(p4); + } + + return pages; + } + + /** Sendet eine klickbare Navigationszeile mit ◀ Seite X/Y ▶ */ + private void sendNavigation(CommandSender sender, String cmd, int page, int total) { + // Für Konsole: einfacher Text + if (!(sender instanceof ProxiedPlayer)) { + String nav = " "; + if (page > 1) nav += "&7[&e◀&7] "; + nav += "&8Seite &e" + page + "&8/&e" + total; + if (page < total) nav += " &7[&e▶&7]"; + send(sender, nav); + return; + } + + // Für Spieler: klickbare Buttons + TextComponent line = new TextComponent(" "); + + // ◀ zurück + if (page > 1) { + TextComponent prev = new TextComponent( + ChatColor.translateAlternateColorCodes('&', "&7[&e◀&7] ")); + prev.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, + "/" + cmd + " help " + (page - 1))); + prev.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(ChatColor.translateAlternateColorCodes('&', + "&7Seite &e" + (page - 1) + " &7anzeigen")).create())); + line.addExtra(prev); + } + + // Seitenanzeige + TextComponent mid = new TextComponent( + ChatColor.translateAlternateColorCodes('&', + "&8Seite &e" + page + "&8/&e" + total)); + line.addExtra(mid); + + // ▶ vor + if (page < total) { + TextComponent next = new TextComponent( + ChatColor.translateAlternateColorCodes('&', " &7[&e▶&7]")); + next.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, + "/" + cmd + " help " + (page + 1))); + next.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(ChatColor.translateAlternateColorCodes('&', + "&7Seite &e" + (page + 1) + " &7anzeigen")).create())); + line.addExtra(next); + } + + sender.sendMessage(line); + } + + private static void send(CommandSender s, String text) { + s.sendMessage(new TextComponent( + ChatColor.translateAlternateColorCodes('&', text))); + } + } +} diff --git a/src/main/java/net/viper/status/modules/network/MultiAccountGuard.java b/src/main/java/net/viper/status/modules/network/MultiAccountGuard.java new file mode 100644 index 0000000..6464d2c --- /dev/null +++ b/src/main/java/net/viper/status/modules/network/MultiAccountGuard.java @@ -0,0 +1,445 @@ +package net.viper.status.modules.network; + +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 net.md_5.bungee.api.event.PostLoginEvent; +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.luckperms.api.LuckPermsProvider; +import net.luckperms.api.model.user.User; +import net.luckperms.api.node.Node; +import net.luckperms.api.node.types.PermissionNode; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; +import net.viper.status.modules.antibot.AntiBotModule; + +import java.io.*; +import java.net.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Logger; + +/** + * MultiAccountGuard + * + * Features: + * - IP-Check: blockiert zweiten Account von gleicher IP + * - Bypass NUR über LuckPerms (OP zählt nicht) + * - Persistentes Log in multiaccountguard.log + * - Staff-Benachrichtigung ingame (Permission: statusapi.staff.notify) + * - Temporärer IP-Bann nach X Versuchen (Integration mit AntiBotModule) + * - Discord-Webhook bei Konflikt + */ +public class MultiAccountGuard implements Module, Listener { + + private static final String CONFIG_FILE = "network-guard.properties"; + private static final String LOG_FILE = "multiaccountguard.log"; + public static final String BYPASS_PERM = "statusapi.multiaccountguard.bypass"; + public static final String STAFF_PERM = "statusapi.staff.notify"; + + private static final DateTimeFormatter LOG_FMT = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss").withZone(ZoneId.systemDefault()); + + private Plugin plugin; + private Logger log; + private File logFile; + + // Config + private boolean enabled = true; + private boolean checkIp = true; + private boolean kickExisting = false; + private String kickMessage = "&cDu bist bereits mit einem anderen Account online!\n&7Bitte trenne deinen anderen Account zuerst."; + + // Staff-Benachrichtigung + private boolean staffNotifyEnabled = true; + private String staffNotifyFormat = "&8[&cMAG&8] &e{blocked} &7wurde blockiert &8(2. Account von &e{existing}&8) &7| IP: &f{ip}"; + + // Temporärer IP-Bann + private boolean tempBanEnabled = true; + private int tempBanMaxAttempts = 3; + private int tempBanDurationSecs = 300; + /** IP → Anzahl Konflikte seit letztem Reset */ + private final Map attemptsByIp = new ConcurrentHashMap<>(); + + // Webhook + private boolean webhookEnabled = true; + private String webhookUrl = ""; + private String webhookUsername = "StatusAPI"; + private String webhookThumbnailUrl = ""; + + @Override public String getName() { return "MultiAccountGuard"; } + + // ------------------------------------------------------------------------- + // Enable / Disable + // ------------------------------------------------------------------------- + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + this.log = plugin.getLogger(); + this.logFile = new File(plugin.getDataFolder(), LOG_FILE); + + loadConfig(); + + if (!enabled) { + log.info("[MultiAccountGuard] Deaktiviert."); + return; + } + + ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + + log.info("[MultiAccountGuard] Aktiv | IP-Check=" + checkIp + + " | kickExisting=" + kickExisting + + " | staffNotify=" + staffNotifyEnabled + + " | tempBan=" + tempBanEnabled + "(max=" + tempBanMaxAttempts + ", " + tempBanDurationSecs + "s)" + + " | Webhook=" + (webhookEnabled && !webhookUrl.isEmpty())); + log.info("[MultiAccountGuard] Bypass NUR via LuckPerms: /lp user permission set " + BYPASS_PERM + " true"); + log.info("[MultiAccountGuard] Log-Datei: " + logFile.getAbsolutePath()); + } + + @Override + public void onDisable(Plugin plugin) {} + + // ------------------------------------------------------------------------- + // Event + // ------------------------------------------------------------------------- + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPostLogin(PostLoginEvent event) { + if (!enabled) return; + + ProxiedPlayer joining = event.getPlayer(); + + if (hasBypass(joining)) { + log.info("[MultiAccountGuard] " + joining.getName() + " hat Bypass (LuckPerms) – übersprungen."); + return; + } + + UUID joiningUuid = joining.getUniqueId(); + String joiningIp = extractIp(joining.getSocketAddress()); + + if (joiningIp == null) { + log.warning("[MultiAccountGuard] Konnte IP von " + joining.getName() + " nicht lesen – übersprungen."); + return; + } + + log.info("[MultiAccountGuard] Login-Check: " + joining.getName() + + " | UUID=" + joiningUuid + " | IP=" + joiningIp); + + // Alle anderen Spieler (sich selbst per UUID ausschließen) + List others = new ArrayList<>(); + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.getUniqueId().equals(joiningUuid)) continue; + others.add(p); + } + + for (ProxiedPlayer online : others) { + if (hasBypass(online)) continue; + + String onlineIp = extractIp(online.getSocketAddress()); + if (onlineIp == null) continue; + + if (checkIp && joiningIp.equals(onlineIp)) { + log.warning("[MultiAccountGuard] KONFLIKT: " + + joining.getName() + " (" + joiningUuid + ")" + + " <-> " + online.getName() + " (" + online.getUniqueId() + ")" + + " IP=" + joiningIp); + handleConflict(joining, online, joiningIp); + return; + } + } + + log.info("[MultiAccountGuard] " + joining.getName() + " – kein Konflikt."); + } + + // ------------------------------------------------------------------------- + // Konflikt behandeln + // ------------------------------------------------------------------------- + + private void handleConflict(ProxiedPlayer joining, ProxiedPlayer existing, String ip) { + + TextComponent msg = new TextComponent( + ChatColor.translateAlternateColorCodes('&', kickMessage)); + + final String blockedName, allowedName; + final UUID blockedUuid, allowedUuid; + + if (kickExisting) { + existing.disconnect(msg); + blockedName = existing.getName(); blockedUuid = existing.getUniqueId(); + allowedName = joining.getName(); allowedUuid = joining.getUniqueId(); + log.warning("[MultiAccountGuard] Bestehender Account " + existing.getName() + " getrennt."); + } else { + joining.disconnect(msg); + blockedName = joining.getName(); blockedUuid = joining.getUniqueId(); + allowedName = existing.getName(); allowedUuid = existing.getUniqueId(); + log.warning("[MultiAccountGuard] Neuer Account " + joining.getName() + " blockiert."); + } + + // 1. Persistentes Log + writeLog(blockedName, blockedUuid, allowedName, allowedUuid, ip); + + // 2. Staff-Benachrichtigung + if (staffNotifyEnabled) { + notifyStaff(blockedName, allowedName, ip); + } + + // 3. Temporärer IP-Bann + if (tempBanEnabled) { + int attempts = attemptsByIp.merge(ip, 1, Integer::sum); + log.info("[MultiAccountGuard] IP " + ip + " hat " + attempts + "/" + tempBanMaxAttempts + " Versuche."); + if (attempts >= tempBanMaxAttempts) { + attemptsByIp.remove(ip); + banIp(ip); + } + } + + // 4. Discord-Webhook (async) + if (webhookEnabled && webhookUrl != null && !webhookUrl.isEmpty()) { + final String bn = blockedName, an = allowedName; + final UUID bu = blockedUuid, au = allowedUuid; + final int att = attemptsByIp.getOrDefault(ip, tempBanMaxAttempts); + ProxyServer.getInstance().getScheduler().runAsync(plugin, + () -> sendWebhook(bn, bu, an, au, ip, att)); + } + } + + // ------------------------------------------------------------------------- + // 1. Persistentes Log + // ------------------------------------------------------------------------- + + private void writeLog(String blockedName, UUID blockedUuid, + String allowedName, UUID allowedUuid, String ip) { + try { + if (!logFile.getParentFile().exists()) logFile.getParentFile().mkdirs(); + + String line = String.format("[%s] KONFLIKT | Geblockt: %s (%s) | Online: %s (%s) | IP: %s%n", + LOG_FMT.format(Instant.now()), + blockedName, blockedUuid, + allowedName, allowedUuid, + ip); + + Files.write(logFile.toPath(), line.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + + } catch (Exception e) { + log.warning("[MultiAccountGuard] Log-Fehler: " + e.getMessage()); + } + } + + // ------------------------------------------------------------------------- + // 2. Staff-Benachrichtigung + // ------------------------------------------------------------------------- + + private void notifyStaff(String blockedName, String existingName, String ip) { + String raw = staffNotifyFormat + .replace("{blocked}", blockedName) + .replace("{existing}", existingName) + .replace("{ip}", ip); + String formatted = ChatColor.translateAlternateColorCodes('&', raw); + TextComponent msg = new TextComponent(formatted); + + int notified = 0; + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.hasPermission(STAFF_PERM)) { + p.sendMessage(msg); + notified++; + } + } + log.info("[MultiAccountGuard] Staff-Benachrichtigung gesendet an " + notified + " Spieler."); + } + + // ------------------------------------------------------------------------- + // 3. Temporärer IP-Bann via AntiBotModule + // ------------------------------------------------------------------------- + + private void banIp(String ip) { + try { + StatusAPI statusApi = (StatusAPI) ProxyServer.getInstance() + .getPluginManager().getPlugin("StatusAPI"); + if (statusApi == null) { + log.warning("[MultiAccountGuard] StatusAPI nicht gefunden – IP-Bann nicht möglich."); + return; + } + AntiBotModule antiBot = statusApi.getModuleManager().getModule(AntiBotModule.class); + if (antiBot == null) { + log.warning("[MultiAccountGuard] AntiBotModule nicht gefunden – IP-Bann nicht möglich."); + return; + } + antiBot.blockIpExternal(ip, tempBanDurationSecs); + log.warning("[MultiAccountGuard] IP " + ip + " für " + tempBanDurationSecs + "s gebannt (zu viele Multi-Account-Versuche)."); + + // Staff über den Bann informieren + String banMsg = ChatColor.translateAlternateColorCodes('&', + "&8[&cMAG&8] &7IP &f" + ip + " &7wurde für &c" + tempBanDurationSecs + "s &7gebannt &8(zu viele Versuche)."); + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.hasPermission(STAFF_PERM)) { + p.sendMessage(new TextComponent(banMsg)); + } + } + + // In Log schreiben + try { + String line = String.format("[%s] IP-BANN | IP: %s | Dauer: %ds%n", + LOG_FMT.format(Instant.now()), ip, tempBanDurationSecs); + Files.write(logFile.toPath(), line.getBytes(StandardCharsets.UTF_8), + StandardOpenOption.CREATE, StandardOpenOption.APPEND); + } catch (Exception ignored) {} + + } catch (Exception e) { + log.warning("[MultiAccountGuard] IP-Bann Fehler: " + e.getMessage()); + } + } + + // ------------------------------------------------------------------------- + // 4. Discord-Webhook + // ------------------------------------------------------------------------- + + private void sendWebhook(String blockedName, UUID blockedUuid, + String allowedName, UUID allowedUuid, + String ip, int attempts) { + StringBuilder fields = new StringBuilder(); + appendField(fields, "\uD83D\uDEAB Geblockter Account", + blockedName + "\n`" + blockedUuid + "`", false); + appendField(fields, "\u2705 Verbundener Account", + allowedName + "\n`" + allowedUuid + "`", false); + appendField(fields, "\uD83C\uDF10 IP", "`" + ip + "`", true); + appendField(fields, "Aktion", + kickExisting ? "Alter Account getrennt" : "Neuer Account blockiert", true); + if (tempBanEnabled) { + appendField(fields, "\u26A0\uFE0F Versuche", + attempts + " / " + tempBanMaxAttempts + + (attempts >= tempBanMaxAttempts ? " \u2192 IP gebannt!" : ""), true); + } + + String body = "{\"username\":\"" + esc(webhookUsername) + "\"," + + "\"embeds\":[{" + + "\"title\":\"\uD83D\uDD12 Multi-Account erkannt\"," + + "\"description\":\"Ein Spieler hat versucht mit einem zweiten Account beizutreten.\"," + + "\"color\":15158332," + + "\"fields\":[" + fields + "]," + + "\"footer\":{\"text\":\"StatusAPI \u2022 MultiAccountGuard\"}," + + "\"timestamp\":\"" + Instant.now() + "\"" + + (webhookThumbnailUrl != null && !webhookThumbnailUrl.isEmpty() + ? ",\"thumbnail\":{\"url\":\"" + esc(webhookThumbnailUrl) + "\"}" : "") + + "}]}"; + + HttpURLConnection conn = null; + try { + byte[] bytes = body.getBytes(StandardCharsets.UTF_8); + conn = (HttpURLConnection) new URL(webhookUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(8000); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setRequestProperty("Content-Length", String.valueOf(bytes.length)); + try (OutputStream os = conn.getOutputStream()) { os.write(bytes); } + int code = conn.getResponseCode(); + if (code < 200 || code >= 300) log.warning("[MultiAccountGuard] Webhook HTTP " + code); + else log.info("[MultiAccountGuard] Webhook gesendet (HTTP " + code + ")"); + } catch (Exception e) { + log.warning("[MultiAccountGuard] Webhook-Fehler: " + e.getMessage()); + } finally { + if (conn != null) conn.disconnect(); + } + } + + private void appendField(StringBuilder sb, String name, String value, boolean inline) { + if (sb.length() > 0) sb.append(","); + sb.append("{\"name\":\"").append(esc(name)) + .append("\",\"value\":\"").append(esc(value)) + .append("\",\"inline\":").append(inline).append("}"); + } + + private String esc(String s) { + if (s == null) return ""; + return s.replace("\\","\\\\").replace("\"","\\\"") + .replace("\n","\\n").replace("\r","\\r"); + } + + // ------------------------------------------------------------------------- + // Bypass – NUR LuckPerms, kein OP-Fallback + // ------------------------------------------------------------------------- + + private boolean hasBypass(ProxiedPlayer player) { + try { + User user = LuckPermsProvider.get().getUserManager().getUser(player.getUniqueId()); + if (user == null) return false; + for (Node node : user.getNodes()) { + if (node instanceof PermissionNode) { + PermissionNode pn = (PermissionNode) node; + if (pn.getPermission().equalsIgnoreCase(BYPASS_PERM) && pn.getValue()) { + return true; + } + } + } + return false; + } catch (Exception e) { + log.warning("[MultiAccountGuard] LuckPerms-Check fehlgeschlagen für " + player.getName() + ": " + e.getMessage()); + return false; + } + } + + // ------------------------------------------------------------------------- + // Config + // ------------------------------------------------------------------------- + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE); + if (!file.exists()) { + log.info("[MultiAccountGuard] Config nicht gefunden – Defaults werden verwendet."); + return; + } + Properties p = new Properties(); + try (FileInputStream fis = new FileInputStream(file); + InputStreamReader r = new InputStreamReader(fis, StandardCharsets.UTF_8)) { + p.load(r); + + enabled = Boolean.parseBoolean(p.getProperty("multiaccountguard.enabled", "true")); + checkIp = Boolean.parseBoolean(p.getProperty("multiaccountguard.check_ip", "true")); + kickExisting = Boolean.parseBoolean(p.getProperty("multiaccountguard.kick_existing", "false")); + kickMessage = p.getProperty("multiaccountguard.kick_message", kickMessage); + + staffNotifyEnabled = Boolean.parseBoolean(p.getProperty("multiaccountguard.staff_notify.enabled", "true")); + staffNotifyFormat = p.getProperty("multiaccountguard.staff_notify.format", staffNotifyFormat); + + tempBanEnabled = Boolean.parseBoolean(p.getProperty("multiaccountguard.tempban.enabled", "true")); + tempBanMaxAttempts = parseInt(p.getProperty("multiaccountguard.tempban.max_attempts", "3"), 3); + tempBanDurationSecs = parseInt(p.getProperty("multiaccountguard.tempban.duration_secs", "300"), 300); + + webhookEnabled = Boolean.parseBoolean(p.getProperty("multiaccountguard.webhook.enabled", "true")); + webhookUrl = p.getProperty("networkinfo.webhook.url", "").trim(); + webhookUsername = p.getProperty("networkinfo.webhook.username", "StatusAPI").trim(); + webhookThumbnailUrl = p.getProperty("networkinfo.webhook.thumbnail_url", "").trim(); + + } catch (Exception e) { + log.warning("[MultiAccountGuard] Config-Fehler: " + e.getMessage()); + } + } + + private int parseInt(String s, int fallback) { + try { return Integer.parseInt(s == null ? "" : s.trim()); } + catch (Exception ignored) { return fallback; } + } + + // ------------------------------------------------------------------------- + + private String extractIp(SocketAddress addr) { + if (addr instanceof InetSocketAddress) + return ((InetSocketAddress) addr).getAddress().getHostAddress(); + return null; + } + + public boolean isEnabled() { return enabled; } + public boolean isCheckIp() { return checkIp; } + public boolean isKickExisting() { return kickExisting; } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java new file mode 100644 index 0000000..d95a8da --- /dev/null +++ b/src/main/java/net/viper/status/modules/network/NetworkInfoModule.java @@ -0,0 +1,862 @@ +package net.viper.status.modules.network; + +import com.sun.management.OperatingSystemMXBean; +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.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.plugin.Command; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.scheduler.ScheduledTask; +import net.viper.status.module.Module; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.lang.management.ManagementFactory; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Properties; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Liefert erweiterte Proxy- und Systeminformationen für API und Ingame-Debug. + */ +public class NetworkInfoModule implements Module { + + private static final String CONFIG_FILE_NAME = "network-guard.properties"; + + private Plugin plugin; + private long startedAtMillis; + + private boolean enabled = true; + private boolean commandEnabled = true; + private boolean includePlayerNames = false; + + private boolean webhookEnabled = false; + private String webhookUrl = ""; + private String webhookUsername = "StatusAPI"; + private String webhookThumbnailUrl = ""; + private boolean webhookNotifyStartStop = true; + private String webhookEmbedMode = "detailed"; + private int webhookCheckSeconds = 30; + private int alertMemoryPercent = 90; + private int alertPlayerPercent = 95; + private int alertCooldownSeconds = 300; + private boolean alertTpsEnabled = true; + private double alertTpsThreshold = 18.0D; + private boolean attackNotificationsEnabled = true; + private String attackApiKey = ""; + private String attackDefaultSource = "Viper-Network"; + private long lastMemoryAlertAt = 0L; + private long lastPlayerAlertAt = 0L; + private long lastTpsAlertAt = 0L; + private volatile double currentProxyTps = 20.0D; + + /** FIX: Öffentlicher Getter damit ScoreboardModule als TPS-Fallback darauf zugreifen kann */ + public double getProxyTps() { return currentProxyTps; } + private long lastTpsSampleAtMs = 0L; + private ScheduledTask alertTask; + private ScheduledTask tpsSamplerTask; + + @Override + public String getName() { + return "NetworkInfoModule"; + } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + this.startedAtMillis = System.currentTimeMillis(); + ensureModuleConfigExists(); + loadConfig(); + + if (!enabled) { + + return; + } + + if (commandEnabled) { + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new NetInfoCommand()); + } + + tpsSamplerTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::sampleProxyTps, 1, 1, TimeUnit.SECONDS); + + if (webhookEnabled && !webhookUrl.isEmpty()) { + if (webhookNotifyStartStop) { + boolean delivered = sendLifecycleStartNotification(); + if (!delivered) { + plugin.getLogger().warning("[NetworkInfoModule] Start-Webhook konnte nicht direkt zugestellt werden. Wiederhole in 10 Sekunden."); + ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { + boolean retryDelivered = sendLifecycleStartNotification(); + if (!retryDelivered) { + plugin.getLogger().warning("[NetworkInfoModule] Start-Webhook auch beim zweiten Versuch fehlgeschlagen."); + } + }, 10L, TimeUnit.SECONDS); + } + } + int interval = Math.max(10, webhookCheckSeconds); + alertTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::evaluateAndSendAlerts, interval, interval, TimeUnit.SECONDS); + } + + + } + + @Override + public void onDisable(Plugin plugin) { + if (alertTask != null) { + alertTask.cancel(); + alertTask = null; + } + if (tpsSamplerTask != null) { + tpsSamplerTask.cancel(); + tpsSamplerTask = null; + } + + if (enabled && webhookEnabled && webhookNotifyStartStop && webhookUrl != null && !webhookUrl.isEmpty()) { + boolean delivered = sendLifecycleStopNotification(); + if (!delivered) { + plugin.getLogger().warning("[NetworkInfoModule] Stop-Webhook konnte nicht zugestellt werden."); + } + } + } + + private boolean sendLifecycleStartNotification() { + if (isCompactEmbedMode()) { + return sendWebhookEmbed( + webhookUrl, + "✅ NetworkInfo gestartet", + "Proxy: **" + ProxyServer.getInstance().getName() + "**\nÜberwachung und Webhook-Alerts sind jetzt aktiv.", + 0x2ECC71, + null, + false + ); + } + + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Proxy", ProxyServer.getInstance().getName(), true); + appendEmbedField(fields, "Modus", "Detailed", true); + appendEmbedField(fields, "Check-Intervall", Math.max(10, webhookCheckSeconds) + "s", true); + return sendWebhookEmbed( + webhookUrl, + "✅ NetworkInfo gestartet", + "Überwachung und Webhook-Alerts sind jetzt aktiv.", + 0x2ECC71, + fields.toString(), + false + ); + } + + private boolean sendLifecycleStopNotification() { + if (isCompactEmbedMode()) { + return sendWebhookEmbed( + webhookUrl, + "🛑 NetworkInfo gestoppt", + "Die NetworkInfo-Überwachung wurde gestoppt.\nKeine weiteren Auto-Alerts bis zum nächsten Start.", + 0xE74C3C, + null, + false + ); + } + + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Proxy", ProxyServer.getInstance().getName(), true); + appendEmbedField(fields, "Modus", "Detailed", true); + appendEmbedField(fields, "Status", "Monitoring pausiert", false); + return sendWebhookEmbed( + webhookUrl, + "🛑 NetworkInfo gestoppt", + "Die NetworkInfo-Überwachung wurde gestoppt.", + 0xE74C3C, + fields.toString(), + false + ); + } + + public boolean isEnabled() { + return enabled; + } + + public boolean isAttackNotificationsEnabled() { + return enabled && attackNotificationsEnabled; + } + + public boolean isAttackApiKeyValid(String providedKey) { + if (attackApiKey == null || attackApiKey.isEmpty()) { + return true; + } + return providedKey != null && attackApiKey.equals(providedKey.trim()); + } + + public boolean sendAttackNotification(String eventType, + Integer connectionsPerSecond, + Integer blockedIps, + Long blockedConnections, + String source) { + if (!isAttackNotificationsEnabled()) { + return false; + } + + String usedSource = (source == null || source.trim().isEmpty()) ? attackDefaultSource : source.trim(); + String type = eventType == null ? "detected" : eventType.trim().toLowerCase(Locale.ROOT); + + String title; + String shortText; + int color; + if ("stopped".equals(type) || "mitigated".equals(type)) { + title = "✅ Attack Stopped"; + shortText = "Traffic hat sich normalisiert."; + color = 0x2ECC71; + } else { + title = "🚨 Attack Detected"; + shortText = "Ungewöhnlich hoher Verbindungs-Traffic erkannt."; + color = 0xE74C3C; + } + + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Source", usedSource, true); + appendEmbedField(fields, "Event", type.toUpperCase(Locale.ROOT), true); + + if (connectionsPerSecond != null && connectionsPerSecond >= 0) { + appendEmbedField(fields, "Connections / Second", String.valueOf(connectionsPerSecond), true); + } + if (blockedIps != null && blockedIps >= 0) { + appendEmbedField(fields, "Blocked IPs", String.valueOf(blockedIps), true); + } + if (blockedConnections != null && blockedConnections >= 0L) { + appendEmbedField(fields, "Blocked Connections", String.valueOf(blockedConnections), true); + } + + sendWebhookAttackEmbed(webhookUrl, title, shortText, color, fields.toString()); + return true; + } + + public Map buildSnapshot() { + Map out = new LinkedHashMap(); + + long now = System.currentTimeMillis(); + long uptimeMs = Math.max(0L, now - startedAtMillis); + + Runtime rt = Runtime.getRuntime(); + long maxMemory = rt.maxMemory(); + long totalMemory = rt.totalMemory(); + long freeMemory = rt.freeMemory(); + long usedMemory = totalMemory - freeMemory; + + Map memory = new LinkedHashMap(); + memory.put("used_mb", bytesToMb(usedMemory)); + memory.put("free_mb", bytesToMb(freeMemory)); + memory.put("total_mb", bytesToMb(totalMemory)); + memory.put("max_mb", bytesToMb(maxMemory)); + memory.put("usage_percent", percent(usedMemory, Math.max(1L, maxMemory))); + + int onlinePlayers = ProxyServer.getInstance().getPlayers().size(); + int maxPlayers = ProxyServer.getInstance().getConfig().getPlayerLimit(); + + // getPlayerLimit() liefert -1 wenn kein globales Limit gesetzt ist. + // In diesem Fall den Listener-Wert (angezeigte Max-Spielerzahl im Server-Ping) nutzen. + if (maxPlayers <= 0) { + try { + java.util.Iterator listenerIt = + ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (listenerIt.hasNext()) { + int listenerMax = listenerIt.next().getMaxPlayers(); + if (listenerMax > 0) { + maxPlayers = listenerMax; + } + } + } catch (Exception ignored) {} + } + + Map ping = buildPingSummary(ProxyServer.getInstance().getPlayers()); + + Map players = new LinkedHashMap(); + players.put("online", onlinePlayers); + players.put("max", maxPlayers); + players.put("occupancy_percent", percent(onlinePlayers, Math.max(1, maxPlayers))); + players.put("bedrock_online", countBedrockPlayers()); + players.put("ping", ping); + + List> backend = buildBackendDistribution(); + + Map system = new LinkedHashMap(); + system.put("java_version", System.getProperty("java.version")); + system.put("java_vendor", System.getProperty("java.vendor")); + system.put("os_name", System.getProperty("os.name")); + system.put("os_arch", System.getProperty("os.arch")); + system.put("available_processors", Runtime.getRuntime().availableProcessors()); + system.put("system_load_percent", getSystemLoadPercent()); + system.put("proxy_tps", roundDouble(currentProxyTps, 2)); + + out.put("enabled", true); + out.put("timestamp_unix", now / 1000L); + out.put("uptime_seconds", uptimeMs / 1000L); + out.put("uptime_human", formatDuration(uptimeMs)); + out.put("players", players); + out.put("backend_servers", backend); + out.put("memory", memory); + out.put("system", system); + out.put("features", buildFeatureSummary()); + + if (includePlayerNames) { + List> playerNames = new ArrayList>(); + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + Map entry = new LinkedHashMap(); + entry.put("name", p.getName()); + try { entry.put("uuid", p.getUniqueId().toString()); } catch (Exception ignored) {} + try { + if (p.getServer() != null && p.getServer().getInfo() != null) { + entry.put("server", p.getServer().getInfo().getName()); + } + } catch (Exception ignored) {} + playerNames.add(entry); + } + out.put("player_names", playerNames); + } + + return out; + } + + private Map buildPingSummary(Collection players) { + Map ping = new LinkedHashMap(); + if (players.isEmpty()) { + ping.put("avg_ms", 0); + ping.put("min_ms", 0); + ping.put("max_ms", 0); + return ping; + } + + long sum = 0L; + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + + for (ProxiedPlayer p : players) { + int ms = Math.max(0, p.getPing()); + sum += ms; + if (ms < min) min = ms; + if (ms > max) max = ms; + } + + ping.put("avg_ms", Math.round((double) sum / (double) players.size())); + ping.put("min_ms", min == Integer.MAX_VALUE ? 0 : min); + ping.put("max_ms", max == Integer.MIN_VALUE ? 0 : max); + return ping; + } + + private List> buildBackendDistribution() { + List> list = new ArrayList>(); + for (Map.Entry entry : ProxyServer.getInstance().getServers().entrySet()) { + ServerInfo info = entry.getValue(); + + Map row = new LinkedHashMap(); + row.put("name", entry.getKey()); + row.put("online_players", info.getPlayers().size()); + row.put("address", String.valueOf(info.getAddress())); + list.add(row); + } + return list; + } + + private Map buildFeatureSummary() { + Map features = new LinkedHashMap(); + features.put("luckperms", ProxyServer.getInstance().getPluginManager().getPlugin("LuckPerms") != null); + features.put("floodgate", isFloodgateAvailable()); + return features; + } + + private boolean isFloodgateAvailable() { + try { + Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + return true; + } catch (Throwable ignored) { + return false; + } + } + + private int countBedrockPlayers() { + try { + Class apiClass = Class.forName("org.geysermc.floodgate.api.FloodgateApi"); + Object api = apiClass.getMethod("getInstance").invoke(null); + int count = 0; + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + Boolean isBedrock = (Boolean) api.getClass().getMethod("isBedrockPlayer", UUID.class).invoke(api, p.getUniqueId()); + if (Boolean.TRUE.equals(isBedrock)) { + count++; + } + } + return count; + } catch (Throwable ignored) { + return 0; + } + } + + private Integer getSystemLoadPercent() { + try { + java.lang.management.OperatingSystemMXBean bean = ManagementFactory.getOperatingSystemMXBean(); + if (bean instanceof OperatingSystemMXBean) { + double load = ((OperatingSystemMXBean) bean).getSystemCpuLoad(); + if (load >= 0D) { + return (int) Math.round(load * 100D); + } + } + } catch (Throwable ignored) { + } + return null; + } + + private int bytesToMb(long bytes) { + return (int) (bytes / (1024L * 1024L)); + } + + private int percent(long value, long max) { + if (max <= 0L) return 0; + return (int) Math.min(100L, Math.round((value * 100.0D) / max)); + } + + private String formatDuration(long ms) { + long totalSeconds = ms / 1000L; + long days = totalSeconds / 86400L; + long hours = (totalSeconds % 86400L) / 3600L; + long minutes = (totalSeconds % 3600L) / 60L; + long seconds = totalSeconds % 60L; + return String.format(Locale.ROOT, "%dd %02dh %02dm %02ds", days, hours, minutes, seconds); + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (!file.exists()) { + enabled = true; + commandEnabled = true; + includePlayerNames = false; + webhookEnabled = false; + webhookUrl = ""; + webhookUsername = "StatusAPI"; + webhookThumbnailUrl = ""; + webhookNotifyStartStop = true; + webhookEmbedMode = "detailed"; + webhookCheckSeconds = 30; + alertMemoryPercent = 90; + alertPlayerPercent = 95; + alertCooldownSeconds = 300; + alertTpsEnabled = true; + alertTpsThreshold = 18.0D; + attackNotificationsEnabled = true; + attackApiKey = ""; + attackDefaultSource = "BetterBungee"; + return; + } + + Properties props = new Properties(); + try (FileInputStream in = new FileInputStream(file)) { + props.load(new InputStreamReader(in, StandardCharsets.UTF_8)); + enabled = Boolean.parseBoolean(props.getProperty("networkinfo.enabled", "true")); + commandEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.command.enabled", "true")); + includePlayerNames = Boolean.parseBoolean(props.getProperty("networkinfo.include_player_names", "false")); + + webhookEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.webhook.enabled", "false")); + webhookUrl = props.getProperty("networkinfo.webhook.url", "").trim(); + webhookUsername = props.getProperty("networkinfo.webhook.username", "StatusAPI").trim(); + webhookThumbnailUrl = props.getProperty("networkinfo.webhook.thumbnail_url", "").trim(); + webhookNotifyStartStop = Boolean.parseBoolean(props.getProperty("networkinfo.webhook.notify_start_stop", "true")); + webhookEmbedMode = props.getProperty("networkinfo.webhook.embed_mode", "detailed").trim(); + webhookCheckSeconds = parseInt(props.getProperty("networkinfo.webhook.check_seconds", "30"), 30); + alertMemoryPercent = parseInt(props.getProperty("networkinfo.alert.memory_percent", "90"), 90); + alertPlayerPercent = parseInt(props.getProperty("networkinfo.alert.player_percent", "95"), 95); + alertCooldownSeconds = parseInt(props.getProperty("networkinfo.alert.cooldown_seconds", "300"), 300); + alertTpsEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.alert.tps_enabled", "true")); + alertTpsThreshold = parseDouble(props.getProperty("networkinfo.alert.tps_threshold", "18.0"), 18.0D); + attackNotificationsEnabled = Boolean.parseBoolean(props.getProperty("networkinfo.attack.enabled", "true")); + attackApiKey = props.getProperty("networkinfo.attack.api_key", "").trim(); + attackDefaultSource = props.getProperty("networkinfo.attack.source", "BetterBungee").trim(); + } catch (Exception e) { + plugin.getLogger().warning("[NetworkInfoModule] Fehler beim Laden von " + CONFIG_FILE_NAME + ": " + e.getMessage()); + enabled = true; + commandEnabled = true; + includePlayerNames = false; + webhookEnabled = false; + webhookUrl = ""; + webhookUsername = "StatusAPI"; + webhookThumbnailUrl = ""; + webhookNotifyStartStop = true; + webhookEmbedMode = "detailed"; + webhookCheckSeconds = 30; + alertMemoryPercent = 90; + alertPlayerPercent = 95; + alertCooldownSeconds = 300; + alertTpsEnabled = true; + alertTpsThreshold = 18.0D; + attackNotificationsEnabled = true; + attackApiKey = ""; + attackDefaultSource = "BetterBungee"; + } + } + + private void ensureModuleConfigExists() { + File target = new File(plugin.getDataFolder(), CONFIG_FILE_NAME); + if (target.exists()) { + return; + } + + if (!plugin.getDataFolder().exists()) { + plugin.getDataFolder().mkdirs(); + } + + try (InputStream in = plugin.getResourceAsStream(CONFIG_FILE_NAME); + OutputStream out = new FileOutputStream(target)) { + if (in == null) { + plugin.getLogger().warning("[NetworkInfoModule] Standarddatei " + CONFIG_FILE_NAME + " nicht im JAR gefunden."); + return; + } + byte[] buffer = new byte[4096]; + int read; + while ((read = in.read(buffer)) != -1) { + out.write(buffer, 0, read); + } + + } catch (Exception e) { + plugin.getLogger().warning("[NetworkInfoModule] Konnte " + CONFIG_FILE_NAME + " nicht erstellen: " + e.getMessage()); + } + } + + private void evaluateAndSendAlerts() { + if (!enabled || !webhookEnabled || webhookUrl == null || webhookUrl.isEmpty()) { + return; + } + + long now = System.currentTimeMillis(); + Map snapshot = buildSnapshot(); + + @SuppressWarnings("unchecked") + Map memory = (Map) snapshot.get("memory"); + @SuppressWarnings("unchecked") + Map players = (Map) snapshot.get("players"); + + int memoryPercent = toInt(memory.get("usage_percent")); + int playerPercent = toInt(players.get("occupancy_percent")); + double proxyTps = currentProxyTps; + + if (memoryPercent >= Math.max(1, alertMemoryPercent) && canSend(lastMemoryAlertAt, now)) { + lastMemoryAlertAt = now; + if (isCompactEmbedMode()) { + sendWebhookEmbed( + webhookUrl, + "⚠️ Hohe RAM-Auslastung", + "Aktuell: **" + memoryPercent + "%**\nVerbrauch: **" + memory.get("used_mb") + " MB / " + memory.get("max_mb") + " MB**\nSchwelle: **" + alertMemoryPercent + "%**", + 0xF39C12 + ); + } else { + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "RAM-Nutzung", memoryPercent + "%", true); + appendEmbedField(fields, "Schwelle", alertMemoryPercent + "%", true); + appendEmbedField(fields, "Verbrauch", memory.get("used_mb") + " MB / " + memory.get("max_mb") + " MB", false); + sendWebhookEmbed( + webhookUrl, + "⚠️ Hohe RAM-Auslastung", + "Ein Schwellwert wurde überschritten.", + 0xF39C12, + fields.toString() + ); + } + } + + if (playerPercent >= Math.max(1, alertPlayerPercent) && canSend(lastPlayerAlertAt, now)) { + lastPlayerAlertAt = now; + if (isCompactEmbedMode()) { + sendWebhookEmbed( + webhookUrl, + "📈 Hohe Spieler-Auslastung", + "Auslastung: **" + playerPercent + "%**\nSpieler: **" + players.get("online") + "/" + players.get("max") + "**\nSchwelle: **" + alertPlayerPercent + "%**", + 0x3498DB + ); + } else { + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Auslastung", playerPercent + "%", true); + appendEmbedField(fields, "Schwelle", alertPlayerPercent + "%", true); + appendEmbedField(fields, "Spieler", players.get("online") + "/" + players.get("max"), true); + sendWebhookEmbed( + webhookUrl, + "📈 Hohe Spieler-Auslastung", + "Die Spielerlast ist aktuell sehr hoch.", + 0x3498DB, + fields.toString() + ); + } + } + + if (alertTpsEnabled && proxyTps > 0D && proxyTps < Math.max(1D, alertTpsThreshold) && canSend(lastTpsAlertAt, now)) { + lastTpsAlertAt = now; + String tpsText = String.format(Locale.ROOT, "%.2f", proxyTps); + String thresholdText = String.format(Locale.ROOT, "%.2f", Math.max(1D, alertTpsThreshold)); + + if (isCompactEmbedMode()) { + sendWebhookEmbed( + webhookUrl, + "🟥 Niedrige Proxy-TPS", + "Aktuell: **" + tpsText + " TPS**\nSchwelle: **" + thresholdText + " TPS**", + 0xE74C3C + ); + } else { + StringBuilder fields = new StringBuilder(); + appendEmbedField(fields, "Proxy TPS", tpsText, true); + appendEmbedField(fields, "Schwelle", thresholdText, true); + appendEmbedField(fields, "Check-Intervall", Math.max(10, webhookCheckSeconds) + "s", true); + sendWebhookEmbed( + webhookUrl, + "🟥 Niedrige Proxy-TPS", + "Die gemessene Proxy-TPS liegt unter der konfigurierten Schwelle.", + 0xE74C3C, + fields.toString() + ); + } + } + } + + private void sampleProxyTps() { + long now = System.currentTimeMillis(); + if (lastTpsSampleAtMs <= 0L) { + lastTpsSampleAtMs = now; + currentProxyTps = 20.0D; + return; + } + + long deltaMs = now - lastTpsSampleAtMs; + lastTpsSampleAtMs = now; + if (deltaMs <= 0L) { + return; + } + + // 1s Scheduler-Tick sollte etwa 20 TPS entsprechen. Abweichung zeigt Main-Thread-Lag. + double instantTps = (1000.0D / (double) deltaMs) * 20.0D; + instantTps = Math.max(0.1D, Math.min(20.0D, instantTps)); + currentProxyTps = (currentProxyTps * 0.7D) + (instantTps * 0.3D); + } + + private boolean isCompactEmbedMode() { + return "compact".equalsIgnoreCase(webhookEmbedMode == null ? "" : webhookEmbedMode.trim()); + } + + private boolean canSend(long lastSentAt, long now) { + long cooldownMs = Math.max(10, alertCooldownSeconds) * 1000L; + return (now - lastSentAt) >= cooldownMs; + } + + private int parseInt(String s, int fallback) { + try { + return Integer.parseInt(s == null ? "" : s.trim()); + } catch (Exception ignored) { + return fallback; + } + } + + private double parseDouble(String s, double fallback) { + try { + return Double.parseDouble(s == null ? "" : s.trim()); + } catch (Exception ignored) { + return fallback; + } + } + + private int toInt(Object o) { + if (o instanceof Number) { + return ((Number) o).intValue(); + } + try { + return Integer.parseInt(String.valueOf(o)); + } catch (Exception ignored) { + return 0; + } + } + + private double roundDouble(double value, int digits) { + double factor = Math.pow(10D, Math.max(0, digits)); + return Math.round(value * factor) / factor; + } + + private boolean sendWebhookEmbed(String targetWebhookUrl, String title, String description, int color) { + return sendWebhookEmbed(targetWebhookUrl, title, description, color, null, true); + } + + private boolean sendWebhookEmbed(String targetWebhookUrl, String title, String description, int color, String fieldsJson) { + return sendWebhookEmbed(targetWebhookUrl, title, description, color, fieldsJson, true); + } + + private boolean sendWebhookEmbed(String targetWebhookUrl, String title, String description, int color, String fieldsJson, boolean async) { + if (targetWebhookUrl == null || targetWebhookUrl.isEmpty()) { + return false; + } + + StringBuilder embed = new StringBuilder(); + embed.append("{\"username\":\"").append(escapeJson(webhookUsername)).append("\",") + .append("\"embeds\":[{\"title\":\"").append(escapeJson(title)).append("\",") + .append("\"description\":\"").append(escapeJson(description)).append("\",") + .append("\"color\":").append(color).append(","); + + if (fieldsJson != null && !fieldsJson.trim().isEmpty()) { + embed.append("\"fields\":[").append(fieldsJson).append("],"); + } + + embed.append("\"footer\":{\"text\":\"StatusPulse • NetworkInfo\"},") + .append("\"timestamp\":\"").append(Instant.now().toString()).append("\""); + + if (webhookThumbnailUrl != null && !webhookThumbnailUrl.isEmpty()) { + embed.append(",\"thumbnail\":{\"url\":\"").append(escapeJson(webhookThumbnailUrl)).append("\"}"); + } + + embed.append("}]}"); + return postWebhookPayload(targetWebhookUrl, embed.toString(), async); + } + + private void sendWebhookAttackEmbed(String targetWebhookUrl, + String title, + String description, + int color, + String fieldsJson) { + if (targetWebhookUrl == null || targetWebhookUrl.isEmpty()) { + return; + } + + StringBuilder embed = new StringBuilder(); + embed.append("{\"username\":\"").append(escapeJson(webhookUsername)).append("\",") + .append("\"embeds\":[{\"title\":\"").append(escapeJson(title)).append("\",") + .append("\"description\":\"").append(escapeJson(description)).append("\",") + .append("\"color\":").append(color).append(",") + .append("\"fields\":[").append(fieldsJson).append("],") + .append("\"footer\":{\"text\":\"StatusPulse • Network Guard\"},") + .append("\"timestamp\":\"").append(Instant.now().toString()).append("\""); + + if (webhookThumbnailUrl != null && !webhookThumbnailUrl.isEmpty()) { + embed.append(",\"thumbnail\":{\"url\":\"").append(escapeJson(webhookThumbnailUrl)).append("\"}"); + } + + embed.append("}]}"); + postWebhookPayload(targetWebhookUrl, embed.toString(), true); + } + + private void appendEmbedField(StringBuilder out, String name, String value, boolean inline) { + if (value == null || value.trim().isEmpty()) { + return; + } + if (out.length() > 0) { + out.append(","); + } + out.append("{\"name\":\"").append(escapeJson(name)).append("\",") + .append("\"value\":\"").append(escapeJson(value.trim())).append("\",") + .append("\"inline\":").append(inline ? "true" : "false") + .append("}"); + } + + private boolean postWebhookPayload(String targetWebhookUrl, String payload, boolean async) { + if (async) { + ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> executeWebhookPost(targetWebhookUrl, payload)); + return true; + } + return executeWebhookPost(targetWebhookUrl, payload); + } + + private boolean executeWebhookPost(String targetWebhookUrl, String payload) { + HttpURLConnection conn = null; + try { + byte[] bytes = payload.getBytes(StandardCharsets.UTF_8); + + conn = (HttpURLConnection) new URL(targetWebhookUrl).openConnection(); + conn.setRequestMethod("POST"); + conn.setConnectTimeout(5000); + conn.setReadTimeout(8000); + conn.setDoOutput(true); + conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8"); + conn.setRequestProperty("Content-Length", String.valueOf(bytes.length)); + + try (OutputStream os = conn.getOutputStream()) { + os.write(bytes); + } + + int code = conn.getResponseCode(); + if (code >= 200 && code < 300) { + return true; + } + + plugin.getLogger().warning("[NetworkInfoModule] Discord Webhook HTTP " + code + ": " + readErrorBody(conn)); + return false; + } catch (Exception e) { + plugin.getLogger().warning("[NetworkInfoModule] Discord Webhook Fehler: " + e.getMessage()); + return false; + } finally { + if (conn != null) { + conn.disconnect(); + } + } + } + + private String readErrorBody(HttpURLConnection conn) { + if (conn == null) { + return ""; + } + try (InputStream errorStream = conn.getErrorStream()) { + if (errorStream == null) { + return ""; + } + byte[] bytes = new byte[1024]; + int read = errorStream.read(bytes); + if (read <= 0) { + return ""; + } + return new String(bytes, 0, read, StandardCharsets.UTF_8).trim(); + } catch (Exception ignored) { + return ""; + } + } + + private String escapeJson(String s) { + if (s == null) return ""; + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r"); + } + + private class NetInfoCommand extends Command { + + NetInfoCommand() { + super("netinfo", "statusapi.netinfo"); + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!enabled) { + sender.sendMessage(ChatColor.RED + "NetworkInfoModule ist deaktiviert."); + return; + } + + Map snapshot = buildSnapshot(); + @SuppressWarnings("unchecked") + Map players = (Map) snapshot.get("players"); + @SuppressWarnings("unchecked") + Map memory = (Map) snapshot.get("memory"); + @SuppressWarnings("unchecked") + Map system = (Map) snapshot.get("system"); + @SuppressWarnings("unchecked") + Map ping = (Map) players.get("ping"); + + sender.sendMessage(ChatColor.GOLD + "----- StatusAPI NetworkInfo -----"); + sender.sendMessage(ChatColor.YELLOW + "Uptime: " + ChatColor.WHITE + snapshot.get("uptime_human")); + sender.sendMessage(ChatColor.YELLOW + "Spieler: " + ChatColor.WHITE + players.get("online") + "/" + players.get("max") + ChatColor.GRAY + " (Bedrock: " + players.get("bedrock_online") + ")"); + sender.sendMessage(ChatColor.YELLOW + "Ping: " + ChatColor.WHITE + "avg " + ping.get("avg_ms") + "ms, min " + ping.get("min_ms") + "ms, max " + ping.get("max_ms") + "ms"); + sender.sendMessage(ChatColor.YELLOW + "RAM: " + ChatColor.WHITE + memory.get("used_mb") + "MB / " + memory.get("max_mb") + "MB" + ChatColor.GRAY + " (" + memory.get("usage_percent") + "%)"); + sender.sendMessage(ChatColor.YELLOW + "Proxy TPS: " + ChatColor.WHITE + system.get("proxy_tps") + ChatColor.GRAY + " (Alert < " + String.format(Locale.ROOT, "%.2f", alertTpsThreshold) + ")"); + sender.sendMessage(ChatColor.YELLOW + "Backends: " + ChatColor.WHITE + ((List) snapshot.get("backend_servers")).size()); + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java b/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java new file mode 100644 index 0000000..beed418 --- /dev/null +++ b/src/main/java/net/viper/status/modules/scoreboard/ScoreboardModule.java @@ -0,0 +1,1980 @@ +package net.viper.status.modules.scoreboard; + +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.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.event.ServerSwitchEvent; +import net.md_5.bungee.api.event.TabCompleteEvent; +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.api.scheduler.ScheduledTask; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.protocol.packet.ScoreboardDisplay; +import net.md_5.bungee.protocol.packet.ScoreboardObjective; +import net.md_5.bungee.protocol.packet.ScoreboardObjective.HealthDisplay; +import net.md_5.bungee.protocol.packet.ScoreboardScore; +import net.md_5.bungee.protocol.packet.Team; +import net.md_5.bungee.protocol.packet.Team.NameTagVisibility; +import net.md_5.bungee.protocol.packet.Team.CollisionRule; +import net.md_5.bungee.protocol.util.Either; +import net.md_5.bungee.protocol.data.NumberFormat; +import net.viper.status.StatusAPI; +import net.viper.status.module.Module; + +import java.io.*; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.text.DecimalFormat; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class ScoreboardModule implements Module, Listener { + + private static final String CONFIG_FILE = "scoreboard.properties"; + private static final String OBJ_NAME = "vpsb"; + private static final String OBJ_NAME_ADMIN = "vpsbadmin"; + private static final String OBJ_NAME_SUPP = "vpsbsupp"; + + public static final ConcurrentHashMap playerHealth = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerCompass = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerTps = new ConcurrentHashMap<>(); + // Neue Placeholder-Daten + public static final ConcurrentHashMap playerX = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerY = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerZ = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerWorld = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerGamemode= new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerExp = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerFood = new ConcurrentHashMap<>(); + public static final ConcurrentHashMap playerSpeed = new ConcurrentHashMap<>(); + + // ── TicketSystem Placeholder ────────────────────────────────────────────── + /** Eigene aktive Tickets des Spielers (OPEN + CLAIMED + FORWARDED) */ + public static final ConcurrentHashMap ticketMyOpen = new ConcurrentHashMap<>(); + /** Alle offenen Tickets gesamt (Status: OPEN) – für Supporter & Admin */ + public static final java.util.concurrent.atomic.AtomicInteger ticketTotalOpen = new java.util.concurrent.atomic.AtomicInteger(0); + /** Alle Tickets in Bearbeitung gesamt (Status: CLAIMED) – für Admin */ + public static final java.util.concurrent.atomic.AtomicInteger ticketTotalClaimed = new java.util.concurrent.atomic.AtomicInteger(0); + /** Positive Bewertungen gesamt – für Admin */ + public static final java.util.concurrent.atomic.AtomicInteger ticketRatingGood = new java.util.concurrent.atomic.AtomicInteger(0); + /** Negative Bewertungen gesamt – für Admin */ + public static final java.util.concurrent.atomic.AtomicInteger ticketRatingBad = new java.util.concurrent.atomic.AtomicInteger(0); + + private final ConcurrentHashMap joinTimes = new ConcurrentHashMap<>(); + /** Aktuell gerenderter Spieler – für PAPI-Auflösung in ph() */ + private UUID currentPlayerUuid = null; + // Spieler, die das Scoreboard ausgeblendet haben + /** FIX: Referenz auf NetworkInfoModule für TPS-Fallback */ + private net.viper.status.modules.network.NetworkInfoModule networkInfoModule = null; + + /** Wird von StatusAPI nach dem Registrieren aller Module aufgerufen */ + public void setNetworkInfoModule(net.viper.status.modules.network.NetworkInfoModule nim) { + this.networkInfoModule = nim; + } + + private final Set hiddenPlayers = ConcurrentHashMap.newKeySet(); + // Spieler, die manuell auf Player-Board gezwungen wurden (Override) + private final Set forcePlayerView = ConcurrentHashMap.newKeySet(); + // Spieler, die manuell auf Supporter-Board gezwungen wurden + private final Set forceSupporterView = ConcurrentHashMap.newKeySet(); + // Spieler, die manuell auf Admin-Board gezwungen wurden (ohne Perm) + private final Set forceAdminView = ConcurrentHashMap.newKeySet(); + + private boolean enabled = true; + private int updateInterval = 500; // Millisekunden + private int tickerSpeed = 1; + private boolean rainbowEnabled = true; + private String rainbowMode = "wave"; + // Wellen-Farben als RGB-Arrays (vorberechnet aus Config) + private int[][] waveColors = null; // null = HSB-Fallback + private float waveSpeed = 0.05f; // Bewegung pro Tick + private String title = "&6&lViper Network"; + private String adminTitle = "&c&l[Admin] &4&lPanel"; + private String supporterTitle = "&e&l[Support] &6&lPanel"; + private String adminPermission = "statusapi.scoreboard.admin"; + private String supporterPermission = "statusapi.scoreboard.supporter"; + private String timeFormat = "HH:mm"; + private String dateFormat = "dd.MM.yyyy"; + private String timeZone = "Europe/Berlin"; + private String moneyFormat = "#,##0.00"; + private String decimalSeparator = ","; + private String tickerText = " Viper Network "; + private String separator = "&8&m--------------------"; + private int tickerWidth = 24; + // Key = Zeilennummer (1-based), Value = Liste der Inhalte pro Page + // Zeilen mit nur 1 Eintrag rotieren nicht, Zeilen mit 2+ Eintraegen wechseln + private Map> playerLineMap = new LinkedHashMap<>(); + private Map> adminLineMap = new LinkedHashMap<>(); + private Map> supporterLineMap = new LinkedHashMap<>(); + private int rotationInterval = 4; // Sekunden pro Page + // News-Ticker + private String newsText = ""; + private String newsPrefix = "&8[&6News&8] &r"; + private int newsWidth = 20; + private int newsSpeed = 1; + private final ConcurrentHashMap newsPos = new ConcurrentHashMap<>(); + private int maxLineNum = 0; // hoechste definierte Zeilennummer + + private Plugin plugin; + private ScheduledTask updateTask; + private ScheduledTask titleTask; + private ScheduledTask newsTask; + private java.text.SimpleDateFormat sdf; + private java.text.SimpleDateFormat sdfDate; + private DecimalFormat df; + private Method sendPkt; + private boolean ready = false; + + + private static final String[] ENTRIES = { + "§0","§1","§2","§3","§4","§5","§6","§7", + "§8","§9","§a","§b","§c","§d","§e", + "§f§0","§f§1","§f§2","§f§3","§f§4" + }; + private static final int MAX_LINES = 15; // Minecraft Client zeigt max 15 Scoreboard-Einträge + + private final ConcurrentHashMap tickerPos = new ConcurrentHashMap<>(); + private final ConcurrentHashMap rainbowIdx = new ConcurrentHashMap<>(); + private final Set created = ConcurrentHashMap.newKeySet(); + private final Set createdAdmin = ConcurrentHashMap.newKeySet(); + private final Set createdSupporter = ConcurrentHashMap.newKeySet(); + + private static final ChatColor[] RAINBOW = { + ChatColor.RED, ChatColor.GOLD, ChatColor.YELLOW, + ChatColor.GREEN, ChatColor.AQUA, ChatColor.BLUE, ChatColor.LIGHT_PURPLE + }; + + // ── Compass Tape ───────────────────────────────────────────────────────── + // Kontinuierlicher Kompass mit Sub-Zeichen-Interpolation. + // Jeder Grad entspricht einem eigenen Slot → 360 Slots, kein Springen. + // Bridge sendet normYaw (0..360, float mit 1 Dezimalstelle). + private static final int COMPASS_SLOTS = 360; // 1 Slot = 1 Grad + private static final int COMPASS_WIN = 19; // sichtbare Slots (ungerade) + private static final int COMPASS_DEG_PER_SLOT = 9; // muss 90 teilen: 1,2,3,5,6,9,10,15,18,30,45,90 + // Labels an festen Grad-Positionen (N=0, E=90, S=180, W=270) + private static final int[] COMPASS_LABEL_DEG = { 0, 90, 180, 270 }; + private static final char[] COMPASS_LABEL_CH = { 'N', 'E', 'S', 'W' }; + + @Override public String getName() { return "ScoreboardModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + plugin.getLogger().info("[ScoreboardModule] Starte..."); + ensureConfigExists(); + loadConfig(); + if (!enabled) { plugin.getLogger().info("[ScoreboardModule] Deaktiviert."); return; } + try { + Class uc = Class.forName("net.md_5.bungee.UserConnection"); + sendPkt = uc.getMethod("sendPacketQueued", net.md_5.bungee.protocol.DefinedPacket.class); + sendPkt.setAccessible(true); + ready = true; + plugin.getLogger().info("[ScoreboardModule] Aktiviert. Interval=" + updateInterval + "ms (mind. 250 empfohlen)"); + } catch (Exception e) { + plugin.getLogger().severe("[ScoreboardModule] sendPacketQueued nicht gefunden: " + e); + return; + } + ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new ScoreboardToggleCommand()); + // updateInterval in ms für Daten-Updates (Kompass, Zeilen, etc.) + updateTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickAll, updateInterval, updateInterval, TimeUnit.MILLISECONDS); + // Separater schneller Task nur für den Titel (Wave-Animation, 100ms = 10fps) + titleTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickTitle, 100, 100, TimeUnit.MILLISECONDS); + // Separater Task für News-Ticker (100ms = flüssiges Scrollen) + newsTask = ProxyServer.getInstance().getScheduler().schedule( + plugin, this::tickNews, 100, 100, TimeUnit.MILLISECONDS); + } + + @Override + public void onDisable(Plugin plugin) { + if (updateTask != null) { updateTask.cancel(); updateTask = null; } + if (titleTask != null) { titleTask.cancel(); titleTask = null; } + if (newsTask != null) { newsTask.cancel(); newsTask = null; } + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (created.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME, "vt"); + if (createdAdmin.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); + if (createdSupporter.contains(p.getUniqueId())) removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); + } + created.clear(); createdAdmin.clear(); createdSupporter.clear(); tickerPos.clear(); rainbowIdx.clear(); + hiddenPlayers.clear(); forceAdminView.clear(); forcePlayerView.clear(); forceSupporterView.clear(); newsPos.clear(); + } + + @EventHandler + public void onJoin(PostLoginEvent e) { + if (!ready) return; + UUID id = e.getPlayer().getUniqueId(); + tickerPos.put(id, 0); + rainbowIdx.put(id, 0); + newsPos.put(id, 0); + joinTimes.put(id, System.currentTimeMillis()); + // Nur State initialisieren – onSwitch baut das Scoreboard auf + created.remove(id); + createdAdmin.remove(id); + createdSupporter.remove(id); + } + + @EventHandler + public void onSwitch(ServerSwitchEvent e) { + if (!ready) return; + ProxiedPlayer p = e.getPlayer(); + UUID id = p.getUniqueId(); + // Altes Objective sauber entfernen – tickAll übernimmt den Neuaufbau + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + // Kein verzögerter sendAll-Call mehr – tickAll baut nach max. 500ms neu auf + } + + @EventHandler + public void onQuit(PlayerDisconnectEvent e) { + UUID id = e.getPlayer().getUniqueId(); + tickerPos.remove(id); rainbowIdx.remove(id); created.remove(id); createdAdmin.remove(id); createdSupporter.remove(id); + forcePlayerView.remove(id); forceSupporterView.remove(id); forceAdminView.remove(id); + playerHealth.remove(id); playerCompass.remove(id); playerTps.remove(id); + playerX.remove(id); playerY.remove(id); playerZ.remove(id); + playerWorld.remove(id); playerGamemode.remove(id); + playerExp.remove(id); playerFood.remove(id); playerSpeed.remove(id); + joinTimes.remove(id); hiddenPlayers.remove(id); forceAdminView.remove(id); forcePlayerView.remove(id); newsPos.remove(id); + } + + /** Schneller Task: aktualisiert News-Position und sendet nur die betroffene Team-Zeile */ + private void tickNews() { + if (newsText == null || newsText.isEmpty()) return; + String newsPlain = ChatColor.stripColor(c(newsText)); + int gap = 4; + int nCycle = Math.max(1, newsPlain.length()) + gap; + + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + if (hiddenPlayers.contains(id)) continue; + boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission))); + Set activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created; + if (!activeCreated.contains(id)) continue; + + // Position vorrücken + int nOff = (newsPos.getOrDefault(id, 0) + newsSpeed) % nCycle; + newsPos.put(id, nOff); + + // Nur die News-Zeilen neu senden (nicht das ganze Scoreboard) + try { + String activeObjName = isAdmin ? OBJ_NAME_ADMIN : OBJ_NAME; + String newsStr = buildNewsTicker(nOff); + // Finde welche Zeilennummer(n) %news% enthält und sende nur diese + java.util.Map> lineMap = + isAdmin ? adminLineMap : isSupporter ? supporterLineMap : playerLineMap; + for (java.util.Map.Entry> entry : lineMap.entrySet()) { + boolean hasNews = false; + for (String v : entry.getValue()) { + if (v.contains("%news%")) { hasNews = true; break; } + } + if (!hasNews) continue; + int lineNum = entry.getKey(); + int lineIdx = lineNum - 1; // 0-based index for ENTRIES + if (lineIdx < 0 || lineIdx >= ENTRIES.length) continue; + + // Aktuellen Varianten-Index berechnen + int pageIdx = (rotationInterval > 0) + ? (int)((System.currentTimeMillis() / 1000) / rotationInterval) : 0; + java.util.List variants = entry.getValue(); + String tpl = variants.get(pageIdx % variants.size()); + if (!tpl.contains("%news%")) continue; + + String lineText = c(tpl.replace("%news%", newsStr)); + + // Team-Packet nur für diese Zeile senden + net.md_5.bungee.protocol.packet.Team team = new net.md_5.bungee.protocol.packet.Team(); + team.setName((isAdmin ? "vta" : isSupporter ? "vts" : "vt") + lineIdx); + team.setMode((byte) 2); // UPDATE + net.md_5.bungee.api.chat.TextComponent tc = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(lineText)) + tc.addExtra(bc); + team.setPrefix(Either.right(tc)); + team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setNameTagVisibility(Either.right(net.md_5.bungee.protocol.packet.Team.NameTagVisibility.ALWAYS)); + team.setCollisionRule(Either.right(net.md_5.bungee.protocol.packet.Team.CollisionRule.ALWAYS)); + team.setColor(Optional.of(21)); + team.setFriendlyFire((byte) 3); + sendPkt.invoke(p, team); + } + } catch (Exception ignored) {} + } + } + + /** Schneller Task: aktualisiert nur den Objective-Titel für flüssige Wave-Animation */ + private void tickTitle() { + if (!rainbowEnabled || !"wave".equals(rainbowMode)) return; + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + if (hiddenPlayers.contains(id)) continue; + boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission))); + Set activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created; + try { + int rIdx = (rainbowIdx.getOrDefault(id, 0) + 1) % 10000; + rainbowIdx.put(id, rIdx); + String activeObjName = isAdmin ? OBJ_NAME_ADMIN : isSupporter ? OBJ_NAME_SUPP : OBJ_NAME; + String rawTitle = isAdmin ? adminTitle : isSupporter ? supporterTitle : title; + String titleStr = rainbow(c(rawTitle), rIdx); + ScoreboardObjective obj = new ScoreboardObjective(); + obj.setName(activeObjName); + obj.setAction((byte) 2); // UPDATE_TITLE + net.md_5.bungee.api.chat.TextComponent tc = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + tc.addExtra(bc); + obj.setValue(Either.right(tc)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + } catch (Exception ignored) {} + } + } + + private void tickAll() { + // Nametags (Prefix über dem Kopf) periodisch aktualisieren + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!p.isConnected()) continue; + UUID id = p.getUniqueId(); + try { + sendAll(p); + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] " + p.getName() + ": " + e); + created.add(id); + } + } + } + + private void sendAll(ProxiedPlayer p) throws Exception { + if (!ready || !p.isConnected()) return; + UUID id = p.getUniqueId(); + + // Scoreboard ausgeblendet → Objective + Teams sauber entfernen (einmalig) und raus + if (hiddenPlayers.contains(id)) { + if (created.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME, "vt"); + created.remove(id); + } + if (createdAdmin.contains(id)) { + removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); + createdAdmin.remove(id); + } + return; + } + + // forcePlayerView hat Vorrang vor Perm und forceAdminView + boolean isAdmin = !forceSupporterView.contains(id) && !forcePlayerView.contains(id) + && (p.hasPermission(adminPermission) || forceAdminView.contains(id)); + boolean isSupporter = !isAdmin && (forceSupporterView.contains(id) + || (!forcePlayerView.contains(id) && p.hasPermission(supporterPermission))); + String rawTicker = stripColors(tickerText); + int tLen = Math.max(1, rawTicker.length()); + int tOff = (tickerPos.getOrDefault(id, 0) + tickerSpeed) % tLen; + tickerPos.put(id, tOff); + int rIdx = rainbowIdx.getOrDefault(id, 0); // wird von tickTitle aktualisiert + + String pn = p.getName(); + String rank = getRank(p); + String money = getMoney(p); + String srvRaw = p.getServer() != null ? p.getServer().getInfo().getName() : "?"; + String srv = srvRaw.isEmpty() ? srvRaw + : Character.toUpperCase(srvRaw.charAt(0)) + srvRaw.substring(1); + String comp = buildCompass(playerCompass.getOrDefault(id, "0")); + String hp = formatHealth(playerHealth.getOrDefault(id, 20.0)); + String hpNum = String.valueOf((int) Math.ceil(playerHealth.getOrDefault(id, 20.0) / 2.0)); + String ping = String.valueOf(p.getPing()); + String online = String.valueOf(ProxyServer.getInstance().getOnlineCount()); + // FIX: getPlayerLimit() gibt -1 wenn kein Limit gesetzt → "∞" anzeigen + int rawLimit = ProxyServer.getInstance().getConfig().getPlayerLimit(); + if (rawLimit <= 0) { + // Listener-Limit als Fallback + try { + java.util.Iterator limIt = + ProxyServer.getInstance().getConfig().getListeners().iterator(); + if (limIt.hasNext()) { + int listenerMax = limIt.next().getMaxPlayers(); + if (listenerMax > 0) rawLimit = listenerMax; + } + } catch (Exception ignored) {} + } + String maxpl = rawLimit > 0 ? String.valueOf(rawLimit) : "∞"; + String tps = isAdmin ? getTps(id) : ""; + String ram = isAdmin ? getRam() : ""; + String time = sdf.format(new Date()); + String date = sdfDate.format(new Date()); + String playtime = formatPlaytime(id); + // Neue Placeholders + String xCoord = String.valueOf(playerX.getOrDefault(id, 0)); + String yCoord = String.valueOf(playerY.getOrDefault(id, 0)); + String zCoord = String.valueOf(playerZ.getOrDefault(id, 0)); + String world = playerWorld.getOrDefault(id, srv); + String gamemode = playerGamemode.getOrDefault(id, "?"); + String exp = String.valueOf(playerExp.getOrDefault(id, 0)); + String food = String.valueOf(playerFood.getOrDefault(id, 20)); + String foodSym = formatFood(playerFood.getOrDefault(id, 20)); + String speed = String.format("%.1f", playerSpeed.getOrDefault(id, 0.2) * 10); + String uptime = getUptime(); + // News: aktuellen Ticker-Stand lesen (wird von tickNews aktualisiert) + String news = buildNewsTicker(newsPos.getOrDefault(id, 0)); + String servers = String.valueOf(ProxyServer.getInstance().getServers().size()); + String proxymem = getRam(); + + // ── TicketSystem ────────────────────────────────────────────────────── + String ticketMyOpenStr = String.valueOf(ticketMyOpen.getOrDefault(id, 0)); + String ticketTotalOpenStr = String.valueOf(ticketTotalOpen.get()); + String ticketTotalClaimedStr = String.valueOf(ticketTotalClaimed.get()); + String ticketRatingGoodStr = String.valueOf(ticketRatingGood.get()); + String ticketRatingBadStr = String.valueOf(ticketRatingBad.get()); + int _tGood = ticketRatingGood.get(); + int _tBad = ticketRatingBad.get(); + String ticketRatingPctStr = (_tGood + _tBad == 0) ? "-" + : String.valueOf(Math.round(_tGood * 100.0 / (_tGood + _tBad))); + + // Per-Zeile Rotation: Zeilen mit mehreren Inhalten wechseln automatisch + Map> lineMap = isAdmin ? adminLineMap + : isSupporter ? supporterLineMap + : playerLineMap; + if (lineMap.isEmpty()) lineMap = playerLineMap; + // Aktueller Page-Index basierend auf Zeit + int pageIdx = (rotationInterval > 0) + ? (int)((System.currentTimeMillis() / 1000) / rotationInterval) + : 0; + // Zeilen aufbauen: fuer jede Zeilennummer den aktuellen Inhalt waehlen + int lineCount = lineMap.isEmpty() ? 0 + : lineMap.keySet().stream().mapToInt(Integer::intValue).max().orElse(0); + List srcLines = new ArrayList<>(); + for (int ln = 1; ln <= lineCount; ln++) { + List variants = lineMap.get(ln); + if (variants == null || variants.isEmpty()) { + srcLines.add(""); + } else { + // Zeile mit 1 Variante = immer gleich; mit 2+ = rotieren + int vi = pageIdx % variants.size(); + srcLines.add(variants.get(vi)); + } + } + List lines = new ArrayList<>(); + boolean hasTicker = !tickerText.isEmpty() && !isAdmin && !isSupporter; + if (hasTicker) lines.add(ticker(rawTicker, tOff, rIdx)); + // Maximale Inhaltszeilen: MAX_LINES insgesamt (Ticker zählt als eine) + currentPlayerUuid = id; // für PAPI-Auflösung in ph() + for (String tpl : srcLines) { + if (lines.size() >= MAX_LINES) break; + lines.add(c(ph(tpl, pn, rank, money, srv, comp, hp, hpNum, ping, online, maxpl, tps, ram, time, playtime, + xCoord, yCoord, zCoord, world, gamemode, exp, food, foodSym, speed, uptime, servers, proxymem, date, news, + ticketMyOpenStr, ticketTotalOpenStr, ticketTotalClaimedStr, + ticketRatingGoodStr, ticketRatingBadStr, ticketRatingPctStr))); + } + // Immer genau MAX_LINES Zeilen (Rest mit Leerzeilen auffüllen) + if (lines.size() > MAX_LINES) lines = new ArrayList<>(lines.subList(0, MAX_LINES)); + while (lines.size() < MAX_LINES) lines.add(" "); + + // Objective-Name und Titel je nach Rolle + String activeObjName = isAdmin ? OBJ_NAME_ADMIN + : isSupporter ? OBJ_NAME_SUPP + : OBJ_NAME; + String titleStr = isAdmin ? rainbow(c(adminTitle), rIdx) + : isSupporter ? rainbow(c(supporterTitle), rIdx) + : rainbow(c(title), rIdx); + + // Wenn Rolle wechselt: altes Objective sauber entfernen + if (isAdmin) { + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + } else if (isSupporter) { + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + } else { + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + } + + Set activeCreated = isAdmin ? createdAdmin : isSupporter ? createdSupporter : created; + + // Objective CREATE (einmalig) oder UPDATE_TITLE + boolean justCreated = !activeCreated.contains(id); + ScoreboardObjective obj = new ScoreboardObjective(); + obj.setName(activeObjName); + if (justCreated) { + obj.setAction((byte) 0); // CREATE + net.md_5.bungee.api.chat.TextComponent titleComp = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + titleComp.addExtra(bc); + obj.setValue(Either.right(titleComp)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + + ScoreboardDisplay disp = new ScoreboardDisplay(); + disp.setPosition(1); // Sidebar + disp.setName(activeObjName); + sendPkt.invoke(p, disp); + + activeCreated.add(id); + } else { + obj.setAction((byte) 2); // UPDATE_TITLE + net.md_5.bungee.api.chat.TextComponent titleComp2 = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : buildComponents(titleStr)) + titleComp2.addExtra(bc); + obj.setValue(Either.right(titleComp2)); + obj.setType(HealthDisplay.INTEGER); + sendPkt.invoke(p, obj); + } + + // Scores + Teams + for (int i = 0; i < MAX_LINES; i++) { + if (i < lines.size()) { + ScoreboardScore score = new ScoreboardScore(); + score.setItemName(ENTRIES[i]); + score.setScoreName(activeObjName); + score.setAction((byte) 0); + score.setValue(lines.size() - i); + // NumberFormat.BLANK versteckt die Zahlen rechts + try { + Class ntCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat$Type"); + Object blank = ntCls.getMethod("valueOf", String.class).invoke(null, "BLANK"); + Class nfCls = Class.forName("net.md_5.bungee.protocol.data.NumberFormat"); + Object nf = nfCls.getDeclaredConstructor(ntCls, Object.class).newInstance(blank, null); + score.getClass().getMethod("setNumberFormat", nfCls).invoke(score, nf); + } catch (Exception ignored) {} + sendPkt.invoke(p, score); + + Team team = new Team(); + team.setName((isAdmin ? "vta" : isSupporter ? "vts" : "vt") + i); + // CREATE wenn das Objective gerade frisch angelegt wurde, sonst UPDATE + team.setMode(justCreated ? (byte) 0 : (byte) 2); + // Hex-Farben: BaseComponent[] als Container-TextComponent verpacken + net.md_5.bungee.api.chat.BaseComponent[] prefixComp = + buildComponents(lines.get(i)); + net.md_5.bungee.api.chat.TextComponent prefixContainer = + new net.md_5.bungee.api.chat.TextComponent(""); + for (net.md_5.bungee.api.chat.BaseComponent bc : prefixComp) + prefixContainer.addExtra(bc); + team.setPrefix(Either.right(prefixContainer)); + team.setSuffix(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setDisplayName(Either.right(new net.md_5.bungee.api.chat.TextComponent(""))); + team.setNameTagVisibility(Either.right(NameTagVisibility.ALWAYS)); + team.setCollisionRule(Either.right(CollisionRule.ALWAYS)); + team.setColor(Optional.of(21)); // RESET + team.setFriendlyFire((byte) 3); + team.setPlayers(new String[]{ ENTRIES[i] }); + sendPkt.invoke(p, team); + } else { + // Leeren Score entfernen + try { + Class cls = Class.forName("net.md_5.bungee.protocol.packet.ScoreboardScoreReset"); + Object pkt = cls.getDeclaredConstructor().newInstance(); + cls.getMethod("setEntity", String.class).invoke(pkt, ENTRIES[i]); + cls.getMethod("setObjective", String.class).invoke(pkt, activeObjName); + sendPkt.invoke(p, pkt); + } catch (Exception ignored) {} + } + } + } + + // ── Ticker & Rainbow ───────────────────────────────────────────────────── + + private String ticker(String raw, int offset, int rIdx) { + if (raw.isEmpty()) return " "; + String doubled = raw + raw; + String visible = doubled.substring(offset, offset + Math.min(tickerWidth, raw.length())); + return rainbow(visible, rIdx); + } + + /** + * Wave-Effekt: jeder Buchstabe bekommt eine interpolierte Farbe. + * + * mode="wave" → Farbwelle mit konfigurierbaren Farben (smooth interpoliert) + * mode="chars" → klassisch mit RAINBOW-Array + * mode="line" → gesamter Text in einer Farbe + * + * idx steigt pro Tick → Welle wandert von links nach rechts. + */ + private String rainbow(String text, int idx) { + if (!rainbowEnabled || text == null || text.isEmpty()) return text == null ? " " : text; + String plain = ChatColor.stripColor(text); + if (plain.isEmpty()) return text; + + if ("wave".equals(rainbowMode)) { + StringBuilder sb = new StringBuilder(); + boolean bold = text.contains("§l") || text.contains("&l"); + boolean italic = text.contains("§o") || text.contains("&o"); + boolean underline = text.contains("§n") || text.contains("&n"); + boolean strike = text.contains("§m") || text.contains("&m"); + String fmt = (bold ? "§l" : "") + (italic ? "§o" : "") + + (underline ? "§n" : "") + (strike ? "§m" : ""); + // Sichtbare Zeichen zählen + int visLen = 0; + for (char c : plain.toCharArray()) if (c != ' ') visLen++; + int charIdx = 0; + for (int i = 0; i < plain.length(); i++) { + char ch = plain.charAt(i); + if (ch == ' ') { sb.append(' '); continue; } + // JAWa-Style: Hue gleichmäßig über alle Buchstaben verteilt, wandert pro Tick + float hue = ((float) charIdx / Math.max(visLen, 1) + idx * this.waveSpeed) % 1.0f; + if (hue < 0) hue += 1.0f; + int[] rgb = waveColors != null + ? interpolateWaveColor(hue) // konfigurierte Farben + : hsbWave(hue); // Sinus-Regenbogen (Default) + sb.append('§').append('x'); + sb.append('§').append(String.format("%02X", rgb[0]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[0]).charAt(1)); + sb.append('§').append(String.format("%02X", rgb[1]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[1]).charAt(1)); + sb.append('§').append(String.format("%02X", rgb[2]).charAt(0)); + sb.append('§').append(String.format("%02X", rgb[2]).charAt(1)); + sb.append(fmt); + sb.append(ch); + charIdx++; + } + return sb.toString(); + } + if ("chars".equals(rainbowMode)) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < plain.length(); i++) { + sb.append(RAINBOW[(idx + i) % RAINBOW.length]); + sb.append(plain.charAt(i)); + } + return sb.toString(); + } + // mode="line": ganzer Text in einer Farbe + return RAINBOW[idx % RAINBOW.length] + plain; + } + + /** + * Interpoliert zwischen den konfigurierten Wellen-Farben. + * pos = 0.0 .. 1.0 (Position in der Welle) + * Wenn keine Farben konfiguriert: HSB-Fallback (voller Regenbogen) + */ + /** + * Verarbeitet %gradient:FARBE1:FARBE2:TEXT% Placeholder. + * + * Beispiele: + * %gradient:#FF0000:#0000FF:Hallo Welt% + * %gradient:&c:&9:> Player Info:% + * %gradient:#FF0000:#FFFF00:#0000FF:Drei Farben% (beliebig viele Stopps) + * + * Auch mit Formatierung: + * %gradient:#00FFFF:#0000FF:&l> Player Info:% + * + * Der Text darf Farb-/Formatcodes enthalten – sie werden nach jedem + * Farbcode wiederhergestellt. + */ + private String applyGradients(String input) { + if (input == null || !input.contains("%gradient:")) return input; + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < input.length()) { + int start = input.indexOf("%gradient:", i); + if (start < 0) { result.append(input.substring(i)); break; } + result.append(input, i, start); + int end = input.indexOf("%", start + 10); + if (end < 0) { result.append(input.substring(start)); break; } + // Parse: %gradient:C1:C2:...:TEXT% + String inner = input.substring(start + 10, end); + // Letzter Teil ist der Text, vorherige Teile sind Farben + // Text beginnt nach dem letzten ':' der eine Farbe abschließt + // Strategie: Teile von links lesen solange sie Farben sind + java.util.List stops = new java.util.ArrayList<>(); + int colonIdx = 0; + while (colonIdx < inner.length()) { + // Nächsten ':' suchen + int nextColon = inner.indexOf(':', colonIdx); + if (nextColon < 0) break; + String candidate = inner.substring(colonIdx, nextColon); + int[] rgb = parseGradientColor(candidate); + if (rgb != null) { + stops.add(rgb); + colonIdx = nextColon + 1; + } else { + break; + } + } + if (stops.size() < 2) { + // Nicht genug Farben – unveraendert lassen + result.append(input, start, end + 1); + i = end + 1; + continue; + } + String text = inner.substring(colonIdx); + // Formatcodes aus Text extrahieren + String plain = ChatColor.stripColor(c(text)); + boolean bold = text.contains("&l") || text.contains("\u00A7l"); + boolean italic = text.contains("&o") || text.contains("\u00A7o"); + boolean underline = text.contains("&n") || text.contains("\u00A7n"); + boolean strike = text.contains("&m") || text.contains("\u00A7m"); + String fmt = (bold ? "\u00A7l" : "") + (italic ? "\u00A7o" : "") + + (underline ? "\u00A7n" : "") + (strike ? "\u00A7m" : ""); + // Gradient auf sichtbare Zeichen anwenden + int visLen = 0; + for (char ch : plain.toCharArray()) if (ch != ' ') visLen++; + int charIdx = 0; + for (char ch : plain.toCharArray()) { + if (ch == ' ') { result.append(' '); continue; } + float pos = visLen <= 1 ? 0f : (float) charIdx / (visLen - 1); + int[] rgb = interpolateGradient(stops, pos); + result.append('\u00A7').append('x'); + result.append('\u00A7').append(String.format("%02X", rgb[0]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[0]).charAt(1)); + result.append('\u00A7').append(String.format("%02X", rgb[1]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[1]).charAt(1)); + result.append('\u00A7').append(String.format("%02X", rgb[2]).charAt(0)); + result.append('\u00A7').append(String.format("%02X", rgb[2]).charAt(1)); + result.append(fmt); + result.append(ch); + charIdx++; + } + i = end + 1; + } + return result.toString(); + } + + private int[] parseGradientColor(String s) { + s = s.trim(); + // Hex: #RRGGBB oder &#RRGGBB + if (s.startsWith("&#")) s = s.substring(1); + if (s.startsWith("#") && s.length() == 7) { + try { + return new int[]{ + Integer.parseInt(s.substring(1,3),16), + Integer.parseInt(s.substring(3,5),16), + Integer.parseInt(s.substring(5,7),16) + }; + } catch (Exception ignored) {} + } + // MC &-Code: &0-&f + if (s.startsWith("&") && s.length() == 2) { + return mcColorToRgb(s.charAt(1)); + } + return null; + } + + private int[] interpolateGradient(java.util.List stops, float pos) { + if (stops.size() == 1) return stops.get(0); + float scaled = pos * (stops.size() - 1); + int i0 = Math.min((int) scaled, stops.size() - 2); + int i1 = i0 + 1; + float t = scaled - i0; + return new int[]{ + (int)(stops.get(i0)[0] * (1-t) + stops.get(i1)[0] * t), + (int)(stops.get(i0)[1] * (1-t) + stops.get(i1)[1] * t), + (int)(stops.get(i0)[2] * (1-t) + stops.get(i1)[2] * t) + }; + } + + /** Sinus-basierter Regenbogen (JAWa-Style) – smooth, volle Farbpalette */ + private static int[] hsbWave(float hue) { + double h = hue * 2 * Math.PI; + int r = (int)(Math.sin(h) * 127.5 + 127.5); + int g = (int)(Math.sin(h + 2*Math.PI/3) * 127.5 + 127.5); + int b = (int)(Math.sin(h + 4*Math.PI/3) * 127.5 + 127.5); + return new int[]{ + Math.max(0, Math.min(255, r)), + Math.max(0, Math.min(255, g)), + Math.max(0, Math.min(255, b)) + }; + } + + /** Wandelt einen Minecraft &-Farbcode (0-9, a-f) in RGB um */ + private static int[] mcColorToRgb(char code) { + switch (Character.toLowerCase(code)) { + case '0': return new int[]{ 0, 0, 0}; // §0 Schwarz + case '1': return new int[]{ 0, 0, 170}; // §1 Dunkelblau + case '2': return new int[]{ 0, 170, 0}; // §2 Dunkelgrün + case '3': return new int[]{ 0, 170, 170}; // §3 Dunkeltürkis + case '4': return new int[]{170, 0, 0}; // §4 Dunkelrot + case '5': return new int[]{170, 0, 170}; // §5 Lila + case '6': return new int[]{255, 170, 0}; // §6 Gold + case '7': return new int[]{170, 170, 170}; // §7 Grau + case '8': return new int[]{ 85, 85, 85}; // §8 Dunkelgrau + case '9': return new int[]{ 85, 85, 255}; // §9 Blau + case 'a': return new int[]{ 85, 255, 85}; // §a Hellgrün + case 'b': return new int[]{ 85, 255, 255}; // §b Türkis + case 'c': return new int[]{255, 85, 85}; // §c Hellrot + case 'd': return new int[]{255, 85, 255}; // §d Hellviolett + case 'e': return new int[]{255, 255, 85}; // §e Gelb + case 'f': return new int[]{255, 255, 255}; // §f Weiß + default: return null; + } + } + + private int[] interpolateWaveColor(float pos) { + if (waveColors == null || waveColors.length == 0) { + // HSB-Fallback: voller Regenbogen + java.awt.Color c = java.awt.Color.getHSBColor(pos, 1.0f, 1.0f); + return new int[]{c.getRed(), c.getGreen(), c.getBlue()}; + } + int n = waveColors.length; + float scaled = pos * n; + int i0 = (int) scaled % n; + int i1 = (i0 + 1) % n; + float t = scaled - (int) scaled; // 0.0 .. 1.0 zwischen i0 und i1 + int r = (int)(waveColors[i0][0] * (1-t) + waveColors[i1][0] * t); + int g = (int)(waveColors[i0][1] * (1-t) + waveColors[i1][1] * t); + int b = (int)(waveColors[i0][2] * (1-t) + waveColors[i1][2] * t); + return new int[]{r, g, b}; + } + + // ── Placeholder ─────────────────────────────────────────────────────────── + + private String ph(String tpl, String player, String rank, String money, String server, + String compass, String health, String healthNum, String ping, String online, String maxplayers, + String tps, String ram, String time, String playtime, + String x, String y, String z, String world, String gamemode, + String exp, String food, String foodSymbol, String speed, + String uptime, String servers, String proxymem, String date, String news, + String ticketMyOpen, String ticketTotalOpen, String ticketTotalClaimed, + String ticketRatingGood, String ticketRatingBad, String ticketRatingPct) { + if (tpl == null) return " "; + // PAPI-Werte zuerst einsetzen; native Tokens überschreiben sie danach + String s = resolvePapiPlaceholders(tpl, currentPlayerUuid); + s = s + .replace("%player%", player) .replace("%rank%", rank) + .replace("%money%", money) .replace("%server%", server) + .replace("%compass%", compass) .replace("%health%", health) + .replace("%hearts%", healthNum) .replace("%ping%", ping) + .replace("%online%", online) .replace("%maxplayers%", maxplayers) + .replace("%tps%", tps) .replace("%ram%", ram) + .replace("%time%", time) .replace("%playtime%", playtime) + .replace("%x%", x) .replace("%y%", y) + .replace("%z%", z) .replace("%world%", world) + .replace("%gamemode%", gamemode) .replace("%exp%", exp) + .replace("%food%", food) .replace("%foodsym%", foodSymbol) + .replace("%speed%", speed) + .replace("%uptime%", uptime) .replace("%servers%", servers) + .replace("%proxymem%", proxymem) + .replace("%date%", date) + .replace("%news%", news) + // ── TicketSystem ────────────────────────────────────────────────── + .replace("%ticket_my_open%", ticketMyOpen) + .replace("%ticket_open%", ticketTotalOpen) + .replace("%ticket_claimed%", ticketTotalClaimed) + .replace("%ticket_rating_good%", ticketRatingGood) + .replace("%ticket_rating_bad%", ticketRatingBad) + .replace("%ticket_rating_pct%", ticketRatingPct); + s = applyGradients(s); + s = s.replace("%line%", c(separator)); + return s.isEmpty() ? " " : s; + } + + private static String resolvePapiPlaceholders(String text, UUID uuid) { + if (text == null || !text.contains("%")) return text; + if (uuid == null) return text; + java.util.Map papiMap = net.viper.status.StatusAPI.playerPapi.get(uuid); + if (papiMap == null || papiMap.isEmpty()) return text; + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + int start = text.indexOf('%', i); + if (start < 0) { sb.append(text.substring(i)); break; } + int end = text.indexOf('%', start + 1); + if (end < 0) { sb.append(text.substring(i)); break; } + String token = text.substring(start + 1, end); + if (papiMap.containsKey(token)) { + sb.append(text, i, start); + sb.append(papiMap.get(token)); + i = end + 1; + } else { + sb.append(text, i, end + 1); + i = end + 1; + } + } + return sb.toString(); + } + + // ── Daten-Helfer ───────────────────────────────────────────────────────── + + private String getRank(ProxiedPlayer p) { + try { + Class prov = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = prov.getMethod("get").invoke(null); + Object um = api.getClass().getMethod("getUserManager").invoke(api); + Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, p.getUniqueId()); + if (usr != null) { + Class qo = Class.forName("net.luckperms.api.query.QueryOptions"); + Object opts = qo.getMethod("defaultContextualOptions").invoke(null); + Object cache= usr.getClass().getMethod("getCachedData").invoke(usr); + Object meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts); + Object pfx = meta.getClass().getMethod("getPrefix").invoke(meta); + if (pfx != null && !pfx.toString().isEmpty()) return pfx.toString(); + Object pg = usr.getClass().getMethod("getPrimaryGroup").invoke(usr); + if (pg != null) return pg.toString().toUpperCase(); + } + } catch (Exception ignored) {} + return "Spieler"; + } + + private String getMoney(ProxiedPlayer p) { + try { + Map bal = (Map) StatusAPI.class.getField("playerBalances").get(null); + Object v = bal.get(p.getUniqueId()); + if (v != null) return df.format(((Number) v).doubleValue()); + } catch (Exception ignored) {} + return "0" + decimalSeparator + "00"; + } + + private String formatHealth(double hp) { + int full = (int)(hp / 2.0); + int half = (hp % 2 >= 1) ? 1 : 0; + int empty = 10 - full - half; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < full; i++) sb.append("\u2665"); // ♥ voll + if (half > 0) sb.append("\u2665"); // ♥ halb + for (int i = 0; i < empty; i++) sb.append("\u2661"); // ♡ leer + return sb.length() > 0 ? sb.toString() : "\u2661"; + } + + private String formatFood(int food) { + // Food 0-20, je 2 Punkte = ein Symbol, max 10 Slots + int full = food / 2; + int half = food % 2; + int empty = 10 - full - half; + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < full; i++) sb.append("\u25C6"); // ◆ voll + if (half > 0) sb.append("\u25C7"); // ◇ halb + for (int i = 0; i < empty; i++) sb.append("\u25C7"); // ◇ leer + return sb.toString(); + } + + private String formatPlaytime(UUID id) { + Long joinTime = joinTimes.get(id); + if (joinTime == null) return "00 00:00:00"; + long seconds = (System.currentTimeMillis() - joinTime) / 1000; + long days = seconds / 86400; + long hours = (seconds % 86400) / 3600; + long mins = (seconds % 3600) / 60; + long secs = seconds % 60; + return String.format("%02d %02d:%02d:%02d", days, hours, mins, secs); + } + + /** + * Baut den scrollenden Kompass-Balken. + * + * Akzeptiert zwei Formate: + * - Richtungstext: "N", "NE", "E", "SE", "S", "SW", "W", "NW" (Bridge-Altformat) + * - Yaw als Float: "-45.5" (Minecraft-Yaw: 0=S, 90=W, 180=N, -90=E) + * + * Beispiel-Output (Blick nach N): "- - - - &c&lN&r&7 - - - -" + */ + /** + * Baut den Kompass-Balken mit Sub-Grad-Auflösung. + * + * Das Fenster hat COMPASS_WIN Slots (z.B. 9). Jeder Slot entspricht genau + * 1 Grad auf dem Kreis (COMPASS_SLOTS = 360). Dadurch verschiebt sich der + * Balken bei jeder 1°-Änderung um genau eine Position – kein Springen. + * + * Jeder Slot zeigt: + * - 'N' / 'E' / 'S' / 'W' wenn sein Grad-Slot mit einem Himmelsrichtungs- + * Label übereinstimmt (±0°, kein Runden) + * - '|' für den Mittelpunkt (aktuelle Blickrichtung), + * falls kein Label genau trifft + * - '·' für alle anderen Slots + * + * Akzeptierte raw-Formate: + * Float-String "normYaw" (0..360): Bridge sendet normYaw = ((yaw%360)+360)%360 + * Richtungstext "N"/"NE"/"E"/…: Legacy-Fallback + */ + /** + * Kompass-Balken: WIN=15 Slots, 1°/Slot, Himmelsrichtungen farbig. + * + * Zeichen: + * '─' normaler Slot (grau, &8) + * N/E/S/W außerhalb Mitte: gelb &e + * Mitte mit Himmelsrichtung: rot+fett &c&l + * Mitte ohne Himmelsrichtung: rot+fett &c&l '|' + * + * Bridge sendet normYaw 0..360 (0 = Süden/MC-Konvention). + * Umrechnung: facingDeg = (normYaw + 180) % 360 → 0=N, 90=E, 180=S, 270=W + */ + private static final int SCOREBOARD_WIDTH = 26; // sichtbare Breite des Scoreboards + + private String buildCompass(String raw) { + if (raw == null || raw.isEmpty()) raw = "180"; + + float facingDeg; + try { + float normYaw = Float.parseFloat(raw); + facingDeg = (normYaw + 180.0f) % 360.0f; + } catch (NumberFormatException e) { + switch (raw.trim().toUpperCase()) { + case "N": facingDeg = 0; break; + case "NE": facingDeg = 45; break; + case "E": facingDeg = 90; break; + case "SE": facingDeg = 135; break; + case "S": facingDeg = 180; break; + case "SW": facingDeg = 225; break; + case "W": facingDeg = 270; break; + case "NW": facingDeg = 315; break; + default: return raw; + } + } + + int half = COMPASS_WIN / 2; + int totalSlots = COMPASS_SLOTS / COMPASS_DEG_PER_SLOT; + int centerSlot = (Math.round(facingDeg) / COMPASS_DEG_PER_SLOT) % totalSlots; + + StringBuilder sb = new StringBuilder(); + for (int i = -half; i <= half; i++) { + int slot = ((centerSlot + i) % totalSlots + totalSlots) % totalSlots; + int slotDeg = (slot * COMPASS_DEG_PER_SLOT) % COMPASS_SLOTS; + + // Himmelsrichtungs-Label? + char label = 0; + for (int k = 0; k < COMPASS_LABEL_DEG.length; k++) { + if (slotDeg == COMPASS_LABEL_DEG[k]) { label = COMPASS_LABEL_CH[k]; break; } + } + + if (i == 0) { + // Mitte: rot + fett; Himmelsrichtung oder '|' als Cursor + char marker = (label != 0) ? label : '|'; + sb.append("&c&l").append(marker).append("&r&8"); + } else if (label != 0) { + // Himmelsrichtung außerhalb Mitte: gelb, gut sichtbar + sb.append("&e").append(label).append("&8"); + } else { + sb.append('-'); // ASCII-Strich, sicher für alle MC-Versionen + } + } + // Kompass zentrieren: Leerzeichen links = (Scoreboard-Breite - Kompass-Breite) / 2 + int padding = Math.max(0, (26 - COMPASS_WIN) / 2); + StringBuilder padded = new StringBuilder(); + for (int p = 0; p < padding; p++) padded.append(' '); + padded.append(sb); + return padded.toString(); + } + + /** + * Baut den News-Ticker: Text gleitet von rechts nach links durch ein fixes Fenster. + * + * Das Fenster ist IMMER exakt newsWidth Zeichen breit – Scoreboard-Breite konstant. + * Text erscheint von rechts, läuft durch, verschwindet links. + * Dann Pause (Leerzeichen) bevor der Text wieder von rechts einläuft. + * + * newsPrefix ist optional – leer lassen in Config zum Deaktivieren. + */ + private String buildNewsTicker(int offset) { + if (newsText == null || newsText.isEmpty()) return ""; + String plain = ChatColor.stripColor(c(newsText)).trim(); + if (plain.isEmpty()) return ""; + String prefix = (newsPrefix != null && !newsPrefix.isEmpty()) ? c(newsPrefix) : ""; + int pfxLen = ChatColor.stripColor(prefix).length(); + int winWidth = Math.max(4, newsWidth - pfxLen); + + // Text + 4 Leerzeichen Pause → dann sofort wieder von rechts + // Zyklus = plain.length() + 4 + int gap = 4; + int cycleLen = plain.length() + gap; + int pos = offset % cycleLen; + + // Virtuelles Band: plain + 4 Leerzeichen, läuft zyklisch + // Fenster zeigt winWidth Zeichen aus dem Band + // Band-Position des ersten Fensterzeichens: pos - winWidth + 1 + StringBuilder window = new StringBuilder(); + for (int i = 0; i < winWidth; i++) { + int bandIdx = pos - winWidth + 1 + i; + if (bandIdx < 0 || bandIdx >= plain.length()) { + window.append(' '); + } else { + window.append(plain.charAt(bandIdx)); + } + } + return prefix + c("&f") + window.toString(); + } + + private static final long START_TIME = System.currentTimeMillis(); + + private String getUptime() { + long s = (System.currentTimeMillis() - START_TIME) / 1000; + return String.format("%02d:%02d:%02d", s / 3600, (s % 3600) / 60, s % 60); + } + + private String getTps(UUID id) { + // Primär: TPS vom Backend-Server (per POST /scoreboard/tps gesendet) + Double t = playerTps.get(id); + if (t != null) { + return new DecimalFormat("0.0").format(Math.min(20.0, t)); + } + // FIX: Fallback auf Proxy-eigene TPS aus NetworkInfoModule (immer verfügbar) + if (networkInfoModule != null && networkInfoModule.isEnabled()) { + double proxyTps = networkInfoModule.getProxyTps(); + return new DecimalFormat("0.0").format(Math.min(20.0, proxyTps)) + " (P)"; + } + return "N/A"; + } + + private String getRam() { + MemoryMXBean m = ManagementFactory.getMemoryMXBean(); + return (m.getHeapMemoryUsage().getUsed() / 1048576) + + "MB/" + (m.getHeapMemoryUsage().getMax() / 1048576) + "MB"; + } + + // ── Component Builder (Hex-Farb-Support für Scoreboard) ───────────────────── + + /** + * Wandelt einen bereits mit c() prozessierten String (§-Codes + §x§R§R§G§G§B§B) + * in BaseComponent[] um, die BungeeCord korrekt ins Scoreboard-Packet schreibt. + * + * Hex-Farben werden als echte net.md_5.bungee.api.ChatColor RGB-Instanzen gesetzt, + * nicht als Legacy-String – das ist der einzige Weg der in Sidebar-Packets funktioniert. + */ + private static net.md_5.bungee.api.chat.BaseComponent[] buildComponents(String text) { + if (text == null || text.isEmpty()) + return new net.md_5.bungee.api.chat.BaseComponent[]{ + new net.md_5.bungee.api.chat.TextComponent("")}; + + java.util.List parts = new java.util.ArrayList<>(); + net.md_5.bungee.api.ChatColor currentColor = net.md_5.bungee.api.ChatColor.WHITE; + boolean bold = false, italic = false, underline = false, strike = false, magic = false; + + int i = 0; + StringBuilder buf = new StringBuilder(); + + while (i < text.length()) { + char c = text.charAt(i); + + // §x§R§R§G§G§B§B – RGB Hex (14 Zeichen) + if (c == '§' && i + 13 <= text.length() - 1 && text.charAt(i+1) == 'x') { + // Flush buffer + if (buf.length() > 0) { + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + buf.setLength(0); + } + try { + // §x§R§R§G§G§B§B: chars at i+3,i+5,i+7,i+9,i+11,i+13 are the hex digits + String hex = "" + text.charAt(i+3) + text.charAt(i+5) + + text.charAt(i+7) + text.charAt(i+9) + + text.charAt(i+11) + text.charAt(i+13); + currentColor = net.md_5.bungee.api.ChatColor.of("#" + hex); + // Formatierungen NICHT zurücksetzen – Bold/Italic bleiben erhalten + } catch (Exception ignored) {} + i += 14; + continue; + } + + // §X – normale Farb/Format-Codes + if (c == '§' && i + 1 < text.length()) { + char code = Character.toLowerCase(text.charAt(i + 1)); + if (buf.length() > 0) { + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + buf.setLength(0); + } + switch (code) { + case 'r': currentColor = net.md_5.bungee.api.ChatColor.WHITE; + bold=false; italic=false; underline=false; strike=false; magic=false; break; + case 'l': bold = true; break; + case 'o': italic = true; break; + case 'n': underline = true; break; + case 'm': strike = true; break; + case 'k': magic = true; break; + default: + net.md_5.bungee.api.ChatColor col = + net.md_5.bungee.api.ChatColor.getByChar(code); + if (col != null) { + currentColor = col; + bold=false; italic=false; underline=false; strike=false; magic=false; + } + } + i += 2; + continue; + } + + buf.append(c); + i++; + } + if (buf.length() > 0) + parts.add(makeComp(buf.toString(), currentColor, bold, italic, underline, strike, magic)); + + if (parts.isEmpty()) + return new net.md_5.bungee.api.chat.BaseComponent[]{ + new net.md_5.bungee.api.chat.TextComponent("")}; + return parts.toArray(new net.md_5.bungee.api.chat.BaseComponent[0]); + } + + private static net.md_5.bungee.api.chat.TextComponent makeComp( + String text, net.md_5.bungee.api.ChatColor color, + boolean bold, boolean italic, boolean underline, boolean strike, boolean magic) { + net.md_5.bungee.api.chat.TextComponent tc = new net.md_5.bungee.api.chat.TextComponent(text); + tc.setColor(color); + if (bold) tc.setBold(true); + if (italic) tc.setItalic(true); + if (underline) tc.setUnderlined(true); + if (strike) tc.setStrikethrough(true); + if (magic) tc.setObfuscated(true); + return tc; + } + + // ── Farb-Hilfsmethoden ──────────────────────────────────────────────────── + + // ══════════════════════════════════════════════════════════════════════════ + // Farb-Parser: Birdflop-kompatibel + // Unterstützte Formate (alle gleichzeitig nutzbar): + // + // &#RRGGBB → Pro-Zeichen Hex (Birdflop Standard-Output) + // {#RRGGBB} → Bracket-Format + // <#RRGGBB> → MiniMessage Kurzform + // → MiniMessage color-Tag + // → Farbverlauf (beliebig viele Farb-Stopps) + // → Text in Schattenfarbe + // → Formatierungen + // &l &o &n &m &k &r → Standard-Formatierungen + // ══════════════════════════════════════════════════════════════════════════ + + private static String c(String s) { + if (s == null) return " "; + s = parseMiniMessage(s); // MiniMessage-Tags (, , <#>, , usw.) + s = parseHexAmpersand(s); // &#RRGGBB und {#RRGGBB} + return net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', s); + } + + private static String stripColors(String s) { + return s == null ? "" : net.md_5.bungee.api.ChatColor.stripColor(c(s)); + } + + // ── MiniMessage Haupt-Dispatcher ───────────────────────────────────────── + + private static String parseMiniMessage(String text) { + if (text == null || !text.contains("<")) return text == null ? "" : text; + // gradient-Tags als erstes, weil sie anderen Text enthalten können + text = parseGradientTags(text); + // shadow-Tags + text = parseShadowTags(text); + // Einfache Tags: , <#>, , , , , , + text = parseSimpleTags(text); + return text; + } + + // ── ────────────────────────────────────────── + + private static String parseGradientTags(String text) { + if (!text.contains(" suchen (mit Tiefenzähler für verschachtelte <...>) + int end = findClosingAngle(text, start + 1); + if (end < 0) { result.append(text, i, text.length()); break; } + String inner = text.substring(start + 1, end); // "gradient:#C1:#C2:TEXT" + result.append(applyGradientTag(inner)); + i = end + 1; + } + return result.toString(); + } + + /** + * Parst "gradient:#C1:#C2:#C3:TEXT" → eingefärbten Text. + * TEXT darf selbst wieder §-Codes oder &-Codes enthalten (z.B. &l für Bold). + */ + private static String applyGradientTag(String inner) { + // inner = "gradient:COLOR:COLOR:...:TEXT" + // Farben beginnen mit # oder mit & gefolgt von einem Hex-Code + java.util.List colors = new java.util.ArrayList<>(); + // Trenne am ersten Doppelpunkt nach "gradient" + int firstColon = inner.indexOf(':'); // nach "gradient" + if (firstColon < 0) return inner; + String rest = inner.substring(firstColon + 1); + + // Lese Farb-Stopps (jeder Teil beginnt mit #) + // TEXT ist alles ab dem ersten Teil der NICHT mit # beginnt + StringBuilder textSb = new StringBuilder(); + boolean inText = false; + String[] parts = rest.split(":", -1); + for (int p = 0; p < parts.length; p++) { + String part = parts[p]; + if (!inText && part.startsWith("#") && part.length() == 7) { + colors.add(part); + } else { + // Ab hier Text (inkl. Doppelpunkte wieder zusammensetzen) + inText = true; + if (textSb.length() > 0) textSb.append(":"); + textSb.append(part); + } + } + if (colors.size() < 2) return textSb.toString(); + + // Shadow-Tags im Text zuerst auflösen (können im Gradient-Text stecken) + String rawText = parseShadowTags(textSb.toString()); + return applyGradient(rawText, colors); + } + + private static String applyGradient(String text, java.util.List colorStops) { + if (text == null || text.isEmpty()) return text; + // §-Codes und &-Codes aus Text herausfiltern für Längenberechnung + String plain = text + .replaceAll("\u00A7[0-9a-fk-orx]", "") + .replaceAll("&[0-9a-fA-Fk-orK-OR]", "") + .replaceAll("\u00A7x(\u00A7[0-9a-fA-F]){6}", ""); // §x§R§R§G§G§B§B + int len = plain.length(); + if (len == 0) return text; + if (len == 1) return resolveColorToSection(colorStops.get(0)) + text; + + int[][] rgbStops = new int[colorStops.size()][3]; + for (int s = 0; s < colorStops.size(); s++) rgbStops[s] = hexToRgb(colorStops.get(s)); + + StringBuilder result = new StringBuilder(); + int charIdx = 0; + int ci = 0; + while (ci < text.length()) { + char ch = text.charAt(ci); + + // §x§R§R§G§G§B§B durchreichen (bereits aufgelöste Hex-Farbe z.B. von shadow) + if (ch == '\u00A7' && ci + 1 < text.length() && text.charAt(ci + 1) == 'x') { + // Lese die 12 folgenden Zeichen (§x + 6x §digit) + if (ci + 13 < text.length() + 1) { + result.append(text, ci, Math.min(ci + 14, text.length())); + ci = Math.min(ci + 14, text.length()); + } else { + result.append(ch); ci++; + } + continue; + } + // §-Formatcode durchreichen + if (ch == '\u00A7' && ci + 1 < text.length()) { + result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue; + } + // &-Formatcode durchreichen + if (ch == '&' && ci + 1 < text.length() && "&0123456789abcdefABCDEFklmnorKLMNOR".indexOf(text.charAt(ci+1)) >= 0) { + result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue; + } + + // Normales Zeichen → Farbe interpolieren + float t = len <= 1 ? 0f : (float) charIdx / (len - 1); + int segments = colorStops.size() - 1; + float scaled = t * segments; + int seg = Math.min((int) scaled, segments - 1); + float segT = scaled - seg; + int[] c1 = rgbStops[seg], c2 = rgbStops[seg + 1]; + int r = clamp((int)(c1[0] + (c2[0] - c1[0]) * segT)); + int g = clamp((int)(c1[1] + (c2[1] - c1[1]) * segT)); + int b = clamp((int)(c1[2] + (c2[2] - c1[2]) * segT)); + String hex = String.format("%02X%02X%02X", r, g, b); + appendHexSection(result, hex); + result.append(ch); + charIdx++; + ci++; + } + return result.toString(); + } + + // ── ───────────────────────────────────────────────── + + private static String parseShadowTags(String text) { + if (text == null || !text.contains("= 0 ? inner.indexOf(':', firstColon + 1) : -1; + if (firstColon < 0 || secondColon < 0) { result.append(text, i, end + 1); i = end + 1; continue; } + String colorPart = inner.substring(firstColon + 1, secondColon).trim(); + String content = inner.substring(secondColon + 1); + result.append(resolveColorToSection(colorPart)).append(content); + i = end + 1; + } + return result.toString(); + } + + // ── Einfache MiniMessage-Tags ───────────────────────────────────────────── + + private static String parseSimpleTags(String text) { + if (text == null || !text.contains("<")) return text == null ? "" : text; + // Ersetzungstabelle + text = text.replace("", "&l").replace("", "&r"); + text = text.replace("", "&o").replace("", "&r"); + text = text.replace("", "&n").replace("", "&r"); + text = text.replace("", "&m").replace("", "&r"); + text = text.replace("", "&k").replace("", "&r"); + text = text.replace("", "&r").replace("", ""); + // Closing-Tags entfernen (werden nach Verarbeitung nicht mehr benötigt) + text = text.replaceAll("", ""); + text = text.replaceAll("", ""); + text = text.replaceAll("", ""); + // und <#RRGGBB> + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < text.length()) { + char ch = text.charAt(i); + if (ch != '<') { result.append(ch); i++; continue; } + // + if (text.startsWith("', i); + if (end > 0) { + String hex = text.substring(i + 7, end).trim(); + if (hex.startsWith("#") && hex.length() == 7 && hex.substring(1).matches("[0-9a-fA-F]{6}")) { + appendHexSection(result, hex.substring(1)); + i = end + 1; continue; + } + } + } + // <#RRGGBB> + if (text.startsWith("<#", i) && i + 9 <= text.length()) { + int end = text.indexOf('>', i); + if (end == i + 8) { + String hex = text.substring(i + 2, end); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(result, hex); + i = end + 1; continue; + } + } + } + result.append(ch); i++; + } + return result.toString(); + } + + // ── &#RRGGBB und {#RRGGBB} ─────────────────────────────────────────────── + + private static String parseHexAmpersand(String text) { + if (text == null) return ""; + if (!text.contains("&#") && !text.contains("{#")) return text; + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + // &#RRGGBB + if (i + 7 < text.length() + 1 && i + 8 <= text.length() + && text.charAt(i) == '&' && text.charAt(i+1) == '#') { + String hex = text.substring(i+2, i+8); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(sb, hex); i += 8; continue; + } + } + // {#RRGGBB} + if (i + 8 < text.length() && text.charAt(i) == '{' && text.charAt(i+1) == '#') { + int end = text.indexOf('}', i+2); + if (end == i + 8) { + String hex = text.substring(i+2, i+8); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(sb, hex); i += 9; continue; + } + } + } + sb.append(text.charAt(i++)); + } + return sb.toString(); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static void appendHexSection(StringBuilder sb, String hex) { + sb.append('\u00A7').append('x'); + for (char ch : hex.toCharArray()) sb.append('\u00A7').append(ch); + } + + private static String resolveColorToSection(String color) { + if (color == null) return ""; + color = color.trim(); + if (color.startsWith("#") && color.length() == 7 + && color.substring(1).matches("[0-9a-fA-F]{6}")) { + StringBuilder sb = new StringBuilder(); + appendHexSection(sb, color.substring(1)); + return sb.toString(); + } + if (color.startsWith("&") && color.length() == 2) return "\u00A7" + color.charAt(1); + return color; + } + + private static int[] hexToRgb(String color) { + String hex = color == null ? "" : color.trim(); + if (hex.startsWith("#")) hex = hex.substring(1); + if (hex.length() != 6) return new int[]{255, 255, 255}; + try { + return new int[]{ + Integer.parseInt(hex.substring(0,2), 16), + Integer.parseInt(hex.substring(2,4), 16), + Integer.parseInt(hex.substring(4,6), 16) + }; + } catch (Exception e) { return new int[]{255,255,255}; } + } + + private static int clamp(int v) { return Math.max(0, Math.min(255, v)); } + + /** + * Findet das schließende '>' für ein Tag das bei fromIndex beginnt. + * Berücksichtigt verschachtelte <...>. + */ + private static int findClosingAngle(String text, int fromIndex) { + int depth = 0; + for (int i = fromIndex; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch == '<') depth++; + else if (ch == '>') { if (depth == 0) return i; depth--; } + } + return -1; + } + + + + + // ── Config ─────────────────────────────────────────────────────────────── + + private void ensureConfigExists() { + File f = new File(plugin.getDataFolder(), CONFIG_FILE); + if (f.exists()) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + String content = + "# ScoreboardModule Konfiguration\n" + + "# Platzhalter Spieler: %player% %rank% %money% %server% %compass% %health% %hearts% %date%\n" + + "# %ping% %online% %maxplayers% %time% %playtime% %news%\n" + + "# %x% %y% %z% %world% %gamemode% %exp% %food% %foodsym% %speed%\n" + + "# Platzhalter Admin: %tps% %ram% %proxymem% %uptime% %servers%\n" + + "# Gradient: %gradient:FARBE1:FARBE2:TEXT%\n" + + "# Sonstiges: %line%\n" + + "# Farben: &-Codes und Hex &#FF6600\n" + + "\n" + + "scoreboard.enabled=true\n" + + "scoreboard.update_interval=500\n" + + "scoreboard.title=&lViper Network\n" + + "scoreboard.admin_title=&l[Admin] Panel\n" + + "scoreboard.supporter_title=&l[Support] Panel\n" + + "\n" + + "scoreboard.ticker.text=\n" + + "scoreboard.ticker.width=26\n" + + "scoreboard.ticker.speed=1\n" + + "\n" + + "scoreboard.rainbow.enabled=true\n" + + "# wave=fließende Welle, chars=Regenbogen pro Buchstabe, line=eine Farbe\n" + + "scoreboard.rainbow.mode=wave\n" + + "# Wellengeschwindigkeit: 1=sehr langsam, 10=normal, 50=schnell\n" + + "scoreboard.rainbow.speed=10\n" + + "# Leer = voller HSB-Regenbogen\n" + + "scoreboard.rainbow.colors=#FF0000,#FF6600,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF\n" + + "\n" + + "scoreboard.admin_permission=statusapi.scoreboard.admin\n" + + "scoreboard.supporter_permission=statusapi.scoreboard.supporter\n" + + "\n" + + "scoreboard.time_format=HH:mm\n" + + "scoreboard.date_format=dd.MM.yyyy\n" + + "scoreboard.timezone=Europe/Berlin\n" + + "scoreboard.money_format=#,##0.00\n" + + "scoreboard.money_decimal_separator=,\n" + + "\n" + + "# SEPARATOR – wird als %line% Placeholder genutzt\n" + + "scoreboard.separator=&8&m--------------------\n" + + "# News-Ticker (erscheint als %news% Placeholder)\n" + + "scoreboard.news.text=&eWillkommen auf Viper Network!\n" + + "scoreboard.news.prefix=&8[&6News&8] &r\n" + + "scoreboard.news.width=20\n" + + "scoreboard.news.speed=1\n" + + "\n" + + "# ===================================================\n" + + "# NAMETAG - Prefix ueber dem Spieler-Kopf\n" + + "# ===================================================\n" + + "nametag.enabled=true\n" + + "\n" + + "scoreboard.rotation_interval=4\n" + + "# ===================================================\n" + + "# ZEILEN - max 15 sichtbar\n" + + "# ===================================================\n" + + "scoreboard.lines.1=%line%\n" + + "scoreboard.lines.2=%gradient:&6:&f:&6:&l> Player Info:%\n" + + "scoreboard.lines.3=&7%rank% &f%player%\n" + + "scoreboard.lines.4=\n" + + "scoreboard.lines.5=&7Spielzeit: &f%playtime%\n" + + "scoreboard.lines.5.2=&7Leben: &c%health%\n" + + "scoreboard.lines.5.3=&7Hunger: B4513%foodsym%\n" + + "scoreboard.lines.6=\n" + + "scoreboard.lines.7=%gradient:&6:&f:&6:&l> Money:%\n" + + "scoreboard.lines.8=&a$%money%\n" + + "scoreboard.lines.9=\n" + + "scoreboard.lines.10=%gradient:&6:&f:&6:&l> Server Info:%\n" + + "scoreboard.lines.11=&f%server%\n" + + "scoreboard.lines.11.2=&7Ping: &f%ping%ms &8| &7Online: &f%online%\n" + + "scoreboard.lines.12=\n" + + "scoreboard.lines.13=%news%\n" + + "scoreboard.lines.14=%line%\n" + + "scoreboard.lines.15=&7%compass%\n" + + "# ===================================================\n" + + "# ADMIN-ZEILEN\n" + + "# ===================================================\n" + + "scoreboard.admin_lines.1=%line%\n" + + "scoreboard.admin_lines.2=%gradient:&6:&f:&6:&l> Player Info:%\n" + + "scoreboard.admin_lines.3=&7%rank% &f%player%\n" + + "scoreboard.admin_lines.4=&7Gamemode: &f%gamemode%\n" + + "scoreboard.admin_lines.5=&7Leben: &c%health%\n" + + "scoreboard.admin_lines.5.2=&7Hunger: B4513%foodsym%\n" + + "scoreboard.admin_lines.6=\n" + + "scoreboard.admin_lines.7=%gradient:&6:&f:&6:&l> Server Info:%\n" + + "scoreboard.admin_lines.8=&f%server% &8| &7RAM: &e%ram%\n" + + "scoreboard.admin_lines.8.2=&7Proxy: &f%uptime%\n" + + "scoreboard.admin_lines.9=\n" + + "scoreboard.admin_lines.10=&7TPS: &a%tps%\n" + + "scoreboard.admin_lines.11=\n" + + "scoreboard.admin_lines.12=&7Spieler: &f%online% &8| &7%maxplayers%\n" + + "scoreboard.admin_lines.13=%news%\n" + + "scoreboard.admin_lines.14=%line%\n" + + "scoreboard.admin_lines.15=&7%compass%\n" + + "scoreboard.admin_lines.15.2=&7Pos: X:&f%x% &7Y:&f%y% &7Z:&f%z%\n" + + "# ===================================================\n" + + "# SUPPORTER-ZEILEN\n" + + "# ===================================================\n" + + "scoreboard.supporter_lines.1=%line%\n" + + "scoreboard.supporter_lines.2=%gradient:&6:&f:&6:&l> Support Panel:%\n" + + "scoreboard.supporter_lines.3=&7%rank% &f%player%\n" + + "scoreboard.supporter_lines.4=&7Ping: &f%ping%ms &8| &7%server%\n" + + "scoreboard.supporter_lines.5=\n" + + "scoreboard.supporter_lines.6=%gradient:&6:&f:&6:&l> Tickets:%\n" + + "scoreboard.supporter_lines.7=&7Offen: &c%ticket_open%\n" + + "scoreboard.supporter_lines.8=&7Meine Tickets: &e%ticket_my_open%\n" + + "scoreboard.supporter_lines.9=\n" + + "scoreboard.supporter_lines.10=%gradient:&6:&f:&6:&l> Server Info:%\n" + + "scoreboard.supporter_lines.11=&7Online: &f%online% &8/ &7%maxplayers%\n" + + "scoreboard.supporter_lines.12=&7Zeit: &f%time%\n" + + "scoreboard.supporter_lines.13=\n" + + "scoreboard.supporter_lines.14=%line%\n" + + "scoreboard.supporter_lines.15=&7%compass%\n" + + ""; + try (OutputStream out = new FileOutputStream(f)) { + out.write(content.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] Config: " + e.getMessage()); + } + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE); + Map map = new LinkedHashMap<>(); + if (file.exists()) { + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + int eq = line.indexOf('='); + if (eq < 1) continue; + map.put(line.substring(0, eq).trim(), line.substring(eq + 1)); + } + } catch (Exception e) { + plugin.getLogger().warning("[ScoreboardModule] Ladefehler: " + e.getMessage()); + } + } + java.util.function.BiFunction g = (k,d) -> map.getOrDefault(k, d); + enabled = Boolean.parseBoolean(g.apply("scoreboard.enabled", "true")); + updateInterval = Math.max(250, pi(g.apply("scoreboard.update_interval", "500"), 500)); + title = g.apply("scoreboard.title", "&6&lViper Network"); + adminTitle = g.apply("scoreboard.admin_title", "&c&l[Admin] &4&lPanel"); + supporterTitle = g.apply("scoreboard.supporter_title", "&e&l[Support] &6&lPanel"); + tickerText = g.apply("scoreboard.ticker.text", " Viper Network "); + tickerWidth = pi(g.apply("scoreboard.ticker.width", "26"), 26); + tickerSpeed = pi(g.apply("scoreboard.ticker.speed", "1"), 1); + rainbowEnabled = Boolean.parseBoolean(g.apply("scoreboard.rainbow.enabled", "true")); + rainbowMode = g.apply("scoreboard.rainbow.mode", "wave").trim().toLowerCase(); + // Wellen-Farben laden + // waveSpeed: Hue-Verschiebung pro Tick + // speed=1 → 0.005 pro Tick → bei 500ms-Interval ~100s pro Zyklus + // speed=10 → 0.05 pro Tick → ~10s pro Zyklus (empfohlen) + // speed=50 → 0.25 pro Tick → ~2s pro Zyklus + waveSpeed = Float.parseFloat(g.apply("scoreboard.rainbow.speed", "10")) * 0.005f; + String waveColorsStr = g.apply("scoreboard.rainbow.colors", ""); + if (!waveColorsStr.isEmpty()) { + String[] parts = waveColorsStr.split(","); + java.util.List cols = new java.util.ArrayList<>(); + for (String part : parts) { + String p = part.trim(); + // Hex-Farbe: #RRGGBB oder &#RRGGBB + if (p.startsWith("&#")) p = p.substring(1); + if (p.startsWith("#") && p.length() == 7) { + try { + int r = Integer.parseInt(p.substring(1,3),16); + int g2= Integer.parseInt(p.substring(3,5),16); + int b = Integer.parseInt(p.substring(5,7),16); + cols.add(new int[]{r,g2,b}); + } catch (Exception ignored) {} + // Minecraft &-Farbe: &0-&9, &a-&f + } else if (p.startsWith("&") && p.length() == 2) { + int[] rgb = mcColorToRgb(p.charAt(1)); + if (rgb != null) cols.add(rgb); + } + } + waveColors = cols.isEmpty() ? null : cols.toArray(new int[0][]); + } else { + waveColors = null; // HSB-Fallback + } + adminPermission = g.apply("scoreboard.admin_permission", "statusapi.scoreboard.admin"); + supporterPermission = g.apply("scoreboard.supporter_permission", "statusapi.scoreboard.supporter"); + timeFormat = g.apply("scoreboard.time_format", "HH:mm"); + dateFormat = g.apply("scoreboard.date_format", "dd.MM.yyyy"); + timeZone = g.apply("scoreboard.timezone", "Europe/Berlin"); + moneyFormat = g.apply("scoreboard.money_format", "#,##0.00"); + decimalSeparator = g.apply("scoreboard.money_decimal_separator",","); + separator = g.apply("scoreboard.separator", "&8&m--------------------"); + try { + sdf = new java.text.SimpleDateFormat(timeFormat); + sdf.setTimeZone(java.util.TimeZone.getTimeZone(timeZone)); + sdfDate = new java.text.SimpleDateFormat(dateFormat); + sdfDate.setTimeZone(java.util.TimeZone.getTimeZone(timeZone)); + } catch (Exception e) { + sdf = new java.text.SimpleDateFormat("HH:mm"); + sdfDate = new java.text.SimpleDateFormat("dd.MM.yyyy"); + } + try { + df = new DecimalFormat(moneyFormat); + java.text.DecimalFormatSymbols sym = df.getDecimalFormatSymbols(); + if (!decimalSeparator.isEmpty()) sym.setDecimalSeparator(decimalSeparator.charAt(0)); + df.setDecimalFormatSymbols(sym); + } catch (Exception e) { df = new DecimalFormat("#,##0.00"); } + rotationInterval = pi(g.apply("scoreboard.rotation_interval", "4"), 4); + newsText = g.apply("scoreboard.news.text", ""); + newsPrefix = g.apply("scoreboard.news.prefix", "&8[&6News&8] &r"); + newsWidth = pi(g.apply("scoreboard.news.width", "20"), 20); + newsSpeed = pi(g.apply("scoreboard.news.speed", "1"), 1); + + // Pro Zeile alle Varianten laden: + // scoreboard.lines.N = Zeile N, Variante 1 (immer sichtbar) + // scoreboard.lines.N.2 = Zeile N, Variante 2 (rotiert nach interval) + // scoreboard.lines.N.3 = Zeile N, Variante 3 usw. + playerLineMap.clear(); + loadLineMap(map, "scoreboard.lines.", playerLineMap); + adminLineMap.clear(); + loadLineMap(map, "scoreboard.admin_lines.", adminLineMap); + supporterLineMap.clear(); + loadLineMap(map, "scoreboard.supporter_lines.", supporterLineMap); + + + plugin.getLogger().info("[ScoreboardModule] " + + playerLineMap.size() + " Player-Zeilen, " + + adminLineMap.size() + " Admin-Zeilen, " + + supporterLineMap.size() + " Supporter-Zeilen. RotInterval=" + rotationInterval + "s" +); + } + + /** + * Liest pro Zeilennummer alle Varianten aus der Config. + * + * Format: + * prefix + N = Variante 1 fuer Zeile N (immer sichtbar wenn nur 1 Variante) + * prefix + N + ".2" = Variante 2 fuer Zeile N (rotiert mit Variante 1) + * prefix + N + ".3" = Variante 3 fuer Zeile N usw. + * + * Zeilen ohne .2/.3 bleiben immer gleich. + * Zeilen MIT .2/.3 wechseln automatisch alle rotationInterval Sekunden. + */ + private void loadLineMap(Map map, String prefix, + Map> result) { + // Hoechste Zeilennummer finden + int maxLine = 0; + for (String key : map.keySet()) { + if (!key.startsWith(prefix)) continue; + String rest = key.substring(prefix.length()); + int dot = rest.indexOf('.'); + String numStr = (dot >= 0) ? rest.substring(0, dot) : rest; + try { + int ln = Integer.parseInt(numStr); + if (ln > maxLine) maxLine = ln; + } catch (NumberFormatException ignored) {} + } + + for (int ln = 1; ln <= maxLine; ln++) { + List variants = new ArrayList<>(); + // Variante 1 (kein Suffix) + String v1 = map.get(prefix + ln); + if (v1 != null) variants.add(v1); + // Variante 2, 3, ... (Suffix .2, .3, ...) + for (int p = 2; p <= 20; p++) { + String vp = map.get(prefix + ln + "." + p); + if (vp == null) break; + variants.add(vp); + } + if (!variants.isEmpty()) result.put(ln, variants); + else result.put(ln, java.util.Collections.singletonList("")); + } + } + + private int pi(String s, int fb) { + try { return Integer.parseInt(s == null ? "" : s.trim()); } + catch (Exception e) { return fb; } + } + + /** + * Entfernt ein Scoreboard-Objective und alle zugehörigen Teams sauber vom Client. + * Muss aufgerufen werden bevor ein anderes Objective aktiviert wird, + * sonst crasht der Client beim erneuten Team-CREATE. + * + * @param p Spieler + * @param objName Objective-Name (z.B. "vpsb" oder "vpsbadmin") + * @param teamPrefix Team-Prefix (z.B. "vt" oder "vta") + */ + private void removeObjectiveAndTeams(ProxiedPlayer p, String objName, String teamPrefix) { + // 1. Alle Teams löschen (Mode 1 = REMOVE) + for (int i = 0; i < 15; i++) { + try { + Team team = new Team(); + team.setName(teamPrefix + i); + team.setMode((byte) 1); // REMOVE + sendPkt.invoke(p, team); + } catch (Exception ignored) {} + } + // 2. Objective entfernen (Action 1 = REMOVE) + try { + ScoreboardObjective rem = new ScoreboardObjective(); + rem.setName(objName); + rem.setAction((byte) 1); + sendPkt.invoke(p, rem); + } catch (Exception ignored) {} + } + + // ── /scoreboard Toggle-Command ──────────────────────────────────────────── + /** + * /scoreboard → Scoreboard ein-/ausblenden + * /scoreboard hide → Scoreboard ausblenden + * /scoreboard show → Scoreboard einblenden + * /scoreboard player → Player-Scoreboard anzeigen + * /scoreboard admin → Admin-Scoreboard anzeigen (Perm: statusapi.scoreboard.admin) + * + * Aliase: /sb, /togglesb + */ + private static final List SB_SUBS = Arrays.asList("hide", "show", "player", "admin", "supporter"); + + /** Tab-Completion für /scoreboard via TabCompleteEvent */ + @EventHandler + public void onTabComplete(TabCompleteEvent event) { + if (!(event.getSender() instanceof ProxiedPlayer)) return; + String cursor = event.getCursor(); + if (cursor == null) return; + String lower = cursor.toLowerCase(); + boolean match = lower.startsWith("/scoreboard ") || lower.startsWith("/sb ") + || lower.startsWith("/togglesb "); + if (!match) return; + + ProxiedPlayer p = (ProxiedPlayer) event.getSender(); + int spaceIdx = cursor.indexOf(' '); + String typed = spaceIdx >= 0 ? cursor.substring(spaceIdx + 1).toLowerCase() : ""; + + List suggestions = new ArrayList<>(); + for (String sub : SB_SUBS) { + // Supporter und Admin nur anzeigen wenn Berechtigung vorhanden + if (sub.equals("admin") && !p.hasPermission(adminPermission)) continue; + if (sub.equals("supporter") && !p.hasPermission(supporterPermission) + && !p.hasPermission(adminPermission)) continue; + if (sub.startsWith(typed)) suggestions.add(sub); + } + event.getSuggestions().clear(); + event.getSuggestions().addAll(suggestions); + } + + + private class ScoreboardToggleCommand extends Command { + + ScoreboardToggleCommand() { + super("scoreboard", null, "sb", "togglesb"); + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { + sender.sendMessage(new net.md_5.bungee.api.chat.TextComponent( + ChatColor.RED + "Nur für Spieler.")); + return; + } + ProxiedPlayer p = (ProxiedPlayer) sender; + UUID id = p.getUniqueId(); + + String sub = (args.length > 0) ? args[0].toLowerCase() : "toggle"; + + switch (sub) { + case "hide": + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + hiddenPlayers.add(id); + p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)")); + break; + + case "show": + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Scoreboard &aeingeblendet&7.")); + break; + + case "player": + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + forcePlayerView.add(id); + forceAdminView.remove(id); + forceSupporterView.remove(id); + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Zeige &eSpieler&7-Scoreboard.")); + break; + + case "supporter": + if (!p.hasPermission(supporterPermission) && !p.hasPermission(adminPermission)) { + p.sendMessage(msg("&cKeine Berechtigung.")); + return; + } + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + forceSupporterView.add(id); + forceAdminView.remove(id); + forcePlayerView.remove(id); + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Zeige &6Supporter&7-Scoreboard.")); + break; + + case "admin": + if (!p.hasPermission(adminPermission)) { + p.sendMessage(msg("&cKeine Berechtigung.")); + return; + } + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + forceAdminView.add(id); + forcePlayerView.remove(id); + forceSupporterView.remove(id); + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Zeige &cAdmin&7-Scoreboard.")); + break; + + default: // "toggle" + if (hiddenPlayers.contains(id)) { + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + hiddenPlayers.remove(id); + p.sendMessage(msg("&7Scoreboard &aeingeblendet&7.")); + } else { + if (created.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME, "vt"); created.remove(id); } + if (createdAdmin.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_ADMIN, "vta"); createdAdmin.remove(id); } + if (createdSupporter.contains(id)) { removeObjectiveAndTeams(p, OBJ_NAME_SUPP, "vts"); createdSupporter.remove(id); } + hiddenPlayers.add(id); + p.sendMessage(msg("&7Scoreboard &causgeblendet&7. (/sb zum Einblenden)")); + } + break; + } + } + + private net.md_5.bungee.api.chat.TextComponent msg(String text) { + return new net.md_5.bungee.api.chat.TextComponent(c(text)); + } + } + + +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java b/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java new file mode 100644 index 0000000..a0c649e --- /dev/null +++ b/src/main/java/net/viper/status/modules/serverswitcher/ServerSwitcherModule.java @@ -0,0 +1,287 @@ +package net.viper.status.modules.serverswitcher; + +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ChatColor; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.CommandSender; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ProxyServer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ClickEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.ComponentBuilder; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.HoverEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.chat.TextComponent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.config.ServerInfo; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.event.TabCompleteEvent; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Command; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Listener; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.StatusAPI; +import net.md_5.bungee.event.EventHandler; +import net.viper.status.module.Module; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.Properties; + +public class ServerSwitcherModule implements Module { + + private static final String CONFIG_FILE = "serverswitcher.properties"; + + private Plugin plugin; + private boolean enabled = true; + private String permission = "serverswitcher.use"; + private String commandName = "go"; + private List aliases = new ArrayList<>(Arrays.asList("wechsel", "switch")); + private List serverWhitelist = new ArrayList<>(); + + private String colorHeader = "&8&m---&r &6&lServer-Menü &8&m---"; + private String colorEntry = "&7>> &e"; + private String colorOnline = "&a"; + private String colorOffline = "&c"; + private String colorSelf = "&7(Aktuell)"; + + @Override + public String getName() { + return "ServerSwitcherModule"; + } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + ensureConfigExists(); + loadConfig(); + + if (!enabled) { + StatusAPI.debugLog(plugin, "[ServerSwitcherModule] Deaktiviert."); + return; + } + + String[] aliasArray = aliases.toArray(new String[0]); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, + new GoCommand(commandName, permission, aliasArray)); + ProxyServer.getInstance().getPluginManager().registerListener(plugin, + new GoTabListener()); + + plugin.getLogger().fine("[ServerSwitcherModule] Aktiviert. Command: /" + commandName + + " | Aliases: " + aliases + " | Permission: " + permission); + } + + @Override + public void onDisable(Plugin plugin) { + } + + private void ensureConfigExists() { + File target = new File(plugin.getDataFolder(), CONFIG_FILE); + if (target.exists()) return; + if (!plugin.getDataFolder().exists()) plugin.getDataFolder().mkdirs(); + + String defaults = + "# ServerSwitcherModule Konfiguration\n" + + "serverswitcher.enabled=true\n\n" + + "serverswitcher.command=go\n" + + "serverswitcher.aliases=wechsel,switch\n" + + "serverswitcher.permission=serverswitcher.use\n\n" + + "# Optionale Whitelist (leer = alle BungeeCord-Server)\n" + + "# Beispiel: serverswitcher.servers=lobby,citybuild,survival\n" + + "serverswitcher.servers=\n\n" + + "serverswitcher.color.header=&8&m---&r &6&lServer-Menü &8&m---\n" + + "serverswitcher.color.entry=&7>> &e\n" + + "serverswitcher.color.online=&a\n" + + "serverswitcher.color.offline=&c\n" + + "serverswitcher.color.self=&7(Aktuell)\n"; + + try (OutputStream out = new FileOutputStream(target)) { + out.write(defaults.getBytes(StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[ServerSwitcherModule] Konnte " + CONFIG_FILE + " nicht erstellen: " + e.getMessage()); + } + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE); + if (!file.exists()) return; + + Properties props = new Properties(); + try (FileInputStream fis = new FileInputStream(file)) { + props.load(new InputStreamReader(fis, StandardCharsets.UTF_8)); + } catch (Exception e) { + plugin.getLogger().warning("[ServerSwitcherModule] Fehler beim Laden: " + e.getMessage()); + return; + } + + enabled = Boolean.parseBoolean(props.getProperty("serverswitcher.enabled", "true")); + commandName = props.getProperty("serverswitcher.command", "go").trim(); + permission = props.getProperty("serverswitcher.permission", "serverswitcher.use").trim(); + + aliases.clear(); + for (String a : props.getProperty("serverswitcher.aliases", "wechsel,switch").split(",")) { + String t = a.trim(); + if (!t.isEmpty()) aliases.add(t); + } + + serverWhitelist.clear(); + for (String s : props.getProperty("serverswitcher.servers", "").split(",")) { + String t = s.trim().toLowerCase(); + if (!t.isEmpty()) serverWhitelist.add(t); + } + + colorHeader = props.getProperty("serverswitcher.color.header", colorHeader); + colorEntry = props.getProperty("serverswitcher.color.entry", colorEntry); + colorOnline = props.getProperty("serverswitcher.color.online", colorOnline); + colorOffline = props.getProperty("serverswitcher.color.offline", colorOffline); + colorSelf = props.getProperty("serverswitcher.color.self", colorSelf); + } + + private List getServerList() { + if (!serverWhitelist.isEmpty()) return new ArrayList<>(serverWhitelist); + List list = new ArrayList<>(ProxyServer.getInstance().getServers().keySet()); + list.sort(String.CASE_INSENSITIVE_ORDER); + return list; + } + + private static String c(String text) { + return ChatColor.translateAlternateColorCodes('&', text); + } + + private static String capitalize(String s) { + if (s == null || s.isEmpty()) return s; + return Character.toUpperCase(s.charAt(0)) + s.substring(1); + } + + // ── Command ─────────────────────────────────────────────────────────────── + + private class GoCommand extends Command { + + GoCommand(String name, String permission, String[] aliases) { + super(name, permission.isEmpty() ? null : permission, aliases); + } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { + sender.sendMessage(c("&cDieser Befehl ist nur für Spieler verfügbar.")); + return; + } + + ProxiedPlayer player = (ProxiedPlayer) sender; + + if (args.length >= 1) { + String target = args[0].trim(); + ServerInfo server = null; + for (Map.Entry entry : ProxyServer.getInstance().getServers().entrySet()) { + if (entry.getKey().equalsIgnoreCase(target)) { + server = entry.getValue(); + break; + } + } + + if (server == null) { + player.sendMessage(c("&cServer &e" + args[0] + " &cnicht gefunden.")); + return; + } + + if (player.getServer() != null + && player.getServer().getInfo().getName().equalsIgnoreCase(server.getName())) { + player.sendMessage(c("&7Du bist bereits auf &e" + server.getName() + "&7.")); + return; + } + + player.sendMessage(c("&7Verbinde mit &e" + server.getName() + "&7...")); + player.connect(server); + return; + } + + sendServerMenu(player); + } + + private void sendServerMenu(ProxiedPlayer player) { + player.sendMessage(c(colorHeader)); + + for (String serverName : getServerList()) { + ServerInfo info = ProxyServer.getInstance().getServerInfo(serverName); + if (info == null) continue; + + boolean isCurrent = player.getServer() != null + && player.getServer().getInfo().getName().equalsIgnoreCase(serverName); + int count = info.getPlayers().size(); + + TextComponent line = new TextComponent(c(colorEntry)); + TextComponent btn = new TextComponent(c((isCurrent ? colorOffline : colorOnline) + capitalize(serverName))); + + if (!isCurrent) { + btn.setClickEvent(new ClickEvent(ClickEvent.Action.RUN_COMMAND, + "/" + commandName + " " + serverName)); + btn.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new ComponentBuilder(c("&7Klicken zum Verbinden\n&7Online: &a" + count + " Spieler")).create())); + } + + line.addExtra(btn); + line.addExtra(new TextComponent(c(" &8(&7" + count + " online&8)"))); + if (isCurrent) line.addExtra(new TextComponent(c(" " + colorSelf))); + + player.sendMessage(line); + } + + player.sendMessage(c("&8&m----------------------------")); + player.sendMessage(c("&7Tipp: &e/" + commandName + " &7für direkten Wechsel")); + } + } + + // ── Tab-Completion ───────────────────────────────────────────────────────── + + public class GoTabListener implements Listener { + + @EventHandler + public void onTabComplete(TabCompleteEvent event) { + String cursor = event.getCursor(); + if (cursor == null) return; + + String lower = cursor.toLowerCase(); + boolean matches = lower.startsWith("/" + commandName.toLowerCase() + " "); + if (!matches) { + for (String alias : aliases) { + if (lower.startsWith("/" + alias.toLowerCase() + " ")) { + matches = true; + break; + } + } + } + if (!matches) return; + + if (event.getSender() instanceof ProxiedPlayer) { + ProxiedPlayer p = (ProxiedPlayer) event.getSender(); + if (!permission.isEmpty() && !p.hasPermission(permission)) return; + } + + int spaceIdx = cursor.indexOf(' '); + String input = spaceIdx >= 0 ? cursor.substring(spaceIdx + 1).toLowerCase() : ""; + + List suggestions = new ArrayList<>(); + for (String server : getServerList()) { + if (server.toLowerCase().startsWith(input)) suggestions.add(server); + } + + event.getSuggestions().clear(); + event.getSuggestions().addAll(suggestions); + } + } +} diff --git a/src/main/java/net/viper/status/modules/tablist/TablistModule.java b/src/main/java/net/viper/status/modules/tablist/TablistModule.java new file mode 100644 index 0000000..eb53c2b --- /dev/null +++ b/src/main/java/net/viper/status/modules/tablist/TablistModule.java @@ -0,0 +1,1269 @@ +package net.viper.status.modules.tablist; + +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.config.ServerInfo; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.event.ServerSwitchEvent; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.scheduler.ScheduledTask; +import net.md_5.bungee.event.EventHandler; +import net.md_5.bungee.protocol.packet.PlayerListItem; +import net.md_5.bungee.protocol.packet.PlayerListItem.Item; +import net.md_5.bungee.protocol.packet.PlayerListItemUpdate; +import net.viper.status.module.Module; + +import java.io.*; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.*; +import java.awt.Color; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; + +public class TablistModule implements Module, Listener { + + private static final String CONFIG_FILE = "tablist.properties"; + + // Leerer Skin (grauer Kopf) für Platzhalter-Slots + private static final net.md_5.bungee.protocol.data.Property[] EMPTY_SKIN = { + new net.md_5.bungee.protocol.data.Property( + "textures", + "ewogICJ0aW1lc3RhbXAiIDogMTY0NDcwNTExNjQ2OCwKICAicHJvZmlsZUlkIiA6ICJmZDQ3Y2I4YjgzNjQ0YmY3YWIyYmUxODZkYjI1ZmMwZCIsCiAgInByb2ZpbGVOYW1lIiA6ICJDVUNGTDEyIiwKICAic2lnbmF0dXJlUmVxdWlyZWQiIDogdHJ1ZSwKICAidGV4dHVyZXMiIDogewogICAgIlNLSU4iIDogewogICAgICAidXJsIiA6ICJodHRwOi8vdGV4dHVyZXMubWluZWNyYWZ0Lm5ldC90ZXh0dXJlL2ZmOWJiOWU1NjEyNWM4MjI3Yjk0YmJkYTlmNmUwZjg2MjkzMWMyMjkyNTViYThmMTIwNWQxM2M0NGMxYmI1NjEiCiAgICB9CiAgfQp9", + "D24yzbg+aBETxe5e+acQR8xJwBkhf8+CdkNYi1ufu3NgXk6YK67dIij8o3QtMx/y3rR6xupRq7bKHUGGgkw+joCC/mtG6yDdLbD32s//VAhA+VVDbIQq/CJrJ8oYarerElTjOF08zxQCw8n97cfI10gkoZvdTDouRfTfQYIIo6vvG9kTGyAJv7mIriTvxE/nwP3m6WlwRmtKWOqDhiMRNoWwo9btCp5JTZR9HVFaZdsNQvh6gUmjBqHoKtr/xWOVveEhQ5mc8WZh0dAiiC3Astfr0VIx7HW1+xNu+Z7xvRMgbZ+SbKuRwotW2KHCN+BDymTbiQ3GBljjXDjwFao0sBHQ24DjafWQcuEEWNsDnhDHtmG3tKdvGQbZ1bYhh97EjRYKXG+eZKMrFGG4jr9oCg0JD3JMBc88Z0mJWyKzPF9B+klFocmrFBF/UgkQnzkNShfkpC6RjUfCymrnAFAoV6XBcznbKQzyKKAMeNE3LPFZ3iS2Tygbrqo2Sjmq9zGpjva04RxWHJ1oeKzROQkge0z96AOO7ChTFTXqnNnAjdkfW2TjK7pSIwS0vMGsUgm1C/amzMpZdJuI0FXFEzz1jhFi5cdwHXSQY1gVpa4VTLNQvu1xgcnbOVJaV0Ty+AebI2s6CLt6OcpI3QKY+KPlITuwj5HydMiQvfYldhiHPjc=" + ) + }; + + private static final int ROWS = 20; + private int rows = ROWS, columns = 6, total = 120, tabSizeMax = 0; + private int configuredTabSize = 0; + private int configuredColumns = 0; + private UUID[] fakeUuids; + + private final ConcurrentHashMap skinCache = new ConcurrentHashMap<>(); + private final Map serverSymbols = new LinkedHashMap<>(); + + private String columnHeaderMode = "none"; + private String playerDisplayMode = "server"; + + private boolean enabled = true; + private int updateInterval = 5; + private String layoutMode = "compact"; + + private String headerLine1 = "&8&m" + rep('\u2501', 53); + private String headerLine2 = " &6&lViper Network"; + private String headerLine3 = "&8&m" + rep('\u2501', 53); + private String footerLine1 = "&8&m" + rep('\u2501', 53); + private String footerLine2 = " &7Discord: &ediscord.viper-network.de &8| &7Shop: &eviper-network.de/shop"; + private String footerLine3 = "&8&m" + rep('\u2501', 53); + + private String compactHeader1 = "&6&lViper Network &8• &2Hallo, &a%player%&7! &6Schön dass du da bist!"; + private String compactHeader2 = ""; + private String compactHeader3 = ""; + private boolean compactHeader1Spacer = false; + private boolean compactHeader2Spacer = false; + private boolean compactHeader3Spacer = false; + private String compactHeader4 = ""; + private boolean compactHeader4Spacer = false; + + private String compactFooter1 = ""; + private String compactFooter2 = "&7Zeit: &f%time% &8| &7Spieler: &f%online% &8| &7Ping: &f%ping%ms"; + private String compactFooter3 = "&7Kontostand: &a$%balance% &8| &7Server: &f%server% &8| &7Welt: &f%world%"; + private String compactFooter4 = ""; + private String compactFooter5 = ""; + private String compactFooter6 = ""; + private boolean compactFooter1Spacer = false; + private boolean compactFooter2Spacer = false; + private boolean compactFooter3Spacer = false; + private boolean compactFooter4Spacer = false; + private boolean compactFooter5Spacer = false; + private boolean compactFooter6Spacer = false; + + private String colorSrvHeader = "&6&l"; + private boolean showFooterServerList = true; + private String timeFormat = "HH:mm:ss / h:mm a"; + private String timeZone = "Europe/Berlin"; + private SimpleDateFormat sdf; + private List serverOrder = new ArrayList<>(); + private Set hiddenServers = new HashSet<>(); + private List rankOrder = new ArrayList<>(); + + private static class InfoEntry { + String label, type, value; boolean enabled; + InfoEntry(String l, String t, String v, boolean e) { label=l; type=t; value=v; enabled=e; } + } + private List infoEntries = new ArrayList<>(); + + private Plugin plugin; + private ScheduledTask updateTask; + private Method sendPacketQueuedMethod; + private java.lang.reflect.Field tabListHandlerField; + + @Override public String getName() { return "TablistModule"; } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + plugin.getLogger().info("[TablistModule] Starte..."); + ensureConfigExists(); + loadConfig(); + plugin.getLogger().info("[TablistModule] Config geladen. Layout=" + layoutMode + " enabled=" + enabled); + if (!enabled) { plugin.getLogger().info("[TablistModule] Deaktiviert."); return; } + try { + initGridSize(); + } catch (Exception e) { + plugin.getLogger().warning("[TablistModule] initGridSize Fehler: " + e.getMessage()); + int fbSize = configuredTabSize > 0 ? configuredTabSize : 120; + int maxCols = fbSize / ROWS; + tabSizeMax = fbSize; rows = ROWS; + columns = configuredColumns > 0 ? configuredColumns : Math.max(6, maxCols); + total = ROWS * columns; + } + initUuids(); + try { + Class uc = Class.forName("net.md_5.bungee.UserConnection"); + sendPacketQueuedMethod = uc.getMethod("sendPacketQueued", net.md_5.bungee.protocol.DefinedPacket.class); + sendPacketQueuedMethod.setAccessible(true); + // BungeeCords internen tabListHandler-Field finden und nullbar machen + try { + tabListHandlerField = uc.getDeclaredField("tabListHandler"); + tabListHandlerField.setAccessible(true); + } catch (Exception ignored) {} + plugin.getLogger().info("[TablistModule] sendPacketQueued gefunden."); + } catch (Exception e) { + plugin.getLogger().severe("[TablistModule] sendPacketQueued NICHT gefunden: " + e.getMessage()); + return; + } + ProxyServer.getInstance().getPluginManager().registerListener(plugin, this); + updateTask = ProxyServer.getInstance().getScheduler().schedule(plugin, this::updateAll, 2L, Math.max(1, updateInterval), TimeUnit.SECONDS); + ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { + plugin.getLogger().info("[TablistModule] Alle BungeeCord-Server: " + new ArrayList<>(ProxyServer.getInstance().getServers().keySet())); + plugin.getLogger().info("[TablistModule] Tablist-Spalten: " + getServerOrder()); + recalculateGrid(); + }, 3L, TimeUnit.SECONDS); + plugin.getLogger().info("[TablistModule] Aktiviert. Grid=" + columns + "x" + rows + " layout=" + layoutMode + + " column_header=" + columnHeaderMode + " symbols=" + serverSymbols.size()); + } + + @Override + public void onDisable(Plugin plugin) { + if (updateTask != null) { updateTask.cancel(); updateTask = null; } + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + try { removeFakeSlots(p); p.setTabHeader(new TextComponent(""), new TextComponent("")); } + catch (Exception ignored) {} + } + } + + private void initGridSize() { + // configuredTabSize aus loadConfig() hat immer Vorrang. + if (configuredTabSize > 0) { + tabSizeMax = configuredTabSize; + } else { + tabSizeMax = 400; // Sicherer Fallback + } + rows = ROWS; + + int serverCount = getServerOrder().size(); + + // SIMPEL & SICHER: configuredColumns direkt nehmen, kein komplexes Berechnen + if (configuredColumns > 0) { + columns = configuredColumns; + } else { + // Kein Wert in config → automatisch: 1 Spalte pro Server, mindestens 6 + columns = Math.max(6, serverCount); + } + + total = ROWS * columns; + plugin.getLogger().info("[TablistModule] Grid: " + columns + " Spalten × " + ROWS + " = " + total + " Slots | " + serverCount + " Server | tabSizeMax=" + tabSizeMax); + } + + private void initUuids() { + fakeUuids = new UUID[total]; + for (int i = 0; i < total; i++) fakeUuids[i] = new UUID(0xFFFEDEAD00000000L, (long) i); + } + + // ── Events ───────────────────────────────────────────────────────────────── + + @EventHandler + public void onLogin(PostLoginEvent e) { + if (!enabled) return; + ProxiedPlayer p = e.getPlayer(); + net.md_5.bungee.protocol.data.Property[] skin = fetchSkin(p); + if (skin != null && skin.length > 0) skinCache.put(p.getUniqueId(), skin); + disableBungeeTabHandler(p); + // BungeeCord resettet tabListHandler nach PostLoginEvent intern nochmals. + // Deshalb: 0.5s, 1s, 2s nacheinander überschreiben. + long[] delays = {500L, 1000L, 2000L}; + for (long delayMs : delays) { + ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { + disableBungeeTabHandler(p); + updateTablist(p); + }, delayMs, TimeUnit.MILLISECONDS); + } + ProxyServer.getInstance().getScheduler().schedule(plugin, this::updateAll, 3L, TimeUnit.SECONDS); + } + + @EventHandler + public void onSwitch(ServerSwitchEvent e) { + if (!enabled) return; + ProxiedPlayer switched = e.getPlayer(); + + net.md_5.bungee.protocol.data.Property[] skin = fetchSkin(switched); + if (skin != null && skin.length > 0) skinCache.put(switched.getUniqueId(), skin); + // Sofort deaktivieren + disableBungeeTabHandler(switched); + + // BungeeCord setzt den tabListHandler nach ServerSwitch intern mehrfach zurück. + // Deshalb: 0.5s, 1s, 2s, 3s nacheinander überschreiben + tablist neu senden. + long[] delays = {500L, 1000L, 2000L, 3000L}; + for (long delayMs : delays) { + ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { + disableBungeeTabHandler(switched); + net.md_5.bungee.protocol.data.Property[] freshSkin = fetchSkin(switched); + if (freshSkin != null && freshSkin.length > 0) skinCache.put(switched.getUniqueId(), freshSkin); + for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) { + try { removeFakeSlots(viewer); } catch (Exception ignored) {} + } + updateAll(); + }, delayMs, TimeUnit.MILLISECONDS); + } + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + if (!enabled) return; + skinCache.remove(e.getPlayer().getUniqueId()); + ProxyServer.getInstance().getScheduler().schedule(plugin, () -> { + for (ProxiedPlayer viewer : ProxyServer.getInstance().getPlayers()) { + try { removeFakeSlots(viewer); } catch (Exception ignored) {} + } + ProxyServer.getInstance().getScheduler().schedule(plugin, + this::updateAll, 1L, TimeUnit.SECONDS); + }, 1L, TimeUnit.SECONDS); + } + + // ── Core ─────────────────────────────────────────────────────────────────── + + private void updateAll() { + recalculateGrid(); + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (!skinCache.containsKey(p.getUniqueId())) { + net.md_5.bungee.protocol.data.Property[] skin = fetchSkin(p); + if (skin != null && skin.length > 0) skinCache.put(p.getUniqueId(), skin); + } + } + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) updateTablist(p); + } + + private void recalculateGrid() { + // tabSizeMax synchronisieren + if (configuredTabSize > 0) tabSizeMax = configuredTabSize; + + int serverCount = getServerOrder().size(); + int newColumns = configuredColumns > 0 ? configuredColumns : Math.max(6, serverCount); + int newTotal = ROWS * newColumns; + + if (newColumns == columns && newTotal == total && fakeUuids != null && fakeUuids.length == newTotal) return; + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + try { removeFakeSlots(p); } catch (Exception ignored) {} + } + rows = ROWS; + columns = newColumns; + total = newTotal; + initUuids(); + plugin.getLogger().info("[TablistModule] Grid: " + columns + "x" + rows + "=" + total + " (" + serverCount + " Server)"); + } + + private void updateTablist(ProxiedPlayer viewer) { + if (viewer == null || !viewer.isConnected()) return; + try { + // DEBUG: zeige aktuelle Grid-Werte im Chat (wird nach erstem Fix entfernt) + String srv = viewer.getServer() != null ? capitalize(viewer.getServer().getInfo().getName()) : "\u2014"; + String world = net.viper.status.StatusAPI.playerWorlds.getOrDefault(viewer.getUniqueId(), "world"); + String rank = getRank(viewer); + String time = sdf.format(new Date()); + String balance = getBalance(viewer); + int online = ProxyServer.getInstance().getOnlineCount(); + + String header, footer; + if ("compact".equalsIgnoreCase(layoutMode)) { + header = buildCompactHeader(viewer, srv, world, rank, time, balance, online); + footer = buildCompactFooter(viewer, srv, world, rank, time, balance, online); + } else { + header = c(headerLine1) + "\n" + c(headerLine2) + "\n" + c(headerLine3); + footer = c(footerLine1) + "\n" + c(footerLine2) + "\n" + c(footerLine3); + } + // fromLegacyText parst §x§R§R§G§G§B§B Hex-Sequenzen korrekt + net.md_5.bungee.api.chat.BaseComponent[] hComps = net.md_5.bungee.api.chat.TextComponent.fromLegacyText(header); + net.md_5.bungee.api.chat.BaseComponent[] fComps = net.md_5.bungee.api.chat.TextComponent.fromLegacyText(footer); + viewer.setTabHeader( + new net.md_5.bungee.api.chat.TextComponent(hComps), + new net.md_5.bungee.api.chat.TextComponent(fComps)); + // Erst Slots senden (ADD_PLAYER), DANN echte Spieler verstecken (UPDATE_LISTED=false). + // Umgekehrte Reihenfolge führt zu "Ignoring player info update for unknown player" + // weil der Client UPDATE_LISTED für unbekannte UUIDs ignoriert. + sendSlots(viewer, buildItems(viewer)); + hideRealPlayers(viewer); + } catch (Exception ex) { + plugin.getLogger().warning("[TablistModule] " + viewer.getName() + ": " + ex.getMessage()); + } + } + + // ── Header / Footer ──────────────────────────────────────────────────────── + + private String buildCompactHeader(ProxiedPlayer viewer, String srv, String world, String rank, String time, String balance, int online) { + StringBuilder sb = new StringBuilder(); + appendLine(sb, compactHeader1, compactHeader1Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactHeader2, compactHeader2Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactHeader3, compactHeader3Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactHeader4, compactHeader4Spacer, viewer, srv, world, rank, time, balance, online); + return sb.toString(); + } + + private String buildCompactFooter(ProxiedPlayer viewer, String srv, String world, String rank, String time, String balance, int online) { + StringBuilder sb = new StringBuilder(); + appendLine(sb, compactFooter1, compactFooter1Spacer, viewer, srv, world, rank, time, balance, online); + List servers = getServerOrder(); + if (showFooterServerList && !servers.isEmpty()) { + StringBuilder sLine = new StringBuilder(); + for (String sName : servers) { + ServerInfo si = ProxyServer.getInstance().getServerInfo(sName); + int cnt = si != null ? si.getPlayers().size() : 0; + if (sLine.length() > 0) sLine.append(" &8| "); + sLine.append(c(colorSrvHeader)).append(capitalize(sName)).append(" &8\u25cf &7").append(cnt); + } + if (sb.length() > 0) sb.append("\n"); + sb.append(c(sLine.toString())); + } + appendLine(sb, compactFooter2, compactFooter2Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactFooter3, compactFooter3Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactFooter4, compactFooter4Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactFooter5, compactFooter5Spacer, viewer, srv, world, rank, time, balance, online); + appendLine(sb, compactFooter6, compactFooter6Spacer, viewer, srv, world, rank, time, balance, online); + return sb.toString(); + } + + private void appendLine(StringBuilder sb, String line, boolean spacer, ProxiedPlayer viewer, String srv, String world, String rank, String time, String balance, int online) { + boolean empty = line == null || line.trim().isEmpty(); + if (empty && !spacer) return; + if (sb.length() > 0) sb.append("\n"); + sb.append(empty ? "\u00A0" : c(replacePlaceholders(line, viewer, srv, world, rank, time, balance, online))); + } + + // ── Items ────────────────────────────────────────────────────────────────── + + private Item[] buildItems(ProxiedPlayer viewer) { + String[] texts = new String[total]; + net.md_5.bungee.protocol.data.Property[][] skins = new net.md_5.bungee.protocol.data.Property[total][]; + int[] pings = new int[total]; + for (int i = 0; i < total; i++) { texts[i] = " "; skins[i] = EMPTY_SKIN; pings[i] = 0; } + + boolean compact = "compact".equalsIgnoreCase(layoutMode); + // Ob der Spalten-Header einen Slot belegt: + // "full" = explizit aktiviert, "none"/"small" = früher deaktiviert. + // FIX: Im Server-Modus immer den Servernamen in Zeile 0 schreiben, + // sonst weiß der Spieler nicht welche Spalte welcher Server ist. + boolean useSlotHeader = !"none".equalsIgnoreCase(columnHeaderMode); + + // Info-Spalte (nur classic) + if (!compact) { + int base = 0, row = 0; + String srv = viewer.getServer() != null ? capitalize(viewer.getServer().getInfo().getName()) : "\u2014"; + String world = net.viper.status.StatusAPI.playerWorlds.getOrDefault(viewer.getUniqueId(), "world"); + String rank = getRank(viewer); String time = sdf.format(new Date()); + String balance = getBalance(viewer); int online = ProxyServer.getInstance().getOnlineCount(); + for (InfoEntry entry : infoEntries) { + if (!entry.enabled || row + 1 >= rows) continue; + if (entry.label != null && !entry.label.isEmpty()) + row = set(texts, base, row, c(replacePlaceholders(entry.label, viewer, srv, world, rank, time, balance, online))); + String val; + switch (entry.type) { + case "name": val = "&f" + viewer.getName(); break; + case "rank": val = "&f" + rank; break; + case "server": val = "&f" + srv; break; + case "world": val = "&f" + world; break; + case "time": val = "&f[" + time + "]"; break; + case "balance": val = "&f" + balance; break; + case "online": val = "&f" + online; break; + default: val = replacePlaceholders(entry.value, viewer, srv, world, rank, time, balance, online); break; + } + row = set(texts, base, row, c(val)); + row = set(texts, base, row, " "); + } + } + + if ("custom".equalsIgnoreCase(playerDisplayMode)) { + // ── Custom-Modus: alle Spieler zusammen, nach Rang sortiert ────────── + // Minecraft Tab-Grid ist spaltenweise aufgebaut (Spalte 1 = Slots 0-19, Spalte 2 = Slots 20-39) + // "Links nach rechts" = Zeile 0 über alle Spalten, dann Zeile 1 usw. + // Spieler 0 → Spalte 0 Zeile 0, Spieler 1 → Spalte 1 Zeile 0, Spieler 2 → Spalte 2 Zeile 0 + // Spieler 3 → Spalte 0 Zeile 1, Spieler 4 → Spalte 1 Zeile 1 usw. + List allPlayers = new ArrayList<>(ProxyServer.getInstance().getPlayers()); + allPlayers = sortPlayersByRank(allPlayers); + int startCol = compact ? 0 : 1; + int usedCols = columns - startCol; + int maxSlots = usedCols * rows; + int playerIdx = 0; + // Zeile für Zeile iterieren, innerhalb jeder Zeile alle Spalten + outer: + for (int row = 0; row < rows; row++) { + for (int col = startCol; col < columns; col++) { + if (playerIdx >= allPlayers.size()) break outer; + ProxiedPlayer p = allPlayers.get(playerIdx++); + int base = col * rows; + String prefix = getLuckPermsPrefix(p); + String symbol = getServerSymbol(p); + String nameStr = p.getName() + (symbol.isEmpty() ? "" : " " + symbol); + set(texts, base, row, prefix.isEmpty() + ? c("&7" + nameStr) + : c(prefix + "&r " + nameStr)); + net.md_5.bungee.protocol.data.Property[] skin = skinCache.get(p.getUniqueId()); + skins[base + row] = (skin != null && skin.length > 0) ? skin : EMPTY_SKIN; + pings[base + row] = p.getPing() < 0 ? 1 : p.getPing(); + } + } + } else { + // ── Server-Modus: pro Spalte ein Server (default) ────────────────── + List servers = getServerOrder(); + int startCol = compact ? 0 : 1; + for (int col = startCol; col < columns && (col - startCol) < servers.size(); col++) { + int base = col * rows; + int row = 0; + String sName = servers.get(col - startCol); + if (useSlotHeader) { + row = set(texts, base, row, c(colorSrvHeader + capitalize(sName))); + } + ServerInfo si = ProxyServer.getInstance().getServerInfo(sName); + if (si != null) { + for (ProxiedPlayer p : sortPlayersByRank(new ArrayList<>(si.getPlayers()))) { + if (row >= rows) break; + String prefix = getLuckPermsPrefix(p); + String symbol = getServerSymbol(p); + String nameStr = p.getName() + (symbol.isEmpty() ? "" : " " + symbol); + set(texts, base, row, prefix.isEmpty() + ? c("&7" + nameStr) + : c(prefix + "&r " + nameStr)); + net.md_5.bungee.protocol.data.Property[] skin = skinCache.get(p.getUniqueId()); + skins[base + row] = (skin != null && skin.length > 0) ? skin : EMPTY_SKIN; + pings[base + row] = p.getPing() < 0 ? 1 : p.getPing(); + row++; + } + } + } + } + + Item[] items = new Item[total]; + for (int i = 0; i < total; i++) { + Item item = new Item(); + item.setUuid(fakeUuids[i]); + item.setUsername(fakeName(i)); + item.setProperties(skins[i]); + item.setGamemode(0); + item.setPing(pings[i]); + item.setListed(true); + String dn = texts[i] == null || texts[i].isEmpty() ? " " : texts[i]; + item.setDisplayName(new net.md_5.bungee.api.chat.TextComponent( + net.md_5.bungee.api.chat.TextComponent.fromLegacyText(dn))); + items[i] = item; + } + return items; + } + + // ── Pakete ───────────────────────────────────────────────────────────────── + + @SuppressWarnings("unchecked") + private void hideRealPlayers(ProxiedPlayer viewer) { + if (sendPacketQueuedMethod == null) return; + try { + // Echte Spieler: nur listed=false setzen (KEIN Remove, sonst geht der Skin verloren!) + // Der Client kennt sie bereits per ADD_PLAYER von BungeeCord → UPDATE_LISTED reicht. + Collection online = ProxyServer.getInstance().getPlayers(); + if (!online.isEmpty()) { + PlayerListItemUpdate playerPkt = new PlayerListItemUpdate(); + playerPkt.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LISTED)); + Item[] playerItems = new Item[online.size()]; + int i = 0; + for (ProxiedPlayer p : online) { + Item it = new Item(); it.setUuid(p.getUniqueId()); it.setListed(false); + playerItems[i++] = it; + } + playerPkt.setItems(playerItems); + sendPacketQueuedMethod.invoke(viewer, playerPkt); + } + + // Server-UUIDs (BungeeCord schreibt pro Server 2 Einträge in die Tablist): + // Diese per PlayerListItemRemove entfernen – das funktioniert auch für + // UUIDs die der Client noch nicht kennt, ohne Skin-Schaden. + List serverUuids = new ArrayList<>(); + for (String srvName : ProxyServer.getInstance().getServers().keySet()) { + try { + serverUuids.add(UUID.nameUUIDFromBytes(("OfflinePlayer:" + srvName).getBytes(StandardCharsets.UTF_8))); + serverUuids.add(UUID.nameUUIDFromBytes(srvName.getBytes(StandardCharsets.UTF_8))); + } catch (Exception ignored) {} + } + if (!serverUuids.isEmpty()) { + try { + Class removeClass = Class.forName("net.md_5.bungee.protocol.packet.PlayerListItemRemove"); + Object removePkt = removeClass.getDeclaredConstructor().newInstance(); + removeClass.getMethod("setUuids", UUID[].class).invoke(removePkt, + (Object) serverUuids.toArray(new UUID[0])); + sendPacketQueuedMethod.invoke(viewer, removePkt); + } catch (Exception ignored) { + // Fallback: UPDATE_LISTED=false (Warnungen im Client-Log sind harmlos) + PlayerListItemUpdate srvPkt = new PlayerListItemUpdate(); + srvPkt.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LISTED)); + Item[] srvItems = new Item[serverUuids.size()]; + for (int j = 0; j < serverUuids.size(); j++) { + Item it = new Item(); it.setUuid(serverUuids.get(j)); it.setListed(false); + srvItems[j] = it; + } + srvPkt.setItems(srvItems); + sendPacketQueuedMethod.invoke(viewer, srvPkt); + } + } + } catch (Exception e) { plugin.getLogger().warning("[TablistModule] hideRealPlayers: " + e.getMessage()); } + } + + @SuppressWarnings("unchecked") + private void sendSlots(ProxiedPlayer viewer, Item[] items) { + if (sendPacketQueuedMethod == null) return; + try { + // Paket 1: ADD_PLAYER + UPDATE_DISPLAY_NAME + UPDATE_LISTED + UPDATE_LATENCY + PlayerListItemUpdate pkt = new PlayerListItemUpdate(); + pkt.setActions(EnumSet.of( + PlayerListItemUpdate.Action.ADD_PLAYER, + PlayerListItemUpdate.Action.UPDATE_DISPLAY_NAME, + PlayerListItemUpdate.Action.UPDATE_LISTED, + PlayerListItemUpdate.Action.UPDATE_LATENCY)); + pkt.setItems(items); + sendPacketQueuedMethod.invoke(viewer, pkt); + + // Paket 2: Explizit UPDATE_LISTED=true für alle Fake-Slots nochmal senden. + // BungeeCord sendet nach ServerSwitch ein eigenes Tab-Paket das einige Slots + // auf listed=false setzt – dieses zweite Paket überschreibt das wieder. + PlayerListItemUpdate listedPkt = new PlayerListItemUpdate(); + listedPkt.setActions(EnumSet.of(PlayerListItemUpdate.Action.UPDATE_LISTED)); + listedPkt.setItems(items); // items haben alle listed=true gesetzt + sendPacketQueuedMethod.invoke(viewer, listedPkt); + } catch (Exception e) { plugin.getLogger().warning("[TablistModule] sendSlots: " + e.getMessage()); } + } + + private void removeFakeSlots(ProxiedPlayer viewer) { + if (sendPacketQueuedMethod == null || fakeUuids == null) return; + try { + Class cls = Class.forName("net.md_5.bungee.protocol.packet.PlayerListItemRemove"); + Object pkt = cls.getDeclaredConstructor().newInstance(); + cls.getMethod("setUuids", UUID[].class).invoke(pkt, (Object) fakeUuids.clone()); + sendPacketQueuedMethod.invoke(viewer, pkt); + } catch (Exception e) { + try { + PlayerListItem rem = new PlayerListItem(); + rem.setAction(PlayerListItem.Action.REMOVE_PLAYER); + Item[] items = new Item[total]; + for (int i = 0; i < total; i++) { Item it = new Item(); it.setUuid(fakeUuids[i]); items[i] = it; } + rem.setItems(items); + sendPacketQueuedMethod.invoke(viewer, rem); + } catch (Exception ignored) {} + } + } + + // ── Helpers ──────────────────────────────────────────────────────────────── + + /** + * Ersetzt BungeeCords internen tabListHandler durch einen No-Op Proxy. + * null setzen crasht BungeeCord (NPE in DownstreamBridge). + * Ein leerer Proxy ignoriert alle Aufrufe lautlos. + */ + private void disableBungeeTabHandler(ProxiedPlayer player) { + if (tabListHandlerField == null) return; + try { + Class tabListClass = Class.forName("net.md_5.bungee.tab.TabList"); + // IMMER neu setzen – BungeeCord resettet den tabListHandler nach jedem + // ServerSwitch intern, daher reicht ein einmaliges Setzen nicht aus. + Object noopProxy = java.lang.reflect.Proxy.newProxyInstance( + tabListClass.getClassLoader(), + new Class[]{ tabListClass }, + (proxy, method, args) -> { + Class ret = method.getReturnType(); + if (ret == boolean.class || ret == Boolean.class) return false; + if (ret == int.class || ret == Integer.class) return 0; + if (ret == long.class || ret == Long.class) return 0L; + return null; + } + ); + tabListHandlerField.set(player, noopProxy); + } catch (Exception ignored) {} + } + + /** + * ── ULTIMATE: Gibt das konfigurierte Server-Symbol für den Spieler zurück. + * Leer wenn kein Symbol für den aktuellen Server definiert ist. + */ + private String getServerSymbol(ProxiedPlayer player) { + if (serverSymbols.isEmpty() || player.getServer() == null) return ""; + String srvKey = player.getServer().getInfo().getName().toLowerCase(); + String raw = serverSymbols.get(srvKey); + if (raw == null || raw.isEmpty()) return ""; + return c(raw); // Farb-Codes und Hex-Farben auflösen + } + + private net.md_5.bungee.protocol.data.Property[] fetchSkin(ProxiedPlayer player) { + try { + Object pending = player.getPendingConnection(); + net.md_5.bungee.connection.LoginResult profile = + (net.md_5.bungee.connection.LoginResult) pending.getClass().getMethod("getLoginProfile").invoke(pending); + if (profile != null && profile.getProperties() != null && profile.getProperties().length > 0) + return profile.getProperties(); + } catch (Exception ignored) {} + return new net.md_5.bungee.protocol.data.Property[0]; + } + + private List getServerOrder() { + List list; + if (!serverOrder.isEmpty()) { + list = new ArrayList<>(serverOrder); + list.removeIf(s -> hiddenServers.contains(s.toLowerCase())); + } else { + list = new ArrayList<>(); + final String[] lobbyKey = {null}; + for (String key : ProxyServer.getInstance().getServers().keySet()) + if (key.equalsIgnoreCase("lobby")) { lobbyKey[0] = key; break; } + if (lobbyKey[0] != null) list.add(lobbyKey[0]); + ProxyServer.getInstance().getServers().keySet().stream() + .filter(s -> lobbyKey[0] == null || !s.equalsIgnoreCase(lobbyKey[0])) + .filter(s -> !hiddenServers.contains(s.toLowerCase())) + .sorted(String.CASE_INSENSITIVE_ORDER).forEach(list::add); + } + return list; + } + + private List sortPlayersByRank(List players) { + if (rankOrder.isEmpty()) return players; + players.sort((a, b) -> { int ia = getRankIndex(a), ib = getRankIndex(b); + return ia != ib ? Integer.compare(ia, ib) : a.getName().compareToIgnoreCase(b.getName()); }); + return players; + } + + private int getRankIndex(ProxiedPlayer player) { + try { + Class prov = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = prov.getMethod("get").invoke(null); + Object um = api.getClass().getMethod("getUserManager").invoke(api); + Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, player.getUniqueId()); + if (usr != null) { + Object pg = usr.getClass().getMethod("getPrimaryGroup").invoke(usr); + if (pg != null) { String g = pg.toString().toLowerCase(); + for (int i = 0; i < rankOrder.size(); i++) if (rankOrder.get(i).equalsIgnoreCase(g)) return i; } + } + } catch (Exception ignored) {} + return rankOrder.size(); + } + + private String getRank(ProxiedPlayer player) { + try { + Class prov = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = prov.getMethod("get").invoke(null); + Object um = api.getClass().getMethod("getUserManager").invoke(api); + Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, player.getUniqueId()); + if (usr != null) { + Class qo = Class.forName("net.luckperms.api.query.QueryOptions"); + Object opts = qo.getMethod("defaultContextualOptions").invoke(null); + Object cache = usr.getClass().getMethod("getCachedData").invoke(usr); + Object meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts); + Object pfx = meta.getClass().getMethod("getPrefix").invoke(meta); + if (pfx != null && !pfx.toString().isEmpty()) return pfx.toString(); + Object pg = usr.getClass().getMethod("getPrimaryGroup").invoke(usr); + if (pg != null && !pg.toString().isEmpty()) return "[" + pg.toString().toUpperCase() + "]"; + } + } catch (Exception ignored) {} + return "NONE"; + } + + private String getLuckPermsPrefix(ProxiedPlayer player) { + try { + Class prov = Class.forName("net.luckperms.api.LuckPermsProvider"); + Object api = prov.getMethod("get").invoke(null); + Object um = api.getClass().getMethod("getUserManager").invoke(api); + Object usr = um.getClass().getMethod("getUser", UUID.class).invoke(um, player.getUniqueId()); + if (usr != null) { + Class qo = Class.forName("net.luckperms.api.query.QueryOptions"); + Object opts = qo.getMethod("defaultContextualOptions").invoke(null); + Object cache = usr.getClass().getMethod("getCachedData").invoke(usr); + Object meta = cache.getClass().getMethod("getMetaData", qo).invoke(cache, opts); + Object pfx = meta.getClass().getMethod("getPrefix").invoke(meta); + // ── HEX-Farben auch im Prefix auflösen ─────────────────────── + if (pfx != null) return c(pfx.toString()); + } + } catch (Exception ignored) {} + return ""; + } + + private String getBalance(ProxiedPlayer player) { + try { + Map balances = (Map) net.viper.status.StatusAPI.class.getField("playerBalances").get(null); + Object val = balances.get(player.getUniqueId()); + if (val != null) return String.format("%,.2f", ((Number) val).doubleValue()); + } catch (Exception ignored) {} + return "0.00"; + } + + private String replacePlaceholders(String text, ProxiedPlayer viewer, String srv, String world, String rank, String time, String balance, int online) { + if (text == null) return ""; + String result = resolvePapiPlaceholders(text, viewer.getUniqueId()); + result = result.replace("%player%", viewer.getName()).replace("%rank%", rank) + .replace("%server%", srv).replace("%world%", world).replace("%time%", time) + .replace("%balance%", balance).replace("%ping%", String.valueOf(viewer.getPing())) + .replace("%online%", String.valueOf(online)); + return result; + } + + private static String resolvePapiPlaceholders(String text, UUID uuid) { + if (text == null || !text.contains("%")) return text; + Map papiMap = net.viper.status.StatusAPI.playerPapi.get(uuid); + if (papiMap == null || papiMap.isEmpty()) return text; + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + int start = text.indexOf('%', i); + if (start < 0) { sb.append(text.substring(i)); break; } + int end = text.indexOf('%', start + 1); + if (end < 0) { sb.append(text.substring(i)); break; } + String token = text.substring(start + 1, end); + if (papiMap.containsKey(token)) { + sb.append(text, i, start); + sb.append(papiMap.get(token)); + i = end + 1; + } else { + sb.append(text, i, end + 1); + i = end + 1; + } + } + return sb.toString(); + } + + private int set(String[] arr, int base, int row, String text) { + if (base + row < total) arr[base + row] = text == null ? " " : text; return row + 1; + } + + // ── Farb-Auflösung ───────────────────────────────────────────────────────── + + // ══════════════════════════════════════════════════════════════════════════ + // Farb-Parser: Birdflop-kompatibel + // Unterstützte Formate (alle gleichzeitig nutzbar): + // + // &#RRGGBB → Pro-Zeichen Hex (Birdflop Standard-Output) + // {#RRGGBB} → Bracket-Format + // <#RRGGBB> → MiniMessage Kurzform + // → MiniMessage color-Tag + // → Farbverlauf (beliebig viele Farb-Stopps) + // → Text in Schattenfarbe + // → Formatierungen + // &l &o &n &m &k &r → Standard-Formatierungen + // ══════════════════════════════════════════════════════════════════════════ + + private static String c(String s) { + if (s == null) return " "; + s = parseMiniMessage(s); // MiniMessage-Tags (, , <#>, , usw.) + s = parseHexAmpersand(s); // &#RRGGBB und {#RRGGBB} + return net.md_5.bungee.api.ChatColor.translateAlternateColorCodes('&', s); + } + + private static String stripColors(String s) { + return s == null ? "" : net.md_5.bungee.api.ChatColor.stripColor(c(s)); + } + + // ── MiniMessage Haupt-Dispatcher ───────────────────────────────────────── + + private static String parseMiniMessage(String text) { + if (text == null || !text.contains("<")) return text == null ? "" : text; + // gradient-Tags als erstes, weil sie anderen Text enthalten können + text = parseGradientTags(text); + // shadow-Tags + text = parseShadowTags(text); + // Einfache Tags: , <#>, , , , , , + text = parseSimpleTags(text); + return text; + } + + // ── ────────────────────────────────────────── + + private static String parseGradientTags(String text) { + if (!text.contains(" suchen (mit Tiefenzähler für verschachtelte <...>) + int end = findClosingAngle(text, start + 1); + if (end < 0) { result.append(text, i, text.length()); break; } + String inner = text.substring(start + 1, end); // "gradient:#C1:#C2:TEXT" + result.append(applyGradientTag(inner)); + i = end + 1; + } + return result.toString(); + } + + /** + * Parst "gradient:#C1:#C2:#C3:TEXT" → eingefärbten Text. + * TEXT darf selbst wieder §-Codes oder &-Codes enthalten (z.B. &l für Bold). + */ + private static String applyGradientTag(String inner) { + // inner = "gradient:COLOR:COLOR:...:TEXT" + // Farben beginnen mit # oder mit & gefolgt von einem Hex-Code + java.util.List colors = new java.util.ArrayList<>(); + // Trenne am ersten Doppelpunkt nach "gradient" + int firstColon = inner.indexOf(':'); // nach "gradient" + if (firstColon < 0) return inner; + String rest = inner.substring(firstColon + 1); + + // Lese Farb-Stopps (jeder Teil beginnt mit #) + // TEXT ist alles ab dem ersten Teil der NICHT mit # beginnt + StringBuilder textSb = new StringBuilder(); + boolean inText = false; + String[] parts = rest.split(":", -1); + for (int p = 0; p < parts.length; p++) { + String part = parts[p]; + if (!inText && part.startsWith("#") && part.length() == 7) { + colors.add(part); + } else { + // Ab hier Text (inkl. Doppelpunkte wieder zusammensetzen) + inText = true; + if (textSb.length() > 0) textSb.append(":"); + textSb.append(part); + } + } + if (colors.size() < 2) return textSb.toString(); + + // Shadow-Tags im Text zuerst auflösen (können im Gradient-Text stecken) + String rawText = parseShadowTags(textSb.toString()); + return applyGradient(rawText, colors); + } + + private static String applyGradient(String text, java.util.List colorStops) { + if (text == null || text.isEmpty()) return text; + // §-Codes und &-Codes aus Text herausfiltern für Längenberechnung + String plain = text + .replaceAll("\u00A7[0-9a-fk-orx]", "") + .replaceAll("&[0-9a-fA-Fk-orK-OR]", "") + .replaceAll("\u00A7x(\u00A7[0-9a-fA-F]){6}", ""); // §x§R§R§G§G§B§B + int len = plain.length(); + if (len == 0) return text; + if (len == 1) return resolveColorToSection(colorStops.get(0)) + text; + + int[][] rgbStops = new int[colorStops.size()][3]; + for (int s = 0; s < colorStops.size(); s++) rgbStops[s] = hexToRgb(colorStops.get(s)); + + StringBuilder result = new StringBuilder(); + int charIdx = 0; + int ci = 0; + while (ci < text.length()) { + char ch = text.charAt(ci); + + // §x§R§R§G§G§B§B durchreichen (bereits aufgelöste Hex-Farbe z.B. von shadow) + if (ch == '\u00A7' && ci + 1 < text.length() && text.charAt(ci + 1) == 'x') { + // Lese die 12 folgenden Zeichen (§x + 6x §digit) + if (ci + 13 < text.length() + 1) { + result.append(text, ci, Math.min(ci + 14, text.length())); + ci = Math.min(ci + 14, text.length()); + } else { + result.append(ch); ci++; + } + continue; + } + // §-Formatcode durchreichen + if (ch == '\u00A7' && ci + 1 < text.length()) { + result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue; + } + // &-Formatcode durchreichen + if (ch == '&' && ci + 1 < text.length() && "&0123456789abcdefABCDEFklmnorKLMNOR".indexOf(text.charAt(ci+1)) >= 0) { + result.append(ch).append(text.charAt(ci + 1)); ci += 2; continue; + } + + // Normales Zeichen → Farbe interpolieren + float t = len <= 1 ? 0f : (float) charIdx / (len - 1); + int segments = colorStops.size() - 1; + float scaled = t * segments; + int seg = Math.min((int) scaled, segments - 1); + float segT = scaled - seg; + int[] c1 = rgbStops[seg], c2 = rgbStops[seg + 1]; + int r = clamp((int)(c1[0] + (c2[0] - c1[0]) * segT)); + int g = clamp((int)(c1[1] + (c2[1] - c1[1]) * segT)); + int b = clamp((int)(c1[2] + (c2[2] - c1[2]) * segT)); + String hex = String.format("%02X%02X%02X", r, g, b); + appendHexSection(result, hex); + result.append(ch); + charIdx++; + ci++; + } + return result.toString(); + } + + // ── ───────────────────────────────────────────────── + + private static String parseShadowTags(String text) { + if (text == null || !text.contains("= 0 ? inner.indexOf(':', firstColon + 1) : -1; + if (firstColon < 0 || secondColon < 0) { result.append(text, i, end + 1); i = end + 1; continue; } + String colorPart = inner.substring(firstColon + 1, secondColon).trim(); + String content = inner.substring(secondColon + 1); + result.append(resolveColorToSection(colorPart)).append(content); + i = end + 1; + } + return result.toString(); + } + + // ── Einfache MiniMessage-Tags ───────────────────────────────────────────── + + private static String parseSimpleTags(String text) { + if (text == null || !text.contains("<")) return text == null ? "" : text; + // Ersetzungstabelle + text = text.replace("", "&l").replace("", "&r"); + text = text.replace("", "&o").replace("", "&r"); + text = text.replace("", "&n").replace("", "&r"); + text = text.replace("", "&m").replace("", "&r"); + text = text.replace("", "&k").replace("", "&r"); + text = text.replace("", "&r").replace("", ""); + // Closing-Tags entfernen (werden nach Verarbeitung nicht mehr benötigt) + text = text.replaceAll("", ""); + text = text.replaceAll("", ""); + text = text.replaceAll("", ""); + // und <#RRGGBB> + StringBuilder result = new StringBuilder(); + int i = 0; + while (i < text.length()) { + char ch = text.charAt(i); + if (ch != '<') { result.append(ch); i++; continue; } + // + if (text.startsWith("', i); + if (end > 0) { + String hex = text.substring(i + 7, end).trim(); + if (hex.startsWith("#") && hex.length() == 7 && hex.substring(1).matches("[0-9a-fA-F]{6}")) { + appendHexSection(result, hex.substring(1)); + i = end + 1; continue; + } + } + } + // <#RRGGBB> + if (text.startsWith("<#", i) && i + 9 <= text.length()) { + int end = text.indexOf('>', i); + if (end == i + 8) { + String hex = text.substring(i + 2, end); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(result, hex); + i = end + 1; continue; + } + } + } + result.append(ch); i++; + } + return result.toString(); + } + + // ── &#RRGGBB und {#RRGGBB} ─────────────────────────────────────────────── + + private static String parseHexAmpersand(String text) { + if (text == null) return ""; + if (!text.contains("&#") && !text.contains("{#")) return text; + StringBuilder sb = new StringBuilder(); + int i = 0; + while (i < text.length()) { + // &#RRGGBB + if (i + 7 < text.length() + 1 && i + 8 <= text.length() + && text.charAt(i) == '&' && text.charAt(i+1) == '#') { + String hex = text.substring(i+2, i+8); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(sb, hex); i += 8; continue; + } + } + // {#RRGGBB} + if (i + 8 < text.length() && text.charAt(i) == '{' && text.charAt(i+1) == '#') { + int end = text.indexOf('}', i+2); + if (end == i + 8) { + String hex = text.substring(i+2, i+8); + if (hex.matches("[0-9a-fA-F]{6}")) { + appendHexSection(sb, hex); i += 9; continue; + } + } + } + sb.append(text.charAt(i++)); + } + return sb.toString(); + } + + // ── Hilfsmethoden ───────────────────────────────────────────────────────── + + private static void appendHexSection(StringBuilder sb, String hex) { + sb.append('\u00A7').append('x'); + for (char ch : hex.toCharArray()) sb.append('\u00A7').append(ch); + } + + private static String resolveColorToSection(String color) { + if (color == null) return ""; + color = color.trim(); + if (color.startsWith("#") && color.length() == 7 + && color.substring(1).matches("[0-9a-fA-F]{6}")) { + StringBuilder sb = new StringBuilder(); + appendHexSection(sb, color.substring(1)); + return sb.toString(); + } + if (color.startsWith("&") && color.length() == 2) return "\u00A7" + color.charAt(1); + return color; + } + + private static int[] hexToRgb(String color) { + String hex = color == null ? "" : color.trim(); + if (hex.startsWith("#")) hex = hex.substring(1); + if (hex.length() != 6) return new int[]{255, 255, 255}; + try { + return new int[]{ + Integer.parseInt(hex.substring(0,2), 16), + Integer.parseInt(hex.substring(2,4), 16), + Integer.parseInt(hex.substring(4,6), 16) + }; + } catch (Exception e) { return new int[]{255,255,255}; } + } + + private static int clamp(int v) { return Math.max(0, Math.min(255, v)); } + + /** + * Findet das schließende '>' für ein Tag das bei fromIndex beginnt. + * Berücksichtigt verschachtelte <...>. + */ + private static int findClosingAngle(String text, int fromIndex) { + int depth = 0; + for (int i = fromIndex; i < text.length(); i++) { + char ch = text.charAt(i); + if (ch == '<') depth++; + else if (ch == '>') { if (depth == 0) return i; depth--; } + } + return -1; + } + + + private static String fakeName(int i) { return String.format("~vt%03d", i); } + private static String capitalize(String s){ return s==null||s.isEmpty()?s:Character.toUpperCase(s.charAt(0))+s.substring(1); } + private static String rep(char ch, int n) { StringBuilder sb=new StringBuilder(n); for(int i=0;i=&FarbCode Symbol\n" + + "# Farben: & + Code (z.B. &6 = Gold) oder &#RRGGBB / {#RRGGBB} / <#RRGGBB>\n" + + "# Emojis und Unicode-Symbole werden unterstützt.\n" + + "# Der Symbol-Text erscheint hinter dem Spielernamen in der Tablist.\n" + + "tablist.symbol.lobby=&f\uD83C\uDFE0\n" + + "tablist.symbol.sv1=&6\u26CF\uFE0F\n" + + "# tablist.symbol.farmwelt=&a\uD83C\uDF3F\n" + + "# tablist.symbol.spielerwelt=&e\u2728\n" + + "# tablist.symbol.game=&d\uD83C\uDFAE\n"; + try (OutputStream out = new FileOutputStream(f)) { out.write(content.getBytes(StandardCharsets.UTF_8)); } + catch (Exception e) { plugin.getLogger().warning("[TablistModule] Config: " + e.getMessage()); } + } + + private void loadConfig() { + File file = new File(plugin.getDataFolder(), CONFIG_FILE); + Map map = new LinkedHashMap<>(); + if (file.exists()) { + try (BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream(file), StandardCharsets.UTF_8))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty() || line.startsWith("#")) continue; + int eq = line.indexOf('='); + if (eq < 1) continue; + map.put(line.substring(0, eq).trim(), line.substring(eq + 1)); + } + } catch (Exception e) { plugin.getLogger().warning("[TablistModule] Ladefehler: " + e.getMessage()); } + } + java.util.function.BiFunction get = (k,d) -> map.getOrDefault(k,d); + + configuredTabSize = parseInt(get.apply("tablist.tab_size", "0"), 0); + configuredColumns = parseInt(get.apply("tablist.columns", "0"), 0); + enabled = Boolean.parseBoolean(get.apply("tablist.enabled", "true")); + updateInterval = parseInt(get.apply("tablist.update_interval", "5"), 5); + layoutMode = get.apply("tablist.layout", "compact").trim().toLowerCase(); + // ── UPGRADE: column_header Modus ────────────────────────────────────── + columnHeaderMode = get.apply("tablist.column_header", "none").trim().toLowerCase(); + playerDisplayMode = get.apply("tablist.player_display", "server").trim().toLowerCase(); + + headerLine1 = get.apply("tablist.header.line1", headerLine1); + headerLine2 = get.apply("tablist.header.line2", headerLine2); + headerLine3 = get.apply("tablist.header.line3", headerLine3); + footerLine1 = get.apply("tablist.footer.line1", footerLine1); + footerLine2 = get.apply("tablist.footer.line2", footerLine2); + footerLine3 = get.apply("tablist.footer.line3", footerLine3); + compactHeader1 = get.apply("tablist.compact.header.line1", compactHeader1); + compactHeader2 = get.apply("tablist.compact.header.line2", compactHeader2); + compactHeader3 = get.apply("tablist.compact.header.line3", compactHeader3); + compactHeader1Spacer = Boolean.parseBoolean(get.apply("tablist.compact.header.line1.spacer", "false")); + compactHeader2Spacer = Boolean.parseBoolean(get.apply("tablist.compact.header.line2.spacer", "false")); + compactHeader3Spacer = Boolean.parseBoolean(get.apply("tablist.compact.header.line3.spacer", "false")); + compactHeader4 = get.apply("tablist.compact.header.line4", compactHeader4); + compactHeader4Spacer = Boolean.parseBoolean(get.apply("tablist.compact.header.line4.spacer", "false")); + compactFooter1 = get.apply("tablist.compact.footer.line1", compactFooter1); + compactFooter2 = get.apply("tablist.compact.footer.line2", compactFooter2); + compactFooter3 = get.apply("tablist.compact.footer.line3", compactFooter3); + compactFooter4 = get.apply("tablist.compact.footer.line4", compactFooter4); + compactFooter5 = get.apply("tablist.compact.footer.line5", compactFooter5); + compactFooter6 = get.apply("tablist.compact.footer.line6", compactFooter6); + compactFooter1Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line1.spacer", "false")); + compactFooter2Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line2.spacer", "false")); + compactFooter3Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line3.spacer", "false")); + compactFooter4Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line4.spacer", "false")); + compactFooter5Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line5.spacer", "false")); + compactFooter6Spacer = Boolean.parseBoolean(get.apply("tablist.compact.footer.line6.spacer", "false")); + colorSrvHeader = get.apply("tablist.color.server_header", colorSrvHeader); + showFooterServerList = Boolean.parseBoolean(get.apply("tablist.compact.footer.serverlist", "true")); + timeFormat = get.apply("tablist.time_format", timeFormat); + timeZone = get.apply("tablist.timezone", timeZone); + try { sdf = new SimpleDateFormat(timeFormat); sdf.setTimeZone(java.util.TimeZone.getTimeZone(timeZone)); } + catch (Exception e) { sdf = new SimpleDateFormat("HH:mm:ss / h:mm a"); } + + rankOrder.clear(); + String rankRaw = get.apply("tablist.rank_order", "").trim(); + if (!rankRaw.isEmpty()) for (String s : rankRaw.split(",")) { String t=s.trim(); if(!t.isEmpty()) rankOrder.add(t.toLowerCase()); } + + serverOrder.clear(); + String raw = get.apply("tablist.server_order", "").trim(); + if (!raw.isEmpty()) for (String s : raw.split(",")) { String t=s.trim(); if(!t.isEmpty()) serverOrder.add(t); } + + hiddenServers.clear(); + String hRaw = get.apply("tablist.hidden_servers", "").trim(); + if (!hRaw.isEmpty()) for (String s : hRaw.split(",")) { String t=s.trim().toLowerCase(); if(!t.isEmpty()) hiddenServers.add(t); } + + infoEntries.clear(); + String orderRaw = get.apply("tablist.info.order", "website,name,rank,server,world,time,teamspeak").trim(); + for (String id : orderRaw.split(",")) { + id = id.trim(); if (id.isEmpty()) continue; + boolean en = Boolean.parseBoolean(get.apply("tablist.info." + id + ".enabled", "true")); + String label = get.apply("tablist.info." + id + ".label", ""); + String type = get.apply("tablist.info." + id + ".type", "custom"); + String value = get.apply("tablist.info." + id + ".value", ""); + infoEntries.add(new InfoEntry(label, type, value, en)); + } + if (infoEntries.isEmpty()) { + infoEntries.add(new InfoEntry("&b&lWebsite:", "website", "&fviper-network.de", true)); + infoEntries.add(new InfoEntry("&b&lName:", "name", "", true)); + infoEntries.add(new InfoEntry("&b&lRank:", "rank", "", true)); + infoEntries.add(new InfoEntry("&b&lServer:", "server", "", true)); + infoEntries.add(new InfoEntry("&b&lWorld:", "world", "", true)); + infoEntries.add(new InfoEntry("&b&lTime:", "time", "", true)); + infoEntries.add(new InfoEntry("&b&lTeamspeak:", "teamspeak", "&fts.viper-network.de", true)); + } + + // ── Server-Symbole aus tablist.properties ───────────────────────────── + // Format: tablist.symbol.=&FarbCode Symbol + // Beispiel: tablist.symbol.lobby=&f🏠 + // tablist.symbol.sv1=&6⛏️ + serverSymbols.clear(); + for (Map.Entry entry : map.entrySet()) { + String key = entry.getKey(); + if (key.startsWith("tablist.symbol.")) { + String srvName = key.substring("tablist.symbol.".length()).trim().toLowerCase(); + String symbol = entry.getValue().trim(); + if (!srvName.isEmpty() && !symbol.isEmpty()) { + serverSymbols.put(srvName, symbol); + plugin.getLogger().info("[TablistModule] Symbol: " + srvName + " → " + symbol); + } + } + } + } + +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/modules/vanish/VanishModule.java b/src/main/java/net/viper/status/modules/vanish/VanishModule.java new file mode 100644 index 0000000..e18a1af --- /dev/null +++ b/src/main/java/net/viper/status/modules/vanish/VanishModule.java @@ -0,0 +1,272 @@ +package net.viper.status.modules.vanish; + +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.TextComponent; +import net.md_5.bungee.api.connection.ProxiedPlayer; +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.VanishProvider; + +import java.io.*; +import java.util.Collections; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * VanishModule für StatusAPI (BungeeCord) + * + * Features: + * - /vanish zum Ein-/Ausschalten + * - /vanish für Admin-Vanish anderer Spieler + * - /vanishlist – zeigt alle aktuell unsichtbaren Spieler + * - Vanish-Status wird persistent in vanish.dat gespeichert + * - Beim Login wird gespeicherter Status wiederhergestellt + * - Volle Integration mit VanishProvider → ChatModule sieht den Status + * + * Permission: + * - vanish.use → darf vanishen + * - vanish.other → darf andere Spieler vanishen + * - vanish.list → darf /vanishlist nutzen + * - chat.admin.bypass → sieht Vanish-Join/Leave-Meldungen im Chat + */ +public class VanishModule implements Module, Listener { + + private static final String PERMISSION = "chat.admin.bypass"; + private static final String PERMISSION_OTHER = "chat.admin.bypass"; + private static final String PERMISSION_LIST = "chat.admin.bypass"; + + private Plugin plugin; + + // Persistente Vanish-UUIDs (werden in vanish.dat gespeichert) + private final Set persistentVanished = + Collections.newSetFromMap(new ConcurrentHashMap<>()); + + private File dataFile; + + @Override + public String getName() { + return "VanishModule"; + } + + @Override + public void onEnable(Plugin plugin) { + this.plugin = plugin; + this.dataFile = new File(plugin.getDataFolder(), "vanish.dat"); + + load(); + + plugin.getProxy().getPluginManager().registerListener(plugin, this); + registerCommands(); + } + + @Override + public void onDisable(Plugin plugin) { + save(); + // Alle als sichtbar markieren beim Shutdown (damit beim nächsten Start + // der VanishProvider sauber ist – load() setzt sie beim Login neu) + for (UUID uuid : persistentVanished) { + VanishProvider.setVanished(uuid, false); + } + } + + // ========================================================= + // EVENTS + // ========================================================= + + /** + * Beim Login: Wenn der Spieler persistent gevanisht war, sofort + * in den VanishProvider eintragen – BEVOR das ChatModule die + * Join-Nachricht nach 2 Sekunden sendet. + */ + @EventHandler(priority = EventPriority.LOWEST) + public void onLogin(PostLoginEvent e) { + ProxiedPlayer player = e.getPlayer(); + if (persistentVanished.contains(player.getUniqueId())) { + // Status SOFORT setzen – kein Delay, damit das ChatModule (2s-Task) + // den Vanish-Status garantiert vorfindet und keine Join-Nachricht sendet. + VanishProvider.setVanished(player.getUniqueId(), true); + + // Nur die Bestätigungsnachricht an den Spieler wird verzögert, + // damit der Client bereit ist. + plugin.getProxy().getScheduler().schedule(plugin, () -> { + if (player.isConnected()) { + player.sendMessage(color("&8[&7Vanish&8] &7Du bist &cUnsichtbar&7.")); + } + }, 1, java.util.concurrent.TimeUnit.SECONDS); + } + } + + @EventHandler + public void onDisconnect(PlayerDisconnectEvent e) { + // VanishProvider cleanup – der Eintrag in persistentVanished bleibt + // erhalten damit der Status beim nächsten Login wiederhergestellt wird + VanishProvider.cleanup(e.getPlayer().getUniqueId()); + } + + // ========================================================= + // COMMANDS + // ========================================================= + + private void registerCommands() { + + // /vanish [spieler] + plugin.getProxy().getPluginManager().registerCommand(plugin, + new Command("vanish", PERMISSION, "v") { + @Override + public void execute(CommandSender sender, String[] args) { + + if (args.length == 0) { + // Sich selbst vanishen + if (!(sender instanceof ProxiedPlayer)) { + sender.sendMessage(color("&cNur Spieler!")); + return; + } + toggleVanish((ProxiedPlayer) sender, (ProxiedPlayer) sender); + + } else { + // Anderen Spieler vanishen + if (!sender.hasPermission(PERMISSION_OTHER)) { + sender.sendMessage(color("&cDu hast keine Berechtigung für /vanish .")); + return; + } + ProxiedPlayer target = ProxyServer.getInstance().getPlayer(args[0]); + if (target == null) { + sender.sendMessage(color("&cSpieler &f" + args[0] + " &cnicht gefunden.")); + return; + } + toggleVanish(sender, target); + } + } + }); + + // /vanishlist + plugin.getProxy().getPluginManager().registerCommand(plugin, + new Command("vanishlist", PERMISSION_LIST, "vlist") { + @Override + public void execute(CommandSender sender, String[] args) { + Set vanished = VanishProvider.getVanishedPlayers(); + if (vanished.isEmpty()) { + sender.sendMessage(color("&8[Vanish] &7Keine unsichtbaren Spieler.")); + return; + } + sender.sendMessage(color("&8[Vanish] &7Unsichtbare Spieler &8(" + vanished.size() + ")&7:")); + for (UUID uuid : vanished) { + ProxiedPlayer p = ProxyServer.getInstance().getPlayer(uuid); + String name = p != null ? p.getName() : uuid.toString().substring(0, 8) + "..."; + String online = p != null ? " &8(online)" : " &8(offline/persistent)"; + sender.sendMessage(color(" &8- &7" + name + online)); + } + } + }); + } + + // ========================================================= + // VANISH-LOGIK + // ========================================================= + + /** + * Schaltet den Vanish-Status eines Spielers um. + * + * @param executor Der Befehlsgeber (für Feedback-Nachrichten) + * @param target Der betroffene Spieler + */ + private void toggleVanish(CommandSender executor, ProxiedPlayer target) { + boolean nowVanished = !VanishProvider.isVanished(target); + setVanished(target, nowVanished); + + String statusMsg = nowVanished + ? "&8[&7Vanish&8] &f" + target.getName() + " &7ist jetzt &cUnsichtbar&7." + : "&8[&7Vanish&8] &f" + target.getName() + " &7ist jetzt &aSichtbar&7."; + + // Feedback an den Ausführenden + executor.sendMessage(color(statusMsg)); + + // Falls jemand anderes gevanisht wurde, auch dem Ziel Bescheid geben + if (!executor.equals(target)) { + String selfMsg = nowVanished + ? "&8[&7Vanish&8] &7Du wurdest &cUnsichtbar &7gemacht." + : "&8[&7Vanish&8] &7Du wurdest &aSichtbar &7gemacht."; + target.sendMessage(color(selfMsg)); + } + + // Admins mit chat.admin.bypass informieren (außer dem Ausführenden) + for (ProxiedPlayer p : ProxyServer.getInstance().getPlayers()) { + if (p.equals(executor) || p.equals(target)) continue; + if (p.hasPermission("chat.admin.bypass")) { + p.sendMessage(color(statusMsg)); + } + } + } + + /** + * Setzt den Vanish-Status direkt (ohne Toggle). + * Aktualisiert VanishProvider UND die persistente Liste. + */ + public void setVanished(ProxiedPlayer player, boolean vanished) { + VanishProvider.setVanished(player.getUniqueId(), vanished); + if (vanished) { + persistentVanished.add(player.getUniqueId()); + } else { + persistentVanished.remove(player.getUniqueId()); + } + save(); + } + + /** + * Öffentliche API für andere Module. + */ + public boolean isVanished(ProxiedPlayer player) { + return VanishProvider.isVanished(player); + } + + // ========================================================= + // PERSISTENZ + // ========================================================= + + private void save() { + try (BufferedWriter bw = new BufferedWriter( + new OutputStreamWriter(new FileOutputStream(dataFile), "UTF-8"))) { + for (UUID uuid : persistentVanished) { + bw.write(uuid.toString()); + bw.newLine(); + } + } catch (IOException e) { + plugin.getLogger().warning("[VanishModule] Fehler beim Speichern: " + e.getMessage()); + } + } + + private void load() { + persistentVanished.clear(); + if (!dataFile.exists()) return; + try (BufferedReader br = new BufferedReader( + new InputStreamReader(new FileInputStream(dataFile), "UTF-8"))) { + String line; + while ((line = br.readLine()) != null) { + line = line.trim(); + if (line.isEmpty()) continue; + try { + persistentVanished.add(UUID.fromString(line)); + } catch (IllegalArgumentException ignored) {} + } + } catch (IOException e) { + plugin.getLogger().warning("[VanishModule] Fehler beim Laden: " + e.getMessage()); + } + } + + // ========================================================= + // HILFSMETHODEN + // ========================================================= + + 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/verify/VerifyModule.java b/src/main/java/net/viper/status/modules/verify/VerifyModule.java new file mode 100644 index 0000000..5aeb5a6 --- /dev/null +++ b/src/main/java/net/viper/status/modules/verify/VerifyModule.java @@ -0,0 +1,196 @@ +package net.viper.status.modules.verify; + +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ChatColor; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.CommandSender; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.ProxyServer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.connection.ProxiedPlayer; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Command; +import net.viper.status.StatusAPI; +import net.md_5.bungee.api.plugin.Plugin; +import net.viper.status.module.Module; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.Charset; +import java.util.HashMap; +import java.util.Map; +import java.util.Properties; + +/** + * VerifyModule: Multi-Server Support. + * + * Fix #7: Servernamen werden jetzt case-insensitiv verglichen. + * Keys in serverConfigs werden beim Laden auf lowercase normalisiert + * und die Suche erfolgt ebenfalls lowercase. + */ +public class VerifyModule implements Module { + + private String wpVerifyUrl; + // Keys sind lowercase normalisiert für case-insensitiven Vergleich + private final Map serverConfigs = new HashMap<>(); + + @Override + public String getName() { return "VerifyModule"; } + + @Override + public void onEnable(Plugin plugin) { + loadConfig(plugin); + ProxyServer.getInstance().getPluginManager().registerCommand(plugin, new VerifyCommand()); + plugin.getLogger().fine("VerifyModule aktiviert. " + serverConfigs.size() + " Server-Konfigurationen geladen."); + } + + @Override + public void onDisable(Plugin plugin) {} + + private void loadConfig(Plugin plugin) { + String fileName = "verify.properties"; + File configFile = new File(plugin.getDataFolder(), fileName); + Properties props = new Properties(); + + if (!configFile.exists()) { + plugin.getDataFolder().mkdirs(); + try (InputStream in = plugin.getResourceAsStream(fileName); + OutputStream out = new FileOutputStream(configFile)) { + if (in == null) { plugin.getLogger().warning("Standard-config '" + fileName + "' nicht in JAR."); return; } + byte[] buffer = new byte[1024]; int length; + while ((length = in.read(buffer)) > 0) out.write(buffer, 0, length); + StatusAPI.debugLog(plugin, "Konfigurationsdatei '" + fileName + "' erstellt."); + } catch (Exception e) { plugin.getLogger().severe("Fehler beim Erstellen der Config: " + e.getMessage()); return; } + } + + try (InputStream in = new FileInputStream(configFile)) { + props.load(in); + } catch (IOException e) { e.printStackTrace(); return; } + + this.wpVerifyUrl = props.getProperty("wp_verify_url", "https://deine-wp-domain.tld"); + + // FIX #7: Keys beim Laden auf lowercase normalisieren + this.serverConfigs.clear(); + for (String key : props.stringPropertyNames()) { + if (key.startsWith("server.")) { + String[] parts = key.split("\\."); + if (parts.length == 3) { + // Servername lowercase → case-insensitiver Lookup + String serverName = parts[1].toLowerCase(); + String type = parts[2]; + ServerConfig config = serverConfigs.computeIfAbsent(serverName, k -> new ServerConfig()); + if ("id".equalsIgnoreCase(type)) { + try { config.serverId = Integer.parseInt(props.getProperty(key)); } + catch (NumberFormatException e) { plugin.getLogger().warning("Ungültige Server ID für " + serverName); } + } else if ("secret".equalsIgnoreCase(type)) { + config.sharedSecret = props.getProperty(key); + } + } + } + } + } + + private static class ServerConfig { + int serverId = 0; + String sharedSecret = ""; + } + + private class VerifyCommand extends Command { + public VerifyCommand() { super("verify"); } + + @Override + public void execute(CommandSender sender, String[] args) { + if (!(sender instanceof ProxiedPlayer)) { sender.sendMessage(ChatColor.RED + "Nur Spieler können diesen Befehl benutzen."); return; } + ProxiedPlayer p = (ProxiedPlayer) sender; + if (args.length != 1) { p.sendMessage(ChatColor.YELLOW + "Benutzung: /verify "); return; } + + // FIX #7: Servername lowercase für case-insensitiven Lookup + String serverName = p.getServer().getInfo().getName().toLowerCase(); + ServerConfig config = serverConfigs.get(serverName); + + if (config == null || config.serverId == 0 || config.sharedSecret.isEmpty()) { + p.sendMessage(ChatColor.RED + "✗ Dieser Server ist nicht in der Verify-Konfiguration hinterlegt."); + p.sendMessage(ChatColor.GRAY + "Aktueller Servername: " + ChatColor.WHITE + p.getServer().getInfo().getName()); + p.sendMessage(ChatColor.GRAY + "Bitte kontaktiere einen Admin."); + return; + } + + String token = args[0].trim(); + String playerName = p.getName(); + + HttpURLConnection conn = null; + try { + Charset utf8 = Charset.forName("UTF-8"); + String signature = hmacSHA256(playerName + token, config.sharedSecret, utf8); + String payload = "{\"player\":\"" + escapeJson(playerName) + + "\",\"token\":\"" + escapeJson(token) + + "\",\"server_id\":" + config.serverId + + ",\"signature\":\"" + signature + "\"}"; + + URL url = new URL(wpVerifyUrl + "/wp-json/mc-gallery/v1/verify"); + conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(5000); + conn.setReadTimeout(7000); + conn.setDoOutput(true); + conn.setRequestMethod("POST"); + conn.setRequestProperty("Content-Type", "application/json; charset=utf-8"); + try (OutputStream os = conn.getOutputStream()) { os.write(payload.getBytes(utf8)); } + + int code = conn.getResponseCode(); + String resp = code >= 200 && code < 300 + ? streamToString(conn.getInputStream(), utf8) + : streamToString(conn.getErrorStream(), utf8); + + if (resp != null && !resp.isEmpty() && resp.trim().startsWith("{")) { + boolean isSuccess = resp.contains("\"success\":true"); + String message = "Ein unbekannter Fehler ist aufgetreten."; + int keyIndex = resp.indexOf("\"message\":\""); + if (keyIndex != -1) { + int startIndex = keyIndex + 11; + int endIndex = resp.indexOf("\"", startIndex); + if (endIndex != -1) message = resp.substring(startIndex, endIndex); + } + if (isSuccess) { + p.sendMessage(ChatColor.GREEN + "✓ " + message); + p.sendMessage(ChatColor.GRAY + "Du kannst nun Bilder hochladen!"); + } else { + p.sendMessage(ChatColor.RED + "✗ " + message); + } + } else { + p.sendMessage(ChatColor.RED + "✗ Fehler beim Verbinden mit der Webseite (Code: " + code + ")"); + } + } catch (Exception ex) { + p.sendMessage(ChatColor.RED + "✗ Ein interner Fehler ist aufgetreten."); + ProxyServer.getInstance().getLogger().warning("Verify error: " + ex.getMessage()); + ex.printStackTrace(); + } finally { + if (conn != null) conn.disconnect(); + } + } + } + + private static String hmacSHA256(String data, String key, Charset charset) throws Exception { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(key.getBytes(charset), "HmacSHA256")); + byte[] raw = mac.doFinal(data.getBytes(charset)); + StringBuilder sb = new StringBuilder(); + for (byte b : raw) sb.append(String.format("%02x", b)); + return sb.toString(); + } + + private static String streamToString(InputStream in, Charset charset) throws IOException { + if (in == null) return ""; + try (BufferedReader br = new BufferedReader(new InputStreamReader(in, charset))) { + StringBuilder sb = new StringBuilder(); String line; + while ((line = br.readLine()) != null) sb.append(line); + return sb.toString(); + } + } + + private static String escapeJson(String s) { + return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + } +} diff --git a/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java b/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java new file mode 100644 index 0000000..672c445 --- /dev/null +++ b/src/main/java/net/viper/status/ratelimit/GlobalRateLimitFramework.java @@ -0,0 +1,119 @@ +package net.viper.status.ratelimit; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Gemeinsames Rate-Limit-Framework fuer mehrere Module. + */ +public final class GlobalRateLimitFramework { + + private static final GlobalRateLimitFramework INSTANCE = new GlobalRateLimitFramework(); + + private final Map buckets = new ConcurrentHashMap(); + + private GlobalRateLimitFramework() { + } + + public static GlobalRateLimitFramework getInstance() { + return INSTANCE; + } + + public Result check(String scope, String actorId, Rule rule) { + return check(scope, actorId, rule, System.currentTimeMillis()); + } + + public Result check(String scope, String actorId, Rule rule, long now) { + if (rule == null || !rule.enabled || scope == null || scope.isEmpty() || actorId == null || actorId.isEmpty()) { + return Result.allowed(0, 0L); + } + + String key = scope + ":" + actorId; + Bucket bucket = buckets.computeIfAbsent(key, k -> new Bucket()); + + synchronized (bucket) { + if (bucket.blockedUntil > now) { + return Result.blocked(Math.max(0L, bucket.blockedUntil - now), bucket.hits.size()); + } + + long minTs = now - Math.max(1L, rule.windowMs); + while (!bucket.hits.isEmpty() && bucket.hits.peekFirst() < minTs) { + bucket.hits.pollFirst(); + } + + bucket.hits.addLast(now); + + if (bucket.hits.size() > Math.max(1, rule.maxActions)) { + long blockMs = Math.max(1L, rule.blockMs); + bucket.blockedUntil = now + blockMs; + return Result.blocked(blockMs, bucket.hits.size()); + } + + return Result.allowed(bucket.hits.size(), 0L); + } + } + + public void clearActor(String actorId) { + if (actorId == null || actorId.isEmpty()) { + return; + } + for (String key : buckets.keySet()) { + if (key.endsWith(":" + actorId)) { + buckets.remove(key); + } + } + } + + public static final class Rule { + public final boolean enabled; + public final long windowMs; + public final int maxActions; + public final long blockMs; + + public Rule(boolean enabled, long windowMs, int maxActions, long blockMs) { + this.enabled = enabled; + this.windowMs = Math.max(1L, windowMs); + this.maxActions = Math.max(1, maxActions); + this.blockMs = Math.max(1L, blockMs); + } + } + + public static final class Result { + private final boolean blocked; + private final int currentHits; + private final long remainingBlockMs; + + private Result(boolean blocked, int currentHits, long remainingBlockMs) { + this.blocked = blocked; + this.currentHits = Math.max(0, currentHits); + this.remainingBlockMs = Math.max(0L, remainingBlockMs); + } + + public static Result blocked(long remainingBlockMs, int currentHits) { + return new Result(true, currentHits, remainingBlockMs); + } + + public static Result allowed(int currentHits, long remainingBlockMs) { + return new Result(false, currentHits, remainingBlockMs); + } + + public boolean isBlocked() { + return blocked; + } + + public int getCurrentHits() { + return currentHits; + } + + public long getRemainingBlockMs() { + return remainingBlockMs; + } + } + + private static final class Bucket { + final Deque hits = new ArrayDeque(); + long blockedUntil = 0L; + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/stats/PlayerStats.java b/src/main/java/net/viper/status/stats/PlayerStats.java new file mode 100644 index 0000000..1c9c04f --- /dev/null +++ b/src/main/java/net/viper/status/stats/PlayerStats.java @@ -0,0 +1,180 @@ +package net.viper.status.stats; + +import java.util.UUID; + +public class PlayerStats { + public final UUID uuid; + public String name; + public long firstSeen; + public long lastSeen; + public long totalPlaytime; + public long currentSessionStart; + public int joins; + + // Combat + public int kills; + public int deaths; + + // Economy + public double balance; + public double totalEarned; + public double totalSpent; + public int transactionsCount; + + // Punishments + public int bansCount; + public int mutesCount; + public int warnsCount; + public long lastPunishmentAt; // Unix-Timestamp (Sek.), 0 = nie + public String lastPunishmentType; // "ban", "mute", "warn", "kick", "" = nie + public int punishmentScore; + + public PlayerStats(UUID uuid, String name) { + this.uuid = uuid; + this.name = name; + long now = System.currentTimeMillis() / 1000L; + this.firstSeen = now; + this.lastSeen = now; + this.totalPlaytime = 0; + this.currentSessionStart = 0; + this.joins = 0; + this.kills = 0; + this.deaths = 0; + this.balance = 0.0; + this.totalEarned = 0.0; + this.totalSpent = 0.0; + this.transactionsCount = 0; + this.bansCount = 0; + this.mutesCount = 0; + this.warnsCount = 0; + this.lastPunishmentAt = 0; + this.lastPunishmentType = ""; + this.punishmentScore = 0; + } + + public synchronized void onJoin() { + long now = System.currentTimeMillis() / 1000L; + if (this.currentSessionStart == 0) this.currentSessionStart = now; + this.lastSeen = now; + this.joins++; + if (this.firstSeen == 0) this.firstSeen = now; + } + + public synchronized void onQuit() { + long now = System.currentTimeMillis() / 1000L; + if (this.currentSessionStart > 0) { + long session = now - this.currentSessionStart; + if (session > 0) this.totalPlaytime += session; + this.currentSessionStart = 0; + } + this.lastSeen = now; + } + + public synchronized long getPlaytimeWithCurrentSession() { + long now = System.currentTimeMillis() / 1000L; + if (this.currentSessionStart > 0) return totalPlaytime + (now - currentSessionStart); + return totalPlaytime; + } + + /** + * Format: uuid|name|firstSeen|lastSeen|totalPlaytime|currentSessionStart|joins| + * kills|deaths| + * balance|totalEarned|totalSpent|transactionsCount| + * bansCount|mutesCount|warnsCount|lastPunishmentAt|lastPunishmentType|punishmentScore + * + * HINWEIS: kills/deaths wurden in Version 1.17.1 als Felder 7 und 8 eingefügt. + * fromLine() ist rückwärtskompatibel (alte Dateien ohne kills/deaths werden 0 gesetzt). + */ + public synchronized String toLine() { + String safeType = (lastPunishmentType == null ? "" : lastPunishmentType).replace("|", "_"); + return uuid + "|" + name.replace("|", "_") + + "|" + firstSeen + + "|" + lastSeen + + "|" + totalPlaytime + + "|" + currentSessionStart + + "|" + joins + + "|" + kills + + "|" + deaths + + "|" + balance + + "|" + totalEarned + + "|" + totalSpent + + "|" + transactionsCount + + "|" + bansCount + + "|" + mutesCount + + "|" + warnsCount + + "|" + lastPunishmentAt + + "|" + safeType + + "|" + punishmentScore; + } + + public static PlayerStats fromLine(String line) { + String[] parts = line.split("\\|", -1); + if (parts.length < 7) return null; + try { + UUID uuid = UUID.fromString(parts[0]); + String name = parts[1]; + PlayerStats ps = new PlayerStats(uuid, name); + ps.firstSeen = Long.parseLong(parts[2]); + ps.lastSeen = Long.parseLong(parts[3]); + ps.totalPlaytime = Long.parseLong(parts[4]); + ps.currentSessionStart = Long.parseLong(parts[5]); + ps.joins = Integer.parseInt(parts[6]); + + // Erkennung ob altes Format (ohne kills/deaths, Economy ab Index 7) + // oder neues Format (kills/deaths ab Index 7, Economy ab Index 9). + // Altes Format hat 17 Felder (Index 0-16), neues hat 19 (Index 0-18). + // Heuristik: Wenn parts[7] ein gültiger Integer ist UND parts[9] wie eine + // Gleitkommazahl aussieht → neues Format. Sonst altes Format. + boolean newFormat = false; + if (parts.length >= 19) { + try { + Integer.parseInt(parts[7]); // kills + Integer.parseInt(parts[8]); // deaths + Double.parseDouble(parts[9]); // balance (Gleitkomma) + newFormat = true; + } catch (Exception ignored) {} + } + + if (newFormat) { + // Neues Format: kills ab [7], deaths ab [8], economy ab [9] + try { ps.kills = Integer.parseInt(parts[7]); } catch (Exception ignored) {} + try { ps.deaths = Integer.parseInt(parts[8]); } catch (Exception ignored) {} + if (parts.length >= 13) { + try { ps.balance = Double.parseDouble(parts[9]); } catch (Exception ignored) {} + try { ps.totalEarned = Double.parseDouble(parts[10]); } catch (Exception ignored) {} + try { ps.totalSpent = Double.parseDouble(parts[11]); } catch (Exception ignored) {} + try { ps.transactionsCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} + } + if (parts.length >= 19) { + try { ps.bansCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} + try { ps.mutesCount = Integer.parseInt(parts[14]); } catch (Exception ignored) {} + try { ps.warnsCount = Integer.parseInt(parts[15]); } catch (Exception ignored) {} + try { ps.lastPunishmentAt = Long.parseLong(parts[16]); } catch (Exception ignored) {} + ps.lastPunishmentType = parts[17]; + try { ps.punishmentScore = Integer.parseInt(parts[18]); } catch (Exception ignored) {} + } + } else { + // Altes Format (kompatibel): kills/deaths = 0, Economy ab [7] + ps.kills = 0; + ps.deaths = 0; + if (parts.length >= 11) { + try { ps.balance = Double.parseDouble(parts[7]); } catch (Exception ignored) {} + try { ps.totalEarned = Double.parseDouble(parts[8]); } catch (Exception ignored) {} + try { ps.totalSpent = Double.parseDouble(parts[9]); } catch (Exception ignored) {} + try { ps.transactionsCount = Integer.parseInt(parts[10]); } catch (Exception ignored) {} + } + if (parts.length >= 17) { + try { ps.bansCount = Integer.parseInt(parts[11]); } catch (Exception ignored) {} + try { ps.mutesCount = Integer.parseInt(parts[12]); } catch (Exception ignored) {} + try { ps.warnsCount = Integer.parseInt(parts[13]); } catch (Exception ignored) {} + try { ps.lastPunishmentAt = Long.parseLong(parts[14]); } catch (Exception ignored) {} + ps.lastPunishmentType = parts[15]; + try { ps.punishmentScore = Integer.parseInt(parts[16]); } catch (Exception ignored) {} + } + } + return ps; + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/stats/StatsManager.java b/src/main/java/net/viper/status/stats/StatsManager.java new file mode 100644 index 0000000..8c7e222 --- /dev/null +++ b/src/main/java/net/viper/status/stats/StatsManager.java @@ -0,0 +1,35 @@ +package net.viper.status.stats; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +public class StatsManager { + private final ConcurrentHashMap map = new ConcurrentHashMap<>(); + + public PlayerStats get(UUID uuid, String name) { + return map.compute(uuid, (k, v) -> { + if (v == null) { + return new PlayerStats(uuid, name != null ? name : ""); + } else { + if (name != null && !name.isEmpty()) v.name = name; + return v; + } + }); + } + + public PlayerStats getIfPresent(UUID uuid) { + return map.get(uuid); + } + + public Iterable all() { + return map.values(); + } + + public void put(PlayerStats ps) { + map.put(ps.uuid, ps); + } + + public void remove(UUID uuid) { + map.remove(uuid); + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/stats/StatsModule.java b/src/main/java/net/viper/status/stats/StatsModule.java new file mode 100644 index 0000000..46baa0a --- /dev/null +++ b/src/main/java/net/viper/status/stats/StatsModule.java @@ -0,0 +1,145 @@ +package net.viper.status.stats; + +import net.md_5.bungee.api.event.PlayerDisconnectEvent; +import net.md_5.bungee.api.event.PostLoginEvent; +import net.md_5.bungee.api.plugin.Plugin; +import net.md_5.bungee.api.plugin.Listener; +import net.md_5.bungee.event.EventHandler; +import net.viper.status.module.Module; + +import java.util.concurrent.TimeUnit; + +/** + * StatsModule: Tracking von Spielerdaten (Playtime, Joins, Kills, Deaths). + * + * Fixes: + * - BUG-1: Crash-Recovery für currentSessionStart (verhindert falsche Spielzeit nach Absturz) + * - BUG-2: kills / deaths werden jetzt getrackt und per POST /stats/update aktualisiert + */ +public class StatsModule implements Module, Listener { + + /** + * Maximale Sessionlänge nach einem Crash noch gutschreiben (24 Stunden). + * Längere Differenzen sind unrealistisch → werden ignoriert, currentSessionStart = 0 gesetzt. + */ + private static final long MAX_SESSION_SECONDS = 86_400L; + + private StatsManager manager; + private StatsStorage storage; + + @Override + public String getName() { + return "StatsModule"; + } + + @Override + public void onEnable(Plugin plugin) { + manager = new StatsManager(); + storage = new StatsStorage(plugin.getDataFolder()); + + try { + storage.load(manager); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Laden der Stats: " + e.getMessage()); + } + + // ----------------------------------------------------------------------- + // FIX BUG-1: Crash-Recovery – offene Sessions bereinigen. + // + // Bei normalem Shutdown setzt onDisable() currentSessionStart = 0 und speichert. + // Bei einem Crash (kill -9, OOM, etc.) passiert das nicht. Beim nächsten Start + // sind alle Spieler offline, aber currentSessionStart enthält noch den alten + // Timestamp. getPlaytimeWithCurrentSession() würde dann fälschlicherweise + // (now - alter_crash_timestamp) zur Spielzeit addieren → massiv falscher Wert. + // + // Fix: Nach dem Laden jeden Eintrag prüfen. Falls currentSessionStart > 0: + // - Plausible Differenz (≤ MAX_SESSION_SECONDS) → als echte Zeit gutschreiben + // - Unplausibel (> MAX_SESSION_SECONDS) → verwerfen, nur zurücksetzen + // - In beiden Fällen: currentSessionStart = 0 setzen + // ----------------------------------------------------------------------- + long now = System.currentTimeMillis() / 1000L; + int recovered = 0; + for (PlayerStats ps : manager.all()) { + synchronized (ps) { + if (ps.currentSessionStart > 0) { + long delta = now - ps.currentSessionStart; + if (delta > 0 && delta <= MAX_SESSION_SECONDS) { + ps.totalPlaytime += delta; + recovered++; + } else if (delta > MAX_SESSION_SECONDS) { + plugin.getLogger().warning( + "[StatsModule] Unplausibler currentSessionStart für " + ps.name + + " (delta=" + delta + "s > " + MAX_SESSION_SECONDS + "s). " + + "Session wird ohne Gutschrift zurückgesetzt." + ); + } + ps.currentSessionStart = 0; + } + } + } + if (recovered > 0) { + plugin.getLogger().info( + "[StatsModule] Crash-Recovery: " + recovered + " offene Session(en) bereinigt und gespeichert." + ); + try { + storage.save(manager); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Speichern nach Crash-Recovery: " + e.getMessage()); + } + } + // ----------------------------------------------------------------------- + + plugin.getProxy().getPluginManager().registerListener(plugin, this); + + // Auto-Save alle 5 Minuten + plugin.getProxy().getScheduler().schedule(plugin, () -> { + try { + storage.save(manager); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Auto-Save: " + e.getMessage()); + } + }, 5, 5, TimeUnit.MINUTES); + } + + @Override + public void onDisable(Plugin plugin) { + if (manager != null && storage != null) { + long now = System.currentTimeMillis() / 1000L; + for (PlayerStats ps : manager.all()) { + synchronized (ps) { + if (ps.currentSessionStart > 0) { + long delta = now - ps.currentSessionStart; + if (delta > 0) ps.totalPlaytime += delta; + ps.currentSessionStart = 0; + } + } + } + try { + storage.save(manager); + } catch (Exception e) { + plugin.getLogger().warning("Fehler beim Speichern (Shutdown): " + e.getMessage()); + } + } + } + + public StatsManager getManager() { + return manager; + } + + // --- Events --- + + @EventHandler + public void onJoin(PostLoginEvent e) { + try { + manager.get(e.getPlayer().getUniqueId(), e.getPlayer().getName()).onJoin(); + } catch (Exception ignored) {} + } + + @EventHandler + public void onQuit(PlayerDisconnectEvent e) { + try { + PlayerStats ps = manager.getIfPresent(e.getPlayer().getUniqueId()); + if (ps != null) ps.onQuit(); + } catch (Exception ignored) {} + } +} \ No newline at end of file diff --git a/src/main/java/net/viper/status/stats/StatsStorage.java b/src/main/java/net/viper/status/stats/StatsStorage.java new file mode 100644 index 0000000..7913271 --- /dev/null +++ b/src/main/java/net/viper/status/stats/StatsStorage.java @@ -0,0 +1,46 @@ +package net.viper.status.stats; + +import java.io.*; + +/** + * Fix #9: save() und load() sind jetzt synchronized um Race Conditions + * zwischen Auto-Save-Task und Shutdown-Aufruf zu verhindern. + */ +public class StatsStorage { + private final File file; + private final Object fileLock = new Object(); + + public StatsStorage(File pluginFolder) { + if (!pluginFolder.exists()) pluginFolder.mkdirs(); + this.file = new File(pluginFolder, "stats.dat"); + } + + public void save(StatsManager manager) { + synchronized (fileLock) { + try (BufferedWriter bw = new BufferedWriter(new FileWriter(file))) { + for (PlayerStats ps : manager.all()) { + bw.write(ps.toLine()); + bw.newLine(); + } + bw.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + public void load(StatsManager manager) { + if (!file.exists()) return; + synchronized (fileLock) { + try (BufferedReader br = new BufferedReader(new FileReader(file))) { + String line; + while ((line = br.readLine()) != null) { + PlayerStats ps = PlayerStats.fromLine(line); + if (ps != null) manager.put(ps); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } +} diff --git a/src/main/resources/chat.yml b/src/main/resources/chat.yml new file mode 100644 index 0000000..b8d34c5 --- /dev/null +++ b/src/main/resources/chat.yml @@ -0,0 +1,376 @@ +# ============================================================ +# StatusAPI - ChatModule Konfiguration +# Kompatibel mit Java & Bedrock (Geyser) | BungeeCord Secure Chat +# ============================================================ + +# Standard-Kanal beim Einloggen +default-channel: "global" + +server-colors: + default: "&7" # Fallback für unbekannte Server + lobby: + color: "&a" + display: "Lobby" # Anzeigename (optional, sonst wird der echte Servername verwendet) + survival: + color: "&#E8A020" + display: "Survival" + skyblock: + color: "&b" + display: "SkyBlock" + citybuild: + color: "&#A020E8" + display: "CityBuild" + minigames: + color: "&e" + display: "MiniGames" + +chatlog: + enabled: true + retention-days: 7 # 7 oder 14 + +reports: + enabled: true + webhook-enabled: true + confirm-message: "&aDein Report &8({id}) &awurde eingereicht. Danke!" + close-permission: "chat.admin.bypass" + view-permission: "chat.admin.bypass" + # Leer = jeder Spieler darf reporten, sonst Permission eintragen (z.B. "chat.report") + report-permission: "" + cooldown: 60 + # Discord Webhook für Report-Benachrichtigungen (leer = deaktiviert) + discord-webhook: "" + # Telegram Chat-ID für Report-Benachrichtigungen (leer = deaktiviert) + telegram-chat-id: "" + +# ============================================================ +# KANÄLE +# Jeder Kanal hat eigene Permissions, Format und Brücken. +# format-Platzhalter: +# {server} - Servername +# {prefix} - LuckPerms Prefix +# {player} - Spielername +# {suffix} - LuckPerms Suffix +# {message} - Nachricht +# {channel} - Kanalname +# ============================================================ +channels: + global: + name: "Global" + symbol: "G" + permission: "" + color: "&a" + format: "&8[&a{server}&8] {prefix}&r{player}&8: &f{message}" + discord-webhook: "" + discord-channel-id: "" + telegram-chat-id: "" + # Themen-ID für Telegram-Gruppen mit Themen (0 = kein Thema / normale Gruppe) + telegram-thread-id: 0 + + local: + name: "Local" + symbol: "L" + permission: "chat.channel.local" + color: "&e" + local-only: true + format: "&8[&e{server}&8] {prefix}&r{player}&8: &f{message}" + discord-webhook: "" + discord-channel-id: "" + telegram-chat-id: "" + telegram-thread-id: 0 + + trade: + name: "Trade" + symbol: "T" + permission: "chat.channel.trade" + color: "&6" + format: "&8[&6TRADE&8] &8[&7{server}&8] {prefix}&r{player}&8: &f{message}" + discord-webhook: "" + discord-channel-id: "" + telegram-chat-id: "" + telegram-thread-id: 0 + + staff: + name: "Staff" + symbol: "S" + permission: "chat.channel.staff" + color: "&c" + format: "&8[&cSTAFF&8] &8[&7{server}&8] {prefix}&r{player}&8: &f{message}" + discord-webhook: "" + discord-channel-id: "" + telegram-chat-id: "" + telegram-thread-id: 0 + use-admin-bridge: true + +# ============================================================ +# HELPOP +# ============================================================ +helpop: + # Format der HelpOp-Nachricht + format: "&8[&eHELPOP&8] &f{player}&8@&7{server}&8: &e{message}" + # Wer bekommt HelpOp zu sehen + receive-permission: "chat.helpop.receive" + # Cooldown in Sekunden + cooldown: 30 + # Bestätigungsnachricht an den Spieler + confirm-message: "&aHilferuf wurde an das Team gesendet!" + # Discord / Telegram auch für HelpOp + discord-webhook: "" + telegram-chat-id: "" + +# ============================================================ +# BROADCAST +# ============================================================ +broadcast: + format: "&c&l[&6&lBroadcast&c&l] &r&e{message}" + permission: "chat.broadcast" + +# ============================================================ +# PRIVATE NACHRICHTEN +# ============================================================ +private-messages: + enabled: true + format-sender: "&8[&7Du &8→ &b{player}&8] &f{message}" + format-receiver: "&8[&b{player} &8→ &7Dir&8] &f{message}" + # Social Spy: Admins können alle PMs sehen + format-social-spy: "&8[&dSPY &7{sender} &8→ &7{receiver}&8] &f{message}" + social-spy-permission: "chat.socialspy" + +# ============================================================ +# JOIN / LEAVE NACHRICHTEN +# Platzhalter: +# {player} - Spielername +# {prefix} - LuckPerms Prefix +# {suffix} - LuckPerms Suffix +# {server} - Zuletzt bekannter Server (bei Leave) oder "Netzwerk" +# ============================================================ +join-leave: + enabled: true + # Normale Join/Leave-Nachrichten (für alle sichtbar) + join-format: "&8[&a+&8] {prefix}&a{player}&r &7hat das Netzwerk betreten." + leave-format: "&8[&c-&8] {prefix}&c{player}&r &7hat das Netzwerk verlassen." + # Vanish: Unsichtbare Spieler erzeugen keine normalen Join/Leave-Meldungen. + # Ist vanish-show-to-admins true, sehen Admins mit bypass-permission eine + # abweichende, dezente Benachrichtigung. + vanish-show-to-admins: true + vanish-join-format: "&8[&7+&8] &8{player} &7hat das Netzwerk betreten. &8(Vanish)" + vanish-leave-format: "&8[&7-&8] &8{player} &7hat das Netzwerk verlassen. &8(Vanish)" + # Brücken-Weitergabe (leer = deaktiviert) + discord-webhook: "" + telegram-chat-id: "" + telegram-thread-id: 0 + +# ============================================================ +# GLOBALES RATE-LIMIT-FRAMEWORK +# Zentraler Schutz für Chat/PM/Command-Flood. +# ============================================================ +rate-limit: + chat: + enabled: true + window-ms: 2500 + max-actions: 3 + block-ms: 6000 + message: "&cBitte nicht so schnell schreiben!" + + private-messages: + enabled: true + window-ms: 5000 + max-actions: 4 + block-ms: 10000 + message: "&cDu sendest zu viele private Nachrichten. Bitte warte kurz." + +# ============================================================ +# MUTE +# ============================================================ +mute: + # Standard-Mute-Dauer in Minuten (0 = permanent) + default-duration-minutes: 60 + # Nachricht an gemuteten Spieler + muted-message: "&cDu bist aktuell stummgeschaltet. Noch: &f{time}" + +# ============================================================ +# EMOJI +# Spieler schreiben :smile: -> wird zu \uD83D\uDE0A konvertiert +# Bedrock-Spieler erhalten Fallback-Text wenn kein Unicode +# ============================================================ +emoji: + enabled: true + # Ob Bedrock-Spieler (via Geyser) auch Emojis erhalten + bedrock-support: true + mappings: + ":smile:": "\uD83D\uDE0A" + ":laugh:": "\uD83D\uDE04" + ":sad:": "\uD83D\uDE22" + ":cry:": "\uD83D\uDE2D" + ":angry:": "\uD83D\uDE20" + ":heart:": "\u2764\uFE0F" + ":fire:": "\uD83D\uDD25" + ":star:": "\u2B50" + ":check:": "\u2705" + ":x:": "\u274C" + ":warning:": "\u26A0\uFE0F" + ":thumbsup:": "\uD83D\uDC4D" + ":thumbsdown:": "\uD83D\uDC4E" + ":wave:": "\uD83D\uDC4B" + ":clap:": "\uD83D\uDC4F" + ":sword:": "\u2694\uFE0F" + ":shield:": "\uD83D\uDEE1\uFE0F" + ":diamond:": "\uD83D\uDC8E" + ":crown:": "\uD83D\uDC51" + ":skull:": "\uD83D\uDC80" + ":sun:": "\u2600\uFE0F" + ":moon:": "\uD83C\uDF19" + ":tree:": "\uD83C\uDF33" + ":house:": "\uD83C\uDFE0" + ":money:": "\uD83D\uDCB0" + ":rocket:": "\uD83D\uDE80" + ":rainbow:": "\uD83C\uDF08" + ":ghost:": "\uD83D\uDC7B" + ":gift:": "\uD83C\uDF81" + ":cake:": "\uD83C\uDF82" + ":chicken:": "\uD83D\uDC14" + ":pig:": "\uD83D\uDC37" + ":creeper:": "\uD83D\uDCA3" + ":gg:": "\uD83C\uDFAE" + +# ============================================================ +# DISCORD INTEGRATION +# ============================================================ +discord: + enabled: false + # Bot-Token für bidirektionale Kommunikation + bot-token: "YOUR_BOT_TOKEN_HERE" + # Server (Guild) ID + guild-id: "YOUR_GUILD_ID" + # Polling-Intervall in Sekunden (Discord → Minecraft) + poll-interval: 3 + # Format für Discord → Minecraft Nachrichten + from-discord-format: "&9[&bDiscord&9] &b{user}&8: &f{message}" + # Extra Admin-Kanal (für Staff-Kanal und HelpOp) + admin-channel-id: "" + # Standard-Embed-Farbe (Hex ohne #) + embed-color: "5865F2" + +# ============================================================ +# TELEGRAM INTEGRATION +# ============================================================ +telegram: + enabled: false + # Bot-Token von @BotFather + bot-token: "YOUR_TELEGRAM_BOT_TOKEN" + # Polling-Intervall in Sekunden + poll-interval: 3 + # Format für Telegram → Minecraft Nachrichten + from-telegram-format: "&3[&bTelegram&3] &b{user}&8: &f{message}" + # Extra Admin-Chat-ID (für Staff-Kanal und HelpOp) + admin-chat-id: "" + # Themen-Gruppe: Topic-ID für den Chat-Kanal (0 = kein Topic / normale Gruppe) + # Die message_thread_id findest du indem du eine Nachricht im Topic weiterleitest + # und dir die forwarded_from_message_id anschaust, oder via Bot-API getUpdates. + chat-topic-id: 0 + # Topic-ID für den Admin-Kanal (0 = kein Topic) + admin-topic-id: 0 + +# ============================================================ +# ACCOUNT-VERKNÜPFUNG (Discord & Telegram) +# Spieler können ihre Minecraft-Accounts mit Discord/Telegram +# verknüpfen damit ihr Name im Chat angezeigt wird. +# ============================================================ +account-linking: + enabled: true + # Token läuft nach X Minuten ab + token-expire-minutes: 10 + # Nachricht die der Spieler nach /linkdiscord bekommt + discord-link-message: "&aSchreibe den folgenden Code als Nachricht an unseren Discord-Bot:\n&f&l{token}\n&7Der Code läuft in &f10 Minuten &7ab." + # Nachricht die der Spieler nach /linktelegram bekommt + telegram-link-message: "&aSchreibe den folgenden Code als Nachricht an unseren Telegram-Bot:\n&f&l{token}\n&7Der Code läuft in &f10 Minuten &7ab." + # Bestätigung nach erfolgreicher Verknüpfung (im Spiel) + success-discord: "&aDiscord-Account erfolgreich verknüpft! &8(&7{discord}&8)" + success-telegram: "&aTelegram-Account erfolgreich verknüpft! &8(&7{telegram}&8)" + # Bestätigung die der Bot in Discord/Telegram schickt + bot-success-discord: "✅ Dein Minecraft-Account **{player}** wurde erfolgreich verknüpft!" + bot-success-telegram: "✅ Dein Minecraft-Account {player} wurde erfolgreich verknüpft!" + # Format wenn verknüpfter Nutzer in Discord/Telegram schreibt + # {player} = Minecraft-Name, {user} = Discord/Telegram-Name, {message} = Nachricht + linked-discord-format: "&9[&bDiscord&9] &f{player} &8(&7{user}&8)&8: &f{message}" + linked-telegram-format: "&3[&bTelegram&3] &f{player} &8(&7{user}&8)&8: &f{message}" + # Themen-ID für den Admin-Chat (0 = kein Thema) + admin-thread-id: 0 + +# ============================================================ +# ADMIN BYPASS +# Spieler mit dieser Permission können nicht geblockt werden +# und sind von Mutes ausgenommen +# ============================================================ +admin: + bypass-permission: "chat.admin.bypass" + # Admins erhalten Benachrichtigung bei Mutes/Blocks + notify-permission: "chat.admin.notify" + +# ============================================================ +# CHAT-FILTER & ANTI-SPAM +# ============================================================ +chat-filter: + anti-spam: + enabled: true + cooldown-ms: 1500 + max-messages: 3 + message: "&cBitte nicht so schnell schreiben!" + duplicate-check: + enabled: true + message: "&cBitte keine identischen Nachrichten senden." + blacklist: + enabled: true + words: + - "beispielwort1" + - "beispielwort2" + caps-filter: + enabled: true + min-length: 6 + max-percent: 70 + + anti-ad: + enabled: true + message: "&cWerbung ist in diesem Chat nicht erlaubt!" + # Domains/Substrings die NICHT geblockt werden (z.B. eigene Serveradresse) + # Vergleich ist case-insensitiv und prüft ob der Substring im Match enthalten ist + whitelist: + - "viper-network.de" + - "m-viper.de" + - "https://www.spigotmc.org" + # TLDs die als Werbung gewertet werden. + # Leer = alle Domain-Treffer blockieren (nicht empfohlen, hohe False-Positive-Rate) + blocked-tlds: + - "net" + - "com" + - "de" + - "org" + - "gg" + - "io" + - "eu" + - "tv" + - "xyz" + - "info" + - "me" + - "cc" + - "co" + - "app" + - "online" + - "site" + - "fun" + +# ============================================================ +# MENTIONS (@Spielername) +# ============================================================ +mentions: + enabled: true + highlight-color: "&e&l" + sound: "ENTITY_EXPERIENCE_ORB_PICKUP" + allow-toggle: true + notify-prefix: "&e&l[Mention] &r" + +# ============================================================ +# CHAT-HISTORY +# ============================================================ +chat-history: + max-lines: 50 + default-lines: 10 \ No newline at end of file diff --git a/src/main/resources/filter.yml b/src/main/resources/filter.yml new file mode 100644 index 0000000..f4f6e85 --- /dev/null +++ b/src/main/resources/filter.yml @@ -0,0 +1,16 @@ +# ============================================================ +# StatusAPI - ChatModule Wort-Blacklist +# Wörter werden case-insensitiv und als Teilwort geprüft. +# Erkannte Wörter werden durch **** ersetzt. +# +# Diese Datei wird bei /chatreload automatisch neu eingelesen. +# Wörter die hier stehen ÜBERSCHREIBEN NICHT die Einträge in +# chat.yml → beide Listen werden zusammengeführt. +# ============================================================ + +words: + - beispielwort1 + - beispielwort2 + # Hier eigene Wörter eintragen, eines pro Zeile: + # - schimpfwort + # - spam \ No newline at end of file diff --git a/src/main/resources/messages.txt b/src/main/resources/messages.txt new file mode 100644 index 0000000..8c1f95a --- /dev/null +++ b/src/main/resources/messages.txt @@ -0,0 +1,18 @@ +§8[§2Viper-Netzwerk§8] §7Der Server läuft 24/7 – also keine Hektik beim Spielen :) +§8[§2Viper-Netzwerk§8] §7Dies ist ein privater Server – hier zählt der Zusammenhalt. +§8[§dTipp§8] §7Wenn du denkst, du bist sicher… schau nochmal nach. Creeper machen keine Geräusche beim Tippen. +§8[§2Viper-Netzwerk§8] §7Wähle einen Server, leg los – der Rest ergibt sich. Oder explodiert. +§8[§2Viper-Netzwerk§8] §7Mehr Server. Mehr Blöcke. Mehr Unfälle. Willkommen! +§8[§dTipp§8] §7Halte eine Spitzhacke mit Glück bereit. Man weiß nie, wann das nächste Erz kommt. +§8[§dTipp§8] §7Mit §e/home§7 kannst du dich jederzeit nach Hause teleportieren. +§8[§2Viper-Netzwerk§8] §7Das wichtigste Plugin? Du selbst. Spiel fair, sei kreativ! +§8[§2Viper-Netzwerk§8] §7Redstone ist keine Magie – aber fast. +§8[§dTipp§8] §7Schilde sind cool. Besonders wenn Skelette zielen. +§8[§2Viper-Netzwerk§8] §7Wenn du in Lava fällst, bist du nicht der Erste. Nur der Nächste. +§8[§dTipp§8] §7Villager sind nicht dumm – nur sehr… eigen. +§8[§2Viper-Netzwerk§8] §7Bau groß, bau sicher – oder bau eine Treppe zur Nachbarschaftsklage. +§8[§2Viper-Netzwerk§8] §7Gras wächst. Spieler auch. Gib jedem eine Chance! +§8[§2Viper-Netzwerk§8] §7Ein Creeper ist keine Begrüßung. Es sei denn, du willst es spannend machen. +§8[§dTipp§8] §7Ein voller Magen ist halbe Miete. Farmen lohnt sich! +§8[§2Viper-Netzwerk§8] §7Wir haben keine Probleme – nur Redstone-Schaltungen mit Charakter. +§8[§dTipp§8] §7Markiere dein Grundstück mit §e/p claim§7, bevor es jemand anderes tut! \ No newline at end of file diff --git a/src/main/resources/network-guard.properties b/src/main/resources/network-guard.properties new file mode 100644 index 0000000..caef562 --- /dev/null +++ b/src/main/resources/network-guard.properties @@ -0,0 +1,135 @@ +# =========================== +# NETWORK INFO MODUL +# =========================== +networkinfo.enabled=true +networkinfo.command.enabled=true +# Aus Datenschutzgruenden standardmaessig aus. Wenn true, erscheinen alle Spielernamen im JSON. +networkinfo.include_player_names=false + +# Discord Webhook fuer Status-, Warn- und Attack-Meldungen +networkinfo.webhook.enabled=false +networkinfo.webhook.url= +networkinfo.webhook.username= +networkinfo.webhook.thumbnail_url= +networkinfo.webhook.notify_start_stop=true +# compact = kurze Texte | detailed = strukturierte Embeds mit Feldern +networkinfo.webhook.embed_mode=detailed +networkinfo.webhook.check_seconds=30 + +# Alert-Schwellwerte +networkinfo.alert.memory_percent=90 +networkinfo.alert.player_percent=95 +networkinfo.alert.cooldown_seconds=300 +# Proxy-TPS Alert (20.0 = perfekt, Werte < 20 zeigen Main-Thread-Lag am Proxy) +networkinfo.alert.tps_enabled=true +networkinfo.alert.tps_threshold=18.0 + +# Attack Meldungen (Detected/Stopped) +networkinfo.attack.enabled=true +# Nutzt automatisch networkinfo.webhook.url +networkinfo.attack.source= +# API-Key fuer POST /network/attack +networkinfo.attack.api_key= + +# =========================== +# ANTIBOT / ATTACK GUARD +# =========================== +antibot.enabled=true + +# Profile: strict | high-traffic +# strict: agressiver Schutz, schnelleres Blocken, VPN-Check standardmaessig aktiv +# high-traffic: toleranter fuer grosse Netzwerke mit Lastspitzen +antibot.profile=high-traffic + +# Presets (Referenz): +# strict -> max_cps=120, start_cps=220, stop_cps=120, ip/min=18, block_seconds=900, vpn_check.enabled=true +# high-traffic -> max_cps=180, start_cps=300, stop_cps=170, ip/min=24, block_seconds=600, vpn_check.enabled=false +# Hinweis: Werte unten ueberschreiben das Profil bei Bedarf. + +# Globaler Traffic +antibot.max_cps=180 +antibot.attack.start_cps=300 +antibot.attack.stop_cps=170 +antibot.attack.stop_grace_seconds=25 + +# Pro-IP Limiter +antibot.ip.max_connections_per_minute=24 +antibot.ip.block_seconds=600 +antibot.kick_message=Zu viele Verbindungen von deiner IP. Bitte warte kurz. + +# Optionaler VPN/Proxy/Hosting Check (ip-api) +antibot.vpn_check.enabled=false +antibot.vpn_check.block_proxy=true +antibot.vpn_check.block_hosting=true +antibot.vpn_check.cache_minutes=30 +antibot.vpn_check.timeout_ms=2500 + +# Sicherheitslog fuer Angreifer/VPN/Proxy-Events (mit Name/UUID falls verfuegbar) +antibot.security_log.enabled=true +antibot.security_log.file=antibot-security.log + +# Lernmodus: Muster mitschreiben, Score bilden und erst ab Schwellwert blockieren. +antibot.learning.enabled=true +antibot.learning.score_threshold=100 +antibot.learning.decay_per_second=2 +antibot.learning.state_window_seconds=120 + +# Punktelogik pro Muster +antibot.learning.rapid.window_ms=1500 +antibot.learning.rapid.points=12 +antibot.learning.ip_rate_exceeded.points=30 +antibot.learning.vpn_proxy.points=40 +antibot.learning.vpn_hosting.points=30 +antibot.learning.attack_mode.points=12 +antibot.learning.high_cps.points=10 +antibot.learning.recent_events.limit=30 + +# =========================== +# BACKEND JOIN GUARD SYNC (optional) +# =========================== +# Diese Werte koennen von BackendJoinGuard im StatusAPI-Sync-Modus abgeholt werden. +# Standalone bleibt weiterhin moeglich. +backendguard.enforcement_enabled=true +backendguard.log_blocked_attempts=true +backendguard.kick_message=&cBitte verbinde dich nur über den Proxy-Server. +# Wichtig: Hier nur echte Proxy-IP(s) eintragen. +backendguard.allowed_proxy_ips=127.0.0.1,::1,10.0.0.10 +# Optional: internes Proxy-Netz als CIDR +backendguard.allowed_proxy_cidrs=10.0.0.0/24 + +# Optionaler API-Key fuer GET /network/backendguard/config +# Leer = kein API-Key erforderlich (nur im internen Netzwerk empfohlen) +backendguard.sync.api_key= + +# =========================== +# MULTI ACCOUNT GUARD +# =========================== +# Verhindert, dass ein Spieler mit zwei Accounts gleichzeitig online ist. +multiaccountguard.enabled=true + +# IP-Check: Gleiche IP mit unterschiedlichem Namen -> blockieren +multiaccountguard.check_ip=true + +# UUID-Check: Gleiche UUID mit unterschiedlichem Namen -> blockieren (Bedrock-Edge-Cases) +multiaccountguard.check_uuid=true + +# true = bestehenden (alten) Account rauswerfen, neuen reinlassen +# false = neuen Account blockieren (Standard) +multiaccountguard.kick_existing=false + +# Kick-Nachricht (& fuer Farbcodes, \n fuer Zeilenumbruch) +multiaccountguard.kick_message=&cDu bist bereits mit einem anderen Account online!\n&7Bitte trenne deinen anderen Account zuerst. + +# Staff-Benachrichtigung bei Konflikt (Permission: statusapi.staff.notify) +multiaccountguard.staff_notify.enabled=true +multiaccountguard.staff_notify.format=&8[&cMAG&8] &e{blocked} &7wurde blockiert &8(2. Account von &e{existing}&8) &7| IP: &f{ip} + +# Temporaerer IP-Bann nach X Versuchen (Integration mit AntiBotModule) +# max_attempts: Anzahl Konflikte bevor die IP gebannt wird +# duration_secs: Bann-Dauer in Sekunden +multiaccountguard.tempban.enabled=true +multiaccountguard.tempban.max_attempts=3 +multiaccountguard.tempban.duration_secs=300 + +# Discord-Meldung bei jedem Konflikt (nutzt networkinfo.webhook.url automatisch) +multiaccountguard.webhook.enabled=true \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml new file mode 100644 index 0000000..12ca058 --- /dev/null +++ b/src/main/resources/plugin.yml @@ -0,0 +1,312 @@ +name: StatusAPI +main: net.viper.status.StatusAPI +version: 4.1.3 +author: M_Viper +description: StatusAPI für BungeeCord inkl. Update-Checker, Modul-System und ChatModule +# Mindestanforderung: Minecraft 1.20 / BungeeCord mit PlayerChatEvent-Unterstützung + +softdepend: + - LuckPerms + - Geyser-BungeeCord + +commands: + # ── HelpModule ──────────────────────────────────────────── + help: + description: Zeigt alle verfügbaren Befehle (Admin-Befehle nur mit Berechtigung) + usage: / help + # Hinweis: Der Befehlsname ist in verify.properties unter statusapi.help konfigurierbar + # Beispiel: statusapi.help=vn → /vn help + + # ── ScoreboardModule ────────────────────────────────────── + scoreboard: + description: Scoreboard ein-/ausblenden oder zwischen Player/Admin wechseln + usage: /scoreboard [hide|show|player|admin] + aliases: [sb, togglesb] + + # ── StatusAPI Admin ─────────────────────────────────────── + statusapi: + description: StatusAPI verwalten (Reload, Info) + usage: /statusapi reload + aliases: [sapi] + permission: statusapi.admin + + # /pay und /ecoadmin werden von NexEco (Spigot) verwaltet + + # ── VanishModule ────────────────────────────────────────── + vanish: + description: Vanish ein-/ausschalten + usage: /vanish [Spieler] + aliases: [v] + + vanishlist: + description: Alle unsichtbaren Spieler anzeigen + usage: /vanishlist + aliases: [vlist] + + # ── Verify Modul ────────────────────────────────────────── + verify: + description: Verifiziere dich mit einem Token + usage: /verify + + # ── ForumBridge Modul ───────────────────────────────────── + forumlink: + description: Verknüpfe deinen Minecraft-Account mit dem Forum + usage: /forumlink + aliases: [fl] + + forum: + description: Zeigt ausstehende Forum-Benachrichtigungen an + usage: /forum + + # ── NetworkInfo Modul ───────────────────────────────────── + netinfo: + description: Zeigt erweiterte Proxy- und Systeminfos an + usage: /netinfo + + antibot: + description: Zeigt AntiBot-Status und Verwaltung + usage: /antibot + + # ── AutoMessage Modul ───────────────────────────────────── + automessage: + description: AutoMessage Verwaltung + usage: /automessage reload + + # ── ChatModule – Kanal ──────────────────────────────────── + channel: + description: Kanal wechseln oder Kanalliste anzeigen + usage: /channel [kanalname] + aliases: [ch, kanal] + + # ── ChatModule – HelpOp ─────────────────────────────────── + helpop: + description: Sende eine Hilfeanfrage an das Team + usage: /helpop + + # ── ChatModule – Privat-Nachrichten ─────────────────────── + msg: + description: Sende eine private Nachricht + usage: /msg + aliases: [tell, w, whisper] + + r: + description: Antworte auf die letzte private Nachricht + usage: /r + aliases: [reply, antwort] + + # ── ChatModule – Blockieren ─────────────────────────────── + ignore: + description: Spieler ignorieren + usage: /ignore + aliases: [block] + + unignore: + description: Spieler nicht mehr ignorieren + usage: /unignore + aliases: [unblock] + + # ── ChatModule – Mute (Admin) ───────────────────────────── + chatmute: + description: Spieler im Chat stumm schalten + usage: /chatmute [Minuten] + aliases: [gmute] + + chatunmute: + description: Chat-Stummschaltung aufheben + usage: /chatunmute + aliases: [gunmute] + + # ── ChatModule – Selbst-Mute ────────────────────────────── + chataus: + description: Eigenen Chat-Empfang ein-/ausschalten + usage: /chataus + aliases: [togglechat, chaton, chatoff] + + # ── ChatModule – Broadcast ──────────────────────────────── + broadcast: + description: Nachricht an alle Spieler senden + usage: /broadcast + aliases: [bc, alert] + + # ── ChatModule – Emoji ──────────────────────────────────── + emoji: + description: Liste aller verfügbaren Emojis + usage: /emoji + aliases: [emojis] + + # ── ChatModule – Social Spy ─────────────────────────────── + socialspy: + description: Private Nachrichten mitlesen (Admin) + usage: /socialspy + aliases: [spy] + + # ── ChatModule – Reload ─────────────────────────────────── + chatreload: + description: Chat-Konfiguration neu laden + usage: /chatreload + + # ── ChatModule – Admin-Info ─────────────────────────────── + chatinfo: + description: Chat-Informationen ueber einen Spieler anzeigen (Admin) + usage: /chatinfo + + # ── ChatModule – Chat-History ───────────────────────────── + chathist: + description: Chat-History aus dem Logfile anzeigen (Admin) + usage: /chathist [Spieler] [Anzahl] + + # ── ChatModule – Mentions ───────────────────────────────── + mentions: + description: Mention-Benachrichtigungen ein-/ausschalten + usage: /mentions + aliases: [mention] + + # ── ChatModule – Plugin-Bypass ──────────────────────────── + chatbypass: + description: ChatModule fuer naechste Eingabe ueberspringen (fuer Plugin-Dialoge wie CMI) + usage: /chatbypass + aliases: [cbp] + + # ── ChatModule – Account-Verknuepfung ───────────────────── + # FIX #4: Command-Namen stimmen jetzt mit der Code-Registrierung überein. + # Im ChatModule wird "discordlink" mit Alias "dlink" registriert, + # und "telegramlink" mit Alias "tlink". + discordlink: + description: Minecraft-Account mit Discord verknuepfen + usage: /discordlink + aliases: [dlink] + + telegramlink: + description: Minecraft-Account mit Telegram verknuepfen + usage: /telegramlink + aliases: [tlink] + + unlink: + description: Account-Verknuepfung aufheben + usage: /unlink + + # ── ChatModule – Report ─────────────────────────────────── + report: + description: Spieler melden + usage: /report + + reports: + description: Offene Reports anzeigen (Admin) + usage: /reports [all] + + reportclose: + description: Report schliessen (Admin) + usage: /reportclose + + # ── ServerSwitcherModule ────────────────────────────────── + go: + description: Schneller Serverwechsel ueber Chat-Menue oder direkt + usage: /go [servername] + aliases: [wechsel, switch] + +permissions: + # ── StatusAPI Core ──────────────────────────────────────── + statusapi.admin: + description: Zugang zu StatusAPI-Administrationsbefehlen (reload etc.) + default: op + + statusapi.update.notify: + description: Erlaubt Update-Benachrichtigungen + default: op + + statusapi.netinfo: + description: Zugriff auf /netinfo + default: op + + statusapi.antibot: + description: Zugriff auf /antibot + default: op + + statusapi.automessage: + description: Zugriff auf /automessage reload + default: op + + # ── MultiAccountGuard ───────────────────────────────────── + # KEIN default – Permission muss manuell vergeben werden! + # lp user permission set statusapi.multiaccountguard.bypass true + statusapi.multiaccountguard.bypass: + description: Erlaubt mehrere gleichzeitige Accounts (nur manuell vergeben) + + statusapi.staff.notify: + description: Empfaengt Ingame-Benachrichtigungen vom MultiAccountGuard + default: false + + # ── ChatModule – Kanaele ────────────────────────────────── + chat.channel.local: + description: Zugang zum Local-Kanal + default: true + + chat.channel.trade: + description: Zugang zum Trade-Kanal + default: true + + chat.channel.staff: + description: Zugang zum Staff-Kanal + default: false + + # ── ChatModule – HelpOp ─────────────────────────────────── + chat.helpop.receive: + description: HelpOp-Nachrichten empfangen + default: false + + # ── ChatModule – Mute ───────────────────────────────────── + chat.mute: + description: Spieler muten / unmuten + default: false + + # ── ChatModule – Broadcast ──────────────────────────────── + chat.broadcast: + description: Broadcast-Nachrichten senden + default: false + + # ── ChatModule – Social Spy ─────────────────────────────── + chat.socialspy: + description: Private Nachrichten mitlesen + default: false + + # ── ChatModule – Admin ──────────────────────────────────── + chat.admin.bypass: + description: Admin-Bypass - Kann nicht geblockt/gemutet werden + default: op + + chat.admin.notify: + description: Benachrichtigungen ueber Mutes und Blocks erhalten + default: false + + # ── ChatModule – Report ─────────────────────────────────── + chat.report: + description: Spieler reporten (/report) + default: true + + # ── ChatModule – Farben ─────────────────────────────────── + chat.color: + description: Farbcodes (&a, &b, ...) im Chat nutzen + default: false + + chat.color.format: + description: Formatierungen (&l, &o, &n, ...) im Chat nutzen + default: false + + # ── ChatModule – Filter ─────────────────────────────────── + chat.filter.bypass: + description: Chat-Filter (Anti-Spam, Caps, Blacklist) umgehen + default: false + + # ── CommandBlocker ──────────────────────────────────────── + commandblocker.bypass: + description: Command-Blocker umgehen + default: op + + commandblocker.admin: + description: CommandBlocker verwalten (/cb) + default: op + + # ── ServerSwitcherModule ────────────────────────────────── + serverswitcher.use: + description: Zugriff auf /go (Schneller Serverwechsel) + default: false \ No newline at end of file diff --git a/src/main/resources/scoreboard.properties b/src/main/resources/scoreboard.properties new file mode 100644 index 0000000..186e465 --- /dev/null +++ b/src/main/resources/scoreboard.properties @@ -0,0 +1,147 @@ +# ScoreboardModule Konfiguration +# Platzhalter Spieler: %player% %rank% %money% %server% %compass% %health% %hearts% %date% +# %ping% %online% %maxplayers% %time% %playtime% %news% +# %x% %y% %z% %world% %gamemode% %exp% %food% %foodsym% %speed% +# Platzhalter Admin: %tps% %ram% %proxymem% %uptime% %servers% +# Ticket (Spieler): %ticket_my_open% +# Ticket (Supporter): %ticket_open% +# Ticket (Admin): %ticket_open% %ticket_claimed% %ticket_rating_good% %ticket_rating_bad% %ticket_rating_pct% +# Gradient: %gradient:FARBE1:FARBE2:TEXT% (beliebig viele Farb-Stopps) +# Sonstiges: %line% +# Farben: &-Codes und Hex &#FF6600 + +scoreboard.enabled=true +# Update-Intervall in Millisekunden - MINIMUM 250! (500 = 0.5s empfohlen) +scoreboard.update_interval=500 +scoreboard.title=&lViper Network +scoreboard.admin_title=&l[Admin] Panel +scoreboard.supporter_title=&l[Support] Panel + +# Laufschrift – leer lassen zum Deaktivieren +scoreboard.ticker.text= +scoreboard.ticker.width=26 +scoreboard.ticker.speed=1 + +scoreboard.rainbow.enabled=true +# wave = fließende Farbwelle | chars = Regenbogen pro Buchstabe | line = eine Farbe +scoreboard.rainbow.mode=wave +# Wellengeschwindigkeit: 1=sehr langsam, 10=normal, 50=schnell, 100=sehr schnell +scoreboard.rainbow.speed=10 +# Farben: Hex (#RRGGBB oder &#RRGGBB) oder Minecraft-Codes (&0-&f) – kommagetrennt +# Leer lassen = voller HSB-Regenbogen +scoreboard.rainbow.colors=&f,&b + +scoreboard.admin_permission=statusapi.scoreboard.admin +scoreboard.supporter_permission=statusapi.scoreboard.supporter + +scoreboard.time_format=HH:mm +scoreboard.date_format=dd.MM.yyyy +scoreboard.timezone=Europe/Berlin +scoreboard.money_format=#,##0.00 +scoreboard.money_decimal_separator=, + +# =================================================== +# SEPARATOR – wird als %line% Placeholder genutzt +# Wähle einen Stil oder erstelle deinen eigenen: +# +# scoreboard.separator=&8&m-------------------- (Standard) +# scoreboard.separator=&8&m==================== (Doppelt) +# scoreboard.separator=&8&m~~~~~~~~~~~~~~~~~~~~ (Wellig) +# scoreboard.separator=&8&m.................... (Punkte) +# scoreboard.separator=&8&m──────────────────── (Dünn) +# scoreboard.separator=&8&m════════════════════ (Dick) +# scoreboard.separator=&8◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇◆◇ (Diamanten) +# scoreboard.separator=%gradient:&8:&7:────────────────────% (Gradient) +# scoreboard.separator=%gradient:#FF0000:#0000FF:────────────────────% (Farbig) +# scoreboard.separator= (Leer/unsichtbar) +# =================================================== +scoreboard.separator=&8&m----------------------- + +# =================================================== +# NEWS-TICKER – erscheint als %news% Placeholder +# Leer lassen zum Deaktivieren +# =================================================== +scoreboard.news.text=&eWillkommen auf Viper Network! +scoreboard.news.prefix=&8[&6News&8] &r +scoreboard.news.width=26 +# Geschwindigkeit: 1=langsam, 2=normal, 3=schnell +scoreboard.news.speed=1 + +# Sekunden pro Rotation (0 = kein Wechsel) +scoreboard.rotation_interval=4 + +# =================================================== +# ZEILEN – max 15 sichtbar +# Rotation pro Zeile: +# scoreboard.lines.N = Variante 1 (immer sichtbar / nur Variante) +# scoreboard.lines.N.2 = Variante 2 (wechselt alle rotation_interval Sekunden) +# scoreboard.lines.N.3 = Variante 3 usw. +# Gradient: %gradient:FARBE1:FARBE2:TEXT% +# =================================================== +scoreboard.lines.1=%line% +scoreboard.lines.2=%gradient:&b:&f:&b:&l> Player Info:% +scoreboard.lines.3=&7%rank% &f%player% +scoreboard.lines.4= +scoreboard.lines.5=&7Spielzeit: &f%playtime% +scoreboard.lines.5.2=&7Leben: &c%health% +scoreboard.lines.5.3=&7Hunger: B4513%foodsym% +scoreboard.lines.6= +scoreboard.lines.7=%gradient:&b:&f:&b:&l> Money:% +scoreboard.lines.8=&a$%money% +scoreboard.lines.9= +scoreboard.lines.10=%gradient:&b:&f:&b:&l> Server Info:% +scoreboard.lines.11=&f%server% +scoreboard.lines.11.2=&7Ping: &f%ping%ms &8| &7Online: &f%online% +scoreboard.lines.12= +scoreboard.lines.13=%news% +scoreboard.lines.14=%line% +scoreboard.lines.15=&7%compass% + +# =================================================== +# SUPPORTER-ZEILEN +# =================================================== +scoreboard.supporter_lines.1=%line% +scoreboard.supporter_lines.2=%gradient:&b:&f:&b:&l> Player Info:% +scoreboard.supporter_lines.3=&7%rank% &f%player% +scoreboard.supporter_lines.4=&7Ping: &f%ping%ms &8| &7%server% +scoreboard.supporter_lines.5= +scoreboard.supporter_lines.6=%gradient:&b:&f:&b:&l> Ticket:% +scoreboard.supporter_lines.7=&7Offen: &c%ticket_open% +scoreboard.supporter_lines.8=&7In Bearbeitung: &e%ticket_claimed% +scoreboard.supporter_lines.9= +scoreboard.supporter_lines.10=%gradient:&b:&f:&b:&l> Server Info:% +scoreboard.supporter_lines.11=&7Online: &f%online% &8/ &7%maxplayers% +scoreboard.supporter_lines.12= +scoreboard.supporter_lines.13= +scoreboard.supporter_lines.14=%line% +scoreboard.supporter_lines.15=&7%compass% + +# =================================================== +# ADMIN-ZEILEN +# =================================================== +scoreboard.admin_lines.1=%line% +scoreboard.admin_lines.2=%gradient:&b:&f:&b:&l> Player Info:% +scoreboard.admin_lines.3=&7%rank% &f%player% +scoreboard.admin_lines.4=&7Gamemode: &f%gamemode% +scoreboard.admin_lines.5=&7Leben: &c%health% +scoreboard.admin_lines.5.2=&7Hunger: B4513%foodsym% +scoreboard.admin_lines.6= +scoreboard.admin_lines.7=%gradient:&b:&f:&b:&l> Server Info:% +scoreboard.admin_lines.8=&f%server% &8| &7RAM: &e%ram% +scoreboard.admin_lines.8.2=&7Proxy: &f%uptime% +scoreboard.admin_lines.9=&7TPS: &a%tps% +scoreboard.admin_lines.10= +scoreboard.admin_lines.11=%gradient:&b:&f:&b:&l> Ticket:% +scoreboard.admin_lines.12=&7Tickets Offen: &c%ticket_open% +scoreboard.admin_lines.12.2=&7Tickets In Bearbeitung: &e%ticket_claimed% +scoreboard.admin_lines.13=&7Spieler: %online% &8/ &7%maxplayers% +scoreboard.admin_lines.14=%line% +scoreboard.admin_lines.15=&7%compass% +scoreboard.admin_lines.15.2=&7Pos: X:&f%x% &7Y:&f%y% &7Z:&f%z% + +# =================================================== +# NAMETAG - Prefix ueber dem Spieler-Kopf +# =================================================== +# Zeigt den LuckPerms-Prefix ueber dem Spieler-Avatar an. +# Auf false setzen zum Deaktivieren. +nametag.enabled=true diff --git a/src/main/resources/verify.properties b/src/main/resources/verify.properties new file mode 100644 index 0000000..d3a9e88 --- /dev/null +++ b/src/main/resources/verify.properties @@ -0,0 +1,106 @@ +# _____ __ __ ___ ____ ____ +# / ___// /_____ _/ /___ _______/ | / __ \/ _/ +# \__ \/ __/ __ `/ __/ / / / ___/ /| | / /_/ // / +# ___/ / /_/ /_/ / /_/ /_/ (__ ) ___ |/ ____// / +# /____/\__/\__,_/\__/\__,_/____/_/ |_/_/ /___/ + + +broadcast.enabled=true +broadcast.prefix=[Broadcast] +broadcast.prefix-color=&c +broadcast.message-color=&f +broadcast.format=%prefixColored% %messageColored% +# broadcast.format kann angepasst werden; nutze Platzhalter: %name%, %prefix%, %prefixColored%, %message%, %messageColored%, %type% + +# =========================== +# StatusAPI Einstellungen +# =========================== +statusapi.port=9191 + +# =========================== +# INGAME HILFE +# =========================== +# Befehlsname für die Ingame-Hilfe (Standard: help) +# Beispiel: statusapi.help=vn → Befehl wird /vn +statusapi.help=sapi + +# Permission, die Admin-Befehle in der Hilfe sichtbar macht +# (OP und Spieler mit dieser Permission sehen die Admin-Sektion) +statusapi.help.permission=statusapi.admin + +# =========================== +# WORDPRESS / VERIFY EINSTELLUNGEN +# =========================== +wp_verify_url=https://example.com + +# Gemeinsames API-Secret (muss identisch sein mit mc_bridge_api_secret in den WP Forum-Einstellungen) +forum.api_secret=HIER_EIN_SICHERES_PASSWORT_SETZEN + +# Verzögerung in Sekunden bevor Login-Benachrichtigungen zugestellt werden +# (damit der Spieler den Server-Wechsel abgeschlossen hat) +forum.login_delay_seconds=3 + +# =========================== +# COMMAND BLOCKER +# =========================== +commandblocker.enabled=true +commandblocker.bypass.permission=commandblocker.bypass + +# =========================== +# SERVER KONFIGURATION +# =========================== +# Hier legst du für jeden Server alles fest: +# 1. Den Anzeigenamen für den Chat (z.B. &bLobby) +# 2. Die Server ID für WordPress (z.B. id=1) +# 3. Das Secret für WordPress (z.B. secret=...) + +# Server 1: Lobby +server.Lobby=&bLobby +server.Lobby.id=64 +server.Lobby.secret=GeheimesWortFuerLobby789 + +# Server 1: Citybuild +server.citybuild=&bCitybuild +server.citybuild.id=67 +server.citybuild.secret=GeheimesWortFuerCitybuild789 + +# Server 2: Survival +server.survival=&aSurvival +server.survival.id=68 +server.survival.secret=GeheimesWortFuerSurvival789 + +# Server 3: SkyBlock +server.skyblock=&dSkyBlock +server.skyblock.id=3 +server.skyblock.secret=GeheimesWortFuerSkyBlock789 + +# =========================== +# AUTOMESSAGE +# =========================== +# Aktiviert den automatischen Nachrichten-Rundruf +automessage.enabled=true + +# Zeitintervall in Sekunden (Standard: 300 = 5 Minuten) +automessage.interval=300 + +# Optional: Ein Prefix, das VOR jede Nachricht aus der Datei gesetzt wird. +# Wenn du das Prefix bereits IN der messages.txt hast, lass dieses Feld einfach leer. +automessage.prefix= + +# Der Name der Datei, in der die Nachrichten stehen (liegt im Plugin-Ordner) +automessage.file=messages.txt + + +# =========================== +# ECONOMY (Serverübergreifendes Geld) +# =========================== +# Alle Server (SurvivalPlus + StatusAPI/BungeeCord) müssen dieselbe Datenbank nutzen. +# Die Tabelle bc_accounts wird automatisch erstellt. +economy.mysql.host=localhost +economy.mysql.port=3306 +economy.mysql.database=survivalplus +economy.mysql.username=root +economy.mysql.password= +economy.start-balance=500.0 + + diff --git a/src/main/resources/welcome.yml b/src/main/resources/welcome.yml new file mode 100644 index 0000000..249a6d5 --- /dev/null +++ b/src/main/resources/welcome.yml @@ -0,0 +1,8 @@ +# Willkommensnachrichten, die zufällig gesendet werden, wenn ein Spieler joint. +# %player% wird durch den Spielernamen ersetzt. +welcome-messages: + - "&aWillkommen, %player%! Viel Spaß auf unserem Server!" + - "&aHey %player%, schön dich hier zu sehen! Los geht's!" + - "&a%player%, dein Abenteuer beginnt jetzt! Viel Spaß!" + - "&aWillkommen an Bord, %player%! Entdecke den Server!" + - "&a%player%, herzlich willkommen! Lass uns loslegen!" \ No newline at end of file